From 082074a00684af97458b85529093e2b2a9f28ed1 Mon Sep 17 00:00:00 2001
From: Bilawal Sidhu <106619546+bilawalsidhu@users.noreply.github.com>
Date: Tue, 22 Sep 2026 20:34:11 -0500
Subject: [PATCH] =?UTF-8?q?feat(layers):=20Recent=20Imagery=20=E2=80=94=20?=
=?UTF-8?q?open=20satellite=20imagery=20for=20a=20selected=20box=20(#716)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Select a box on the world and browse the last 30 days of open satellite imagery over it, keyless: NASA GIBS Harmonized Landsat Sentinel-2 (30 m) and the VIIRS daily overview (250 m), with a day strip, IMAGE / VS BASEMAP / A / B modes with a swipe divider, PNG export and share links. The before/after swipe from the Nepal scene moves into shared modules so both use it.
Co-authored-by: manjunath22466 <301209272+manjunath22466@users.noreply.github.com>
---
CHANGELOG.md | 16 +
DATA_SOURCES.md | 1 +
docs/CURRENT-STATE.md | 16 +-
package.json | 1 +
scripts/format-scope.json | 15 +-
scripts/package-boundaries.json | 43 +-
scripts/qa-recent-imagery.mjs | 1480 ++++++++++++++++++
src/app/constructCatalog.js | 2 +
src/app/constructCatalog.test.mjs | 2 +-
src/app/layerPresentation.js | 6 +
src/app/layers/recentImagery.js | 19 +
src/app/tools.js | 39 +
src/data/bhoteKoshiEvent.js | 168 +-
src/data/dataCredits.js | 9 +
src/data/layerState.js | 122 +-
src/data/layerState.test.mjs | 1324 ++++++++++++----
src/layers/recentImagery/catalog.js | 219 +++
src/layers/recentImagery/catalog.test.mjs | 296 ++++
src/layers/recentImagery/index.js | 1453 +++++++++++++++++
src/layers/recentImagery/index.test.mjs | 1387 ++++++++++++++++
src/layers/recentImagery/model.js | 613 ++++++++
src/layers/recentImagery/model.test.mjs | 439 ++++++
src/layers/recentImagery/rendering.js | 265 ++++
src/layers/recentImagery/rendering.test.mjs | 210 +++
src/layers/recentImagery/testDoubles.mjs | 368 +++++
src/layers/recentImagery/thumbnails.js | 279 ++++
src/layers/recentImagery/thumbnails.test.mjs | 317 ++++
src/maps/controller.js | 78 +-
src/maps/controller.test.mjs | 381 +++++
src/maps/imageryComparison.js | 124 ++
src/maps/imageryHost.js | 24 +
src/maps/imageryHost.test.mjs | 40 +
src/sharelink.celestial.test.mjs | 29 +
src/sharelink.js | 1 +
src/ui/imageryBoxTool.js | 293 ++++
src/ui/imageryBoxTool.test.mjs | 154 ++
src/ui/imagerySplit.js | 176 +++
src/ui/imagerySplit.test.mjs | 307 ++++
src/ui/layerPanel.js | 29 +-
src/ui/layerPanel.test.mjs | 53 +
src/ui/panelChrome.js | 3 +
src/ui/panelLayoutController.js | 8 +-
src/ui/panelRails.test.mjs | 44 +
src/ui/recentImagery.js | 1083 +++++++++++++
src/ui/recentImagery.test.mjs | 1153 ++++++++++++++
src/ui/styles/layers.css | 22 +-
src/ui/styles/recent-imagery.css | 483 ++++++
src/ui/styles/responsive.css | 2 +
src/ui/templates/context.html | 12 +
style.css | 1 +
50 files changed, 13169 insertions(+), 440 deletions(-)
create mode 100644 scripts/qa-recent-imagery.mjs
create mode 100644 src/app/layers/recentImagery.js
create mode 100644 src/layers/recentImagery/catalog.js
create mode 100644 src/layers/recentImagery/catalog.test.mjs
create mode 100644 src/layers/recentImagery/index.js
create mode 100644 src/layers/recentImagery/index.test.mjs
create mode 100644 src/layers/recentImagery/model.js
create mode 100644 src/layers/recentImagery/model.test.mjs
create mode 100644 src/layers/recentImagery/rendering.js
create mode 100644 src/layers/recentImagery/rendering.test.mjs
create mode 100644 src/layers/recentImagery/testDoubles.mjs
create mode 100644 src/layers/recentImagery/thumbnails.js
create mode 100644 src/layers/recentImagery/thumbnails.test.mjs
create mode 100644 src/maps/imageryComparison.js
create mode 100644 src/maps/imageryHost.js
create mode 100644 src/maps/imageryHost.test.mjs
create mode 100644 src/ui/imageryBoxTool.js
create mode 100644 src/ui/imageryBoxTool.test.mjs
create mode 100644 src/ui/imagerySplit.js
create mode 100644 src/ui/imagerySplit.test.mjs
create mode 100644 src/ui/recentImagery.js
create mode 100644 src/ui/recentImagery.test.mjs
create mode 100644 src/ui/styles/recent-imagery.css
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c4016296b..0bcdebf70 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,20 @@
# Changelog
+- Add a **Recent Imagery** data layer (NASA GIBS · HLS + VIIRS, keyless).
+ Select a box (drag, the current view, or around a pin; up to 1,000 km a
+ side) and the right-rail panel lists the last 30 days of Sentinel-2 /
+ Landsat (30 m) and, when switched on, VIIRS daily overview imagery over it,
+ with thumbnails and scene cloud. Three modes: IMAGE shows one day, VS
+ BASEMAP swipes it against the map, A / B swipes two days; a SHOW or A / B
+ chip on each day card pins it, arrow keys preview the focused day while a
+ slot is empty, and no control moves when anything changes. While imagery is
+ shown on Google 3D the map switches to Esri and comes back when it is
+ cleared. Either image exports as a PNG; box, pins, mode and split travel in
+ share links; the NASA acknowledgement is in the credits and
+ `DATA_SOURCES.md`. The swipe is now shared with the Nepal scene
+ (`src/ui/imagerySplit.js`, `src/maps/imageryComparison.js`), and
+ `MapSourceController.subscribe()` reports every settled map switch.
+
## Unreleased — weather review
- On 3D Tiles, draw a 4096×2048 detail window around the view on each
@@ -88,6 +103,7 @@
Add feed provenance to analyst/view answers and HUD context while retaining existing response fields and runner ownership (Matt Van Horn, #347).
Analyst records for loaded satellites, datacenters and dams, with explicit bounded count/rank coverage (Matt Van Horn, #351).
+
- Remove the spurious scrollbars that appeared on both panel stacks at narrow
widths (720px and below) as soon as a panel was expanded. The stacks scroll
vertically there, and each panel's decorative glow, absolutely positioned
diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md
index 1c89cd2e0..a9cf38145 100644
--- a/DATA_SOURCES.md
+++ b/DATA_SOURCES.md
@@ -31,6 +31,7 @@ How to read this:
| **The Space Devs — Launch Library 2 v2.3** | Recent launch, payload, stage, and recovery metadata for Space Missions (30d) | [The Space Devs terms of use](https://github.com/TheSpaceDevs/Tutorials/blob/main/faqs/faq_TSD.md#terms-of-use): data may be used and shared in any form; avoid forwarding it without added value; attribution is encouraged (not mandatory). [Official API limits](https://ll.thespacedevs.com/docs/): 15 unauthenticated calls/hour; optional token | "Launch Library 2 — The Space Devs" (courtesy attribution) |
| **Esri World Imagery** (ArcGIS Online tile service) | The keyless satellite basemap — the default landing when no Google/ion credential is configured, and the "Esri Satellite" map stack | [Esri Master Agreement](https://www.esri.com/en-us/legal/terms/full-master-agreement): the public World Imagery service is usable in public-facing apps with attribution; no key is required for this classic endpoint, but Esri governs and can change access — an app at scale should review current ArcGIS Location Platform terms | "Powered by Esri — Source: Esri, Maxar, Earthstar Geographics, and the GIS User Community" (provider carries the service's own credit line) |
| **USGS** | Earthquakes | U.S. public domain | "Data courtesy of the U.S. Geological Survey" |
+| **NASA GIBS** (WMTS tiles + Worldview Snapshots) and **NASA CMR** (granule catalog) | Recent Imagery layer: HLS S30/L30 (Sentinel-2, Landsat 8/9) and VIIRS true-colour tiles draped on the map, per-day thumbnails and PNG exports; the HLS granule catalog (dates, cloud, footprints) for a selected box | U.S. public domain (NASA data); keyless, browser-direct (CORS `*`); no bulk use — one 30-day catalog query per box, tiles bounded to the box, six tile requests in flight | "We acknowledge the use of imagery provided by services from NASA's Global Imagery Browse Services (GIBS), part of NASA's Earth Science Data and Information System (ESDIS)." — [gibs.earthdata.nasa.gov](https://gibs.earthdata.nasa.gov), [HLS](https://lpdaac.usgs.gov/products/hlss30v002/) |
| **OpenStreetMap (Overpass API)** | Road geometry for traffic | ODbL 1.0 | "© OpenStreetMap contributors" |
| **TomTom Traffic API** (flow vector tiles) | Live congestion coloring for the traffic layer (optional, BYOK) | [TomTom for Developers terms](https://developer.tomtom.com) (proprietary, your own key; free tier currently 200K tile requests/month — see [current pricing](https://docs.tomtom.com/pricing/)) | "Traffic flow data © TomTom" — registered when live mode activates |
| **OpenStreetMap (Overpass API)** | Viewport-bounded mapped installation context for Global Context | ODbL 1.0 | "© OpenStreetMap contributors" (incomplete mapped context) |
diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md
index 1ad6f8080..67acfffce 100644
--- a/docs/CURRENT-STATE.md
+++ b/docs/CURRENT-STATE.md
@@ -1149,7 +1149,7 @@ fetching, trailing-24-hour filtering and partial-success caching are unchanged.
> rule by a unit pin), and session-dismisses rather than contesting the key; if
> one is already up at init it **waits** instead of appearing over it. (2) A
> surface can take the screen with **no class to watch** — the Cesium attribution
-> lightbox is full-screen at `z-index: 200` against the card's `175`, which left
+> lightbox is at `z-index: 200` against the card's `175`, which left
> the launcher measurable (`getClientRects()` non-empty) and buried, so ESC
> dismissed a card nobody could see and burned the session flag. `isTopmost()`
> therefore also **hit-tests the card's own centre** with `elementFromPoint`; any
@@ -1961,7 +1961,7 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
> - **Compact data-attribution panel:** the complete Cesium credit inventory
> remains available without taking over the viewport. Its expanded desktop
> panel is capped at 70dvh/36rem, the wrapped 12px credit list scrolls inside
-> it, and narrow screens retain Cesium's full-screen surface with an internal
+> it, and narrow screens retain Cesium's surface with an internal
> scroller. The title, close control, links, and persistent Google/Cesium line
> remain unchanged and visible.
> - **Distant-aircraft recession:** both civilian and military billboard fleets
@@ -3655,6 +3655,18 @@ are omitted rather than framing the wrong part of the globe.
- Pure half: `src/annotations/drawMode.js` (session, minimum vertices, double-click de-dup at 0.5 m, vertex ceiling, degenerate-geometry refusal, ring closing, dateline-safe length/area/centroid, hint text). Regression surface: `src/annotations/drawMode.test.mjs` and `src/data/inputOwnership.test.mjs` (pure), `src/annotations/drawToolBehaviour.test.mjs` (the tool driven against DOM and viewer doubles — leases, borrowed input actions, teardown, the Enter key, preview heights), `src/annotations/drawTool.test.mjs` (structural: where files live, which ids the markup carries, what the docs may claim), and the rendered harness `scripts/qa-draw-tool.mjs`.
- The annotation tree is a declared ownership group: `./annotations` → `src/annotations/index.js` in `package.json`, with its module graph listed in `scripts/package-boundaries.json`, so an annotation module reaching outside that graph is a gate failure rather than a surprise in a layer's bundle. `drawMode.js`, `drawTool.js` and their tests are in `scripts/format-scope.json`.
+### Recent Imagery (September 2026)
+
+- The layer sits in the Data Layers panel's Cameras group beside Cameras (CCTV); Mapped ALPR Cameras moved to the Infrastructure group to make room.
+- DATA ▸ **Recent Imagery** (`recent-imagery`, `src/layers/recentImagery/`) lists the last 30 UTC days of imagery for a selected box and drapes the days you pick: HLS S30 (Sentinel-2) and L30 (Landsat 8/9) at 30 m, and the VIIRS NOAA-21 daily overview at 250 m. Keyless and browser-direct: NASA CMR for which days have HLS granules in the box (`catalog.js`, both collections concurrently, paged with `CMR-Search-After` up to 2,000 records each, `truncated` against CMR `hits`), GIBS WMTS for tiles, Worldview Snapshots for thumbnails and exports. Worldview's `Data-Present` header is the only availability signal: GIBS answers an empty tile with HTTP 200, and the JPEG overview would drape black, so a day confirmed empty never drapes.
+- **Box.** SELECT BOX (`src/ui/imageryBoxTool.js`) claims the pointer like Draw, borrows the stock click actions and freezes the camera only mid-drag; USE VIEW takes the camera rectangle; `boxFromPinAt` grows a 10 km box. Boxes must be ≤ 1,000 km a side, on one side of the dateline and inside the Web-Mercator limit (`validateBox`). A refusal (`boxError`, including the tool's own via `reportBoxRefusal`) stays at the top of the panel until a box succeeds; a view over the cap is refused with its size, never shrunk. An oversized box or view (`Box is … km wide · limit 1,000 km`, `View is … wide · …`) sets the hint to `Zoom in or draw a smaller box` and enables ZOOM IN: `zoomToFit()` flies top-down (1.2 s) to the refused box's centre, or the ground under the canvas centre for a view, at the height whose view is about 400 km wide for the canvas aspect and the camera's vertical FOV (`fitViewHeightM`, clamped 5–400 km), and returns that height.
+- **Modes, pins and the preview.** The mode is explicit: `image` (one pinned day, slot `a`), `basemap` (that day on the left of a swipe, the basemap on the right: slot `a` splits LEFT and there is no second layer) and `ab` (day `a` left, day `b` right). `setAssignment(slot, key | null)` / `toggleAssignment(slot, key)` pin and unpin; a day is in one slot at most (pinning it in the other slot moves it) and an empty day cannot be pinned. `setMode()` keeps both pins; `b` only drapes in `ab`. While the mode's first empty slot exists the focused day previews in it: a click at once, arrow keys after 250 ms of quiet (`FOCUS_DEBOUNCE_MS`, one timer, last day wins); an unprobed day is followed and previews when its probe says present, an empty or unknown day never drapes and the previous preview stays. A pin change drops the preview until focus moves again, and unpinning takes that layer off at once. `rankLatest` picks START HERE coverage first (a present HLS day whose footprints cover the box, or have none, beats a newer sliver), then newest with all granules ≤ 20% known cloud, then newest; slivers only as `partial`; else the newest non-empty VIIRS day; it is focused and previewed after each search when nothing is pinned. Escape clears the preview (`clearPreview()`), then cancels the box tool, then blurs — with SELECT BOX armed too, through the tool's `onEscape` interceptor wired in `createApplicationTools`. Pins, mode, box and split changes made in the panel are published with `adoptLayerParams`, never while a share link is being applied.
+- **Rendering.** `rendering.js` owns at most two GIBS layers on the host `src/maps/imageryHost.js` resolves: `viewer.imageryLayers` while the globe shows, the photoreal tileset's `imageryLayers` while it is hidden, or none (the panel says so). Layers are rebuilt on a new host, never re-parented. `MapSourceController.subscribe()` fires after every settled stack switch (silent switches and fallbacks included) and the layer rebinds on it. Tile requests pass a six-slot semaphore of our own (Cesium's global limits are untouched). Under `requestRenderMode` a deferred tile needs a later frame, but a deferral must never request one itself or a stalled tile keeps the scene rendering every frame: a request that finds the semaphore full asks for nothing (the settling request asks for one frame), and a tile Cesium itself deferred goes through one coalesced 250 ms retry. Thumbnails: three fetches at once, sixteen decoded images, least-recently-used where a `get` read is never a use; eviction keeps the probe result. The strip's visible window is probed around the focused card; the pinned and previewed days are probed wherever they sit, at the focused card's priority, so a pin restored or assigned off-screen still drapes.
+- **Esri surface and the swipe.** While any image is on the map (a preview included) the layer holds the map lease (`acquireImageryComparison`, owner `recent-imagery`, `esri-if-photoreal`): on Google 3D the map switches to Esri, because 30 m pixels over 3D buildings do not read, and Cesium's split only works on the globe. The lease is handed back, and Google 3D restored unless the operator changed maps meanwhile, when the last image leaves the map or the layer is disabled; the notice line says so while it holds. While a day is shown the imagery keeps Esri: a manual switch that lands on Google 3D is answered at once with a new lease under the same policy (`keepEsri`), the notice reads `Imagery stays on Esri · CLEAR to use Google 3D`, and the divider never suspends because of a map switch. It re-leases once per switch generation and only for a manual one (`MapSourceController#getSwitchOrigin()` is `manual` for an outside `setStack`, `automatic` for the controller's own fallback or recovery), never for the lease's own switch and never while releasing, so CLEAR and disable still return to Google 3D; a lease another owner holds stays refused. When Esri fails and the controller falls back to Google 3D on its own, the layer lets the lease go without restoring anything, both days drape on the tileset without a swipe and the guidance reads `Esri map unavailable · no swipe`; only the next manual switch to Google 3D takes Esri again, so a failing Esri map is never retried in a loop. Switching between globe stacks (Esri, OSM, Bing) only rebinds. On a tileset host both images drape without a swipe. The split control (`createImagerySplit`, labels A / B, or IMAGE / BASEMAP) exists only while a swipe is live, recentres on every new comparison (not while scrubbing the second day), except once for a split restored from a link. There is no keyboard shortcut (Space is the app's push-to-talk): SWAP (`#ri-swap`, in the controls row, its slot reserved and the button disabled while no swipe is live) trades the two sides, A to the right and B to the left, or the image to the right of the basemap, and a second click restores; the hint reads `Drag the divider · SWAP trades sides`. The swap is not in the share link, and every new comparison starts unswapped. One opacity applies to both drapes. Another owner holding the lease is reported and the images still drape.
+- **Panel.** `#recent-imagery-panel` is static rail markup in `src/ui/templates/context.html` beside `#weather-panel`, registered in `PanelLayoutController`, `PanelChrome` and the share panel state (`ui` token `i`), hidden while the layer is off and opened on first appearance unless a stored or shared collapse choice exists (`dataset.collapsedPreference`). `LayerPanel.attachRecentImagery(factory)` mounts the readout (`createRecentImageryPanel` in `src/ui/recentImagery.js`) into its `data-rail-scroller` body, as the weather readout is mounted. The body is a fixed stack in which no block changes height and no control moves in any state: `#ri-actions` (SELECT BOX · USE VIEW · CLEAR, rail-card actions), `#ri-notice` (one line: `#ri-notice-text` with the refusal, error, CLEAR notice or the Esri note, and `#ri-zoom-in` in a fixed slot at its right end, reserved unless an oversized box or view can be fitted), `#ri-strip` (day cards newest first: thumbnail, date, sensor, cloud, START HERE, PREVIEW, and a SHOW chip or A and B chips; ← → Home End move, S / A / B pin, Enter previews), `#ri-hint` (one line: the next step), `#ri-selection` (`#ri-mode` IMAGE · VS BASEMAP · A / B, then `#ri-slot-a` and `#ri-slot-b` with `#ri-unpin-a` / `#ri-unpin-b` kept in place when empty), `#ri-controls` (`#ri-opacity`, EXPORT / EXPORT A and EXPORT B) and `#ri-details`, a collapsed rail card holding the focused day's acquisition, coverage and granules, catalog notes, the empty-days toggle and the imagery credit. Absent controls are reserved (invisible), never removed. Every render preserves the body's `scrollTop`.
+- **Share links.** Layer options (token `1`): box edges as integers at degrees × 100000, pins `a` and `b` as compact days (`S20260918`), `m` mode (0 image, 1 basemap, 2 A / B), `p` split percent (share-link only: never stored locally) and `v` for the daily overview. Row chips: `MORE DETAIL · 30 m` (on) and `DAILY OVERVIEW · 250 m` (off).
+- Regression surface: the colocated `*.test.mjs` beside each module (shared doubles in `src/layers/recentImagery/testDoubles.mjs`; `src/ui/recentImagery.test.mjs` includes a DOM-structure guard: every control keeps its place in the tree, nothing is hidden, and every fixed-height block keeps the class the stylesheet fixes its height by; it has no layout, so it cannot see movement), `src/ui/layerPanel.test.mjs` (mount), `src/maps/controller.test.mjs` (`subscribe`), `src/data/layerState.test.mjs` (codec), `src/sharelink.celestial.test.mjs` (panel token), and the live gate `scripts/qa-recent-imagery.mjs`, which also measures every control's rect across the transitions within 1 px; that measurement is the real layout-shift check.
+
### 3D Aircraft + Tracking (June 2026)
- **The TRACKED contact's 2D↔3D handoff is DEFAULT behaviour (2026-08-19), driven by camera distance alone.** It does NOT consult the DISPLAY-rail `3D` toggle, which continues to own the FLEET (the un-instanced draw-call budget stays the operator's decision). Policy lives in `src/data/trackedModelRegime.js` and is shared by both layers: enter below `TRACKED_MODEL_ENTER_ALT_M` = 150,000 m and hand back to the billboard only above `TRACKED_MODEL_EXIT_ALT_M` = 172,500 m. **The swap distance was set by playtesting on 2026-08-20:** a first pass at 1,000,000 m switched too early; 2D reads correctly at ~600 km and the handoff belongs at ~150 km. **Consequence, recorded on purpose:** the tracked contact now enters 3D NEARER than the FLEET does (`MODEL_ALT_CEIL_M` = 800,000 m, unchanged), so with the DISPLAY-rail `3D` toggle on, 150–800 km draws surrounding contacts as models while the selected one is still a glyph. Nothing double-draws (the fleet pass skips the tracked icao) and aligning the two is a fleet-side decision, deliberately out of scope. **The two thresholds are asymmetric on purpose:** a single threshold makes a tracked orbit sitting ON the boundary strobe billboard↔model as the camera's altitude wobbles across it. Do not collapse them. The latch is scoped per selection, so a new target re-evaluates against the ENTER ceiling rather than inheriting the previous target's exit band. Exactly ONE model is involved; it loads on demand when the regime opens, is HIDDEN (not released) on regime exit so re-entry has no load gap, and is released by the existing teardown on deselect/re-track/destroy. Cockpit and TR-3B suppression are unchanged. **Two invariants around it:** (a) the hysteresis latch AND the load-failure latch are per-selection state cleared by `_resetTrackedSelectionState()` in the tracking lifecycle (deselect / re-track / cross-layer / init / destroy) — the predicate's icao-change guard is defence only, since it needs a drawn frame while nothing is selected and the render governor's idle mode does not promise one; (b) on-demand loading is bounded at 3 attempts per selection with a 1.5 s backoff and one console warning naming the asset — the driver runs every `scene.preUpdate`, so an unbounded catch means a missing GLB spins load→reject at frame rate. The billboard stays the visual throughout a failed load.
diff --git a/package.json b/package.json
index 3dd681f41..429c13aa7 100644
--- a/package.json
+++ b/package.json
@@ -152,6 +152,7 @@
"./aircraft": "./src/layers/aircraft/index.js",
"./layers/vessels": "./src/layers/vessels/index.js",
"./layers/earthquakes": "./src/layers/earthquakes/index.js",
+ "./layers/recent-imagery": "./src/layers/recentImagery/index.js",
"./sources/firms-csv": "./src/data/firmsCsv.js",
"./layers/firms": "./src/layers/firms/index.js",
"./layers/satellites": "./src/layers/satellites/index.js",
diff --git a/scripts/format-scope.json b/scripts/format-scope.json
index dc9cc2b0d..97bb0b8f2 100644
--- a/scripts/format-scope.json
+++ b/scripts/format-scope.json
@@ -144,6 +144,8 @@
"src/keylessGeocoder.js",
"src/standalone/placeSearch.js",
"src/ui/panelDisclosure.js",
+ "src/ui/imagerySplit.js",
+ "src/ui/imagerySplit.test.mjs",
"src/ui/surfaceKeyboard.js",
"src/ui/surfaceKeyboard.test.mjs",
"src/ui/panelRails.js",
@@ -555,6 +557,7 @@
"src/maps/catalog.js",
"src/maps/controller.js",
"src/maps/controller.test.mjs",
+ "src/maps/imageryComparison.js",
"src/maps/credits.js",
"src/maps/defaultSources.js",
"src/maps/google3d.js",
@@ -795,5 +798,15 @@
"scripts/qa-weather-journey.mjs",
"scripts/qa-weather-swap.mjs",
"scripts/qa-weather-teardown.mjs",
- "src/layers/weather/imageryHost.test.mjs"
+ "src/layers/weather/imageryHost.test.mjs",
+ "src/layers/recentImagery/model.test.mjs",
+ "src/layers/recentImagery/catalog.test.mjs",
+ "src/maps/imageryHost.test.mjs",
+ "src/layers/recentImagery/rendering.test.mjs",
+ "src/layers/recentImagery/thumbnails.test.mjs",
+ "src/layers/recentImagery/index.test.mjs",
+ "src/ui/imageryBoxTool.test.mjs",
+ "src/ui/recentImagery.test.mjs",
+ "src/layers/recentImagery/testDoubles.mjs",
+ "scripts/qa-recent-imagery.mjs"
]
diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json
index a2e1d9519..5273abbf8 100644
--- a/scripts/package-boundaries.json
+++ b/scripts/package-boundaries.json
@@ -631,7 +631,8 @@
"src/ui/styles/voice-cost.css",
"src/ui/styles/first-run.css",
"src/ui/styles/provider-settings.css",
- "src/ui/styles/weather.css"
+ "src/ui/styles/weather.css",
+ "src/ui/styles/recent-imagery.css"
],
"external": []
},
@@ -851,6 +852,18 @@
],
"external": ["cesium"]
},
+ "recent-imagery": {
+ "exports": ["./layers/recent-imagery"],
+ "modules": [
+ "src/layers/recentImagery/index.js",
+ "src/layers/recentImagery/model.js",
+ "src/layers/recentImagery/catalog.js",
+ "src/layers/recentImagery/rendering.js",
+ "src/layers/recentImagery/thumbnails.js",
+ "src/maps/imageryHost.js"
+ ],
+ "external": ["cesium"]
+ },
"fire-layer": {
"exports": ["./layers/firms"],
"modules": [
@@ -1162,13 +1175,15 @@
"src/maps/availability.js",
"src/maps/catalog.js",
"src/maps/controller.js",
+ "src/maps/imageryComparison.js",
"src/maps/credits.js",
"src/maps/defaultSources.js",
"src/maps/google3d.js",
"src/maps/imagery.js",
"src/maps/registry.js",
"src/maps/terrain.js",
- "src/keySetupCore.mjs"
+ "src/keySetupCore.mjs",
+ "src/maps/imageryHost.js"
],
"external": ["cesium"]
},
@@ -1228,6 +1243,7 @@
"modules": [
"src/scenes/nepalEvidencePack.js",
"src/data/bhoteKoshiEvent.js",
+ "src/ui/imagerySplit.js",
"src/data/bhoteKoshiEmbeddedMedia.js",
"src/data/bhoteKoshiLocator.js",
"src/data/bhoteKoshiIncidentPlaces.js",
@@ -1616,6 +1632,7 @@
"src/maps/availability.js",
"src/maps/catalog.js",
"src/maps/controller.js",
+ "src/maps/imageryComparison.js",
"src/maps/credits.js",
"src/maps/defaultSources.js",
"src/maps/google3d.js",
@@ -1872,7 +1889,16 @@
"src/layers/cyclones/index.js",
"src/layers/cyclones/rendering.js",
"src/layers/cyclones/labels.js",
- "src/layers/cyclones/source.js"
+ "src/layers/cyclones/source.js",
+ "src/app/layers/recentImagery.js",
+ "src/layers/recentImagery/index.js",
+ "src/layers/recentImagery/model.js",
+ "src/layers/recentImagery/catalog.js",
+ "src/layers/recentImagery/rendering.js",
+ "src/layers/recentImagery/thumbnails.js",
+ "src/maps/imageryHost.js",
+ "src/ui/recentImagery.js",
+ "src/ui/imageryBoxTool.js"
],
"external": [
"hls.js",
@@ -1893,6 +1919,8 @@
"exports": ["./application/layers"],
"modules": [
"src/data/bhoteKoshiEvent.js",
+ "src/ui/imagerySplit.js",
+ "src/maps/imageryComparison.js",
"src/data/bhoteKoshiEmbeddedMedia.js",
"src/data/bhoteKoshiLocator.js",
"src/data/bhoteKoshiIncidentPlaces.js",
@@ -2293,7 +2321,14 @@
"src/layers/cyclones/index.js",
"src/layers/cyclones/rendering.js",
"src/layers/cyclones/labels.js",
- "src/layers/cyclones/source.js"
+ "src/layers/cyclones/source.js",
+ "src/app/layers/recentImagery.js",
+ "src/layers/recentImagery/index.js",
+ "src/layers/recentImagery/model.js",
+ "src/layers/recentImagery/catalog.js",
+ "src/layers/recentImagery/rendering.js",
+ "src/layers/recentImagery/thumbnails.js",
+ "src/maps/imageryHost.js"
],
"external": [
"hls.js",
diff --git a/scripts/qa-recent-imagery.mjs b/scripts/qa-recent-imagery.mjs
new file mode 100644
index 000000000..1460ed3b5
--- /dev/null
+++ b/scripts/qa-recent-imagery.mjs
@@ -0,0 +1,1480 @@
+#!/usr/bin/env node
+/**
+ * Rendered acceptance for DATA ▸ Recent Imagery against the real NASA
+ * endpoints (CMR catalog, GIBS tiles, Worldview snapshots): the rail panel,
+ * USE VIEW and the live preview, the three modes (IMAGE, VS BASEMAP, A / B)
+ * with their pins, the divider and the Esri surface on Google 3D, Escape
+ * order, the oversized-view refusal and ZOOM IN, CLEAR, the share link,
+ * map-switch rebind (a manual switch to Google 3D keeps the imagery on
+ * Esri), CCTV stability at 900 and 700 px, scroll preservation, a
+ * layout-shift measurement across every transition, and disable.
+ *
+ * Run: start the app (`./scripts/dev-fresh.sh`) and
+ * node scripts/qa-recent-imagery.mjs
+ * Env: QA_BASE_URL (default http://localhost:4173), QA_SHOT_DIR (default
+ * qa-shots/recent-imagery), QA_HEADFUL=1. Every wait is bounded. Without
+ * network access to NASA the run skips with exit code 0.
+ */
+import fs from 'node:fs';
+import path from 'node:path';
+import puppeteer from 'puppeteer';
+
+const BASE_URL = process.env.QA_BASE_URL || 'http://localhost:4173';
+const SHOT_DIR = process.env.QA_SHOT_DIR || 'qa-shots/recent-imagery';
+const HEADFUL = process.env.QA_HEADFUL === '1';
+const STEP_TIMEOUT = 20_000;
+const AUSTIN = { lon: -97.74, lat: 30.27, height: 12_000 };
+const PANEL = '#recent-imagery-panel';
+
+async function networkAvailable() {
+ try {
+ const response = await fetch(
+ 'https://cmr.earthdata.nasa.gov/search/collections.json?page_size=1',
+ { signal: AbortSignal.timeout(8_000) },
+ );
+ return response.ok;
+ } catch {
+ return false;
+ }
+}
+
+if (!(await networkAvailable())) {
+ console.log(
+ 'SKIP: cmr.earthdata.nasa.gov is unreachable — the live Recent Imagery gate needs network access.',
+ );
+ process.exit(0);
+}
+
+const browser = await puppeteer.launch({
+ headless: !HEADFUL,
+ ...(HEADFUL
+ ? {
+ executablePath:
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
+ }
+ : {}),
+ args: [
+ '--no-sandbox',
+ '--disable-background-timer-throttling',
+ '--disable-renderer-backgrounding',
+ ...(HEADFUL ? ['--window-size=1280,860'] : []),
+ ...(process.platform === 'darwin'
+ ? ['--use-angle=metal', '--enable-gpu']
+ : ['--use-gl=angle', '--use-angle=swiftshader']),
+ ],
+});
+const page = await browser.newPage();
+const errors = [];
+page.on('pageerror', (error) => errors.push(`pageerror: ${error.message}`));
+let failures = 0;
+let checks = 0;
+function check(name, passed, detail = '') {
+ checks += 1;
+ console.log(
+ `[${passed ? 'PASS' : 'FAIL'}] ${name}${detail ? ` — ${detail}` : ''}`,
+ );
+ if (!passed) failures++;
+}
+fs.mkdirSync(SHOT_DIR, { recursive: true });
+const shots = [];
+const shot = async (name) => {
+ const file = path.join(SHOT_DIR, `${name}.jpg`);
+ await page.screenshot({ path: file, type: 'jpeg', quality: 82 });
+ shots.push(file);
+};
+const panelShot = async (name) => {
+ const panel = await page.$(PANEL);
+ if (!panel) return;
+ const file = path.join(SHOT_DIR, `${name}.png`);
+ await panel.screenshot({ path: file });
+ shots.push(file);
+};
+const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
+
+/** Poll a page predicate; false on timeout instead of throwing. */
+async function until(fn, arg, timeout = STEP_TIMEOUT) {
+ try {
+ await page.waitForFunction(fn, { timeout, polling: 250 }, arg);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Page-side helpers every predicate can use: the layer handle, GIBS layers
+ * per host with their split directions, and whether a header is on screen.
+ */
+function installPageHelpers() {
+ window.__riQa = {
+ layer: () => window.__gevRecentImagery.layer,
+ snap: () => window.__gevRecentImagery.layer.getSnapshot(),
+ owned: () => window.__gevRecentImagery.layer.diagnostics().ownedCount,
+ gibs(collection) {
+ const out = [];
+ for (let i = 0; i < (collection?.length || 0); i += 1) {
+ const layer = collection.get(i);
+ if (
+ String(layer?.imageryProvider?.url || '').includes(
+ 'earthdata.nasa.gov',
+ )
+ )
+ out.push(layer.splitDirection);
+ }
+ return out;
+ },
+ hosts() {
+ const gev = window.__godsEyeView;
+ const globe = this.gibs(gev.viewer.imageryLayers);
+ const tileset = this.gibs(gev.tileset?.imageryLayers);
+ return {
+ globe: globe.length,
+ tileset: tileset.length,
+ splits: [...globe, ...tileset],
+ globeShown: gev.viewer.scene.globe.show,
+ };
+ },
+ splitShown() {
+ const line = document.getElementById('recent-imagery-split-line');
+ return Boolean(line && !line.hidden);
+ },
+ headerOnScreen(id) {
+ const element = document.getElementById(id);
+ const header =
+ element?.querySelector('.panel-header, .pp-header-row') || element;
+ const rect = header?.getBoundingClientRect();
+ return rect
+ ? rect.height > 0 && rect.top >= 0 && rect.bottom <= window.innerHeight
+ : false;
+ },
+ activeStack: () =>
+ window.__godsEyeView.mapStackController?.getActiveId?.() ?? null,
+ };
+}
+
+const state = () =>
+ page.evaluate(() => {
+ const qa = window.__riQa;
+ const snapshot = qa.snap();
+ const gev = window.__godsEyeView;
+ const split = document.getElementById('recent-imagery-split-line');
+ const text = (id) => document.getElementById(id)?.textContent || '';
+ const hosts = qa.hosts();
+ return {
+ box: snapshot.box,
+ boxError: snapshot.boxError,
+ mode: snapshot.mode,
+ candidates: snapshot.candidates.map((c) => ({
+ key: c.key,
+ drapable: c.drapable,
+ })),
+ focus: snapshot.focus?.key || null,
+ pinA: snapshot.pins.a.key,
+ pinB: snapshot.pins.b.key,
+ preview: snapshot.preview.key,
+ shown: snapshot.shown,
+ notice: snapshot.notice,
+ comparison: snapshot.comparison,
+ borrowedEsri: snapshot.borrowedEsri,
+ error: snapshot.error,
+ diagnostics: qa.layer().diagnostics(),
+ cards: document.querySelectorAll('#recent-imagery-panel .ri-card').length,
+ panelHidden: document.getElementById('recent-imagery-panel')?.hidden,
+ noticeText: text('ri-notice-text'),
+ hintText: text('ri-hint'),
+ rowA: text('ri-slot-a'),
+ rowB: text('ri-slot-b'),
+ splitVisible: Boolean(
+ split && !split.hidden && split.getBoundingClientRect().height > 0,
+ ),
+ splitLabels: split
+ ? [
+ split.querySelector('span.before')?.textContent,
+ split.querySelector('span.after')?.textContent,
+ ]
+ : null,
+ splitLeft: split ? split.getBoundingClientRect().left : null,
+ splitPosition: gev.viewer.scene.splitPosition,
+ canvasWidth: gev.viewer.scene.canvas.clientWidth,
+ activeStack: qa.activeStack(),
+ gibsOnGlobe: hosts.globe,
+ gibsOnTileset: hosts.tileset,
+ gibsSplits: hosts.splits,
+ };
+ });
+const hostsDetail = (s) =>
+ `owned=${s.diagnostics.ownedCount} host=${s.diagnostics.host} globe=${s.gibsOnGlobe} tileset=${s.gibsOnTileset} splits=${s.gibsSplits}`;
+
+/** Put the camera straight down over a point at a height, no flight. */
+const setView = (view) =>
+ page.evaluate((target) => {
+ const viewer = window.__godsEyeView.viewer;
+ viewer.camera.cancelFlight?.();
+ viewer.scene.tweens?.removeAll?.();
+ viewer.camera.setView({
+ destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({
+ longitude: (target.lon * Math.PI) / 180,
+ latitude: (target.lat * Math.PI) / 180,
+ height: target.height,
+ }),
+ orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
+ });
+ viewer.scene.requestRender();
+ }, view);
+
+/** Ground width of the camera's view rectangle, in km (0 without one). */
+const viewWidthKm = () =>
+ page.evaluate(() => {
+ const rect = window.__godsEyeView.viewer.camera.computeViewRectangle?.();
+ if (!rect) return 0;
+ const lat = (rect.south + rect.north) / 2;
+ return Math.abs(rect.east - rect.west) * 6371 * Math.cos(lat);
+ });
+
+const click = (selector) =>
+ page.evaluate((target) => {
+ const button = document.querySelector(target);
+ if (!button || button.disabled || button.hidden) return false;
+ button.click();
+ return true;
+ }, selector);
+const clickAction = (id) => click(`${PANEL} [data-action-id="${id}"]`);
+const clickMode = (mode) => click(`${PANEL} .ri-mode-btn[data-mode="${mode}"]`);
+const focusStrip = () =>
+ page.evaluate(() =>
+ document.getElementById('ri-strip')?.focus({ preventScroll: true }),
+ );
+const pressOnStrip = async (key) => {
+ await focusStrip();
+ await page.keyboard.press(key);
+};
+
+/** Average colour of the canvas centre — the inside of a centred 10 km box. */
+async function probe() {
+ const base64 = await page.screenshot({
+ clip: { x: 540, y: 330, width: 200, height: 200 },
+ type: 'png',
+ encoding: 'base64',
+ });
+ return page.evaluate(async (data) => {
+ const bitmap = await createImageBitmap(
+ await (await fetch(`data:image/png;base64,${data}`)).blob(),
+ );
+ const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
+ const context = canvas.getContext('2d');
+ context.drawImage(bitmap, 0, 0);
+ const pixels = context.getImageData(0, 0, bitmap.width, bitmap.height).data;
+ const sum = [0, 0, 0];
+ for (let i = 0; i < pixels.length; i += 4)
+ for (let c = 0; c < 3; c += 1) sum[c] += pixels[i + c];
+ return sum.map((value) => Math.round(value / (pixels.length / 4)));
+ }, base64);
+}
+
+const tilesSettled = () =>
+ until(() => {
+ const gev = window.__godsEyeView;
+ if (gev.viewer.scene.globe.show) return gev.viewer.scene.globe.tilesLoaded;
+ return !gev.tileset || gev.tileset.tilesLoaded;
+ });
+const backOnPhotoreal = () =>
+ until(() => window.__riQa.activeStack() === 'photoreal');
+const onEsri = () =>
+ until(() => window.__riQa.activeStack() === 'esri-imagery');
+const ownedIs = (count) =>
+ until((n) => window.__riQa.owned() === n, count, 10_000);
+
+// ── layout-shift measurement ────────────────────────────────────────────────
+const CONTROLS = {
+ 'select-box': `${PANEL} [data-action-id="select-box"]`,
+ 'use-view': `${PANEL} [data-action-id="use-view"]`,
+ clear: `${PANEL} [data-action-id="clear"]`,
+ notice: '#ri-notice',
+ 'zoom-in': '#ri-zoom-in',
+ strip: '#ri-strip',
+ hint: '#ri-hint',
+ 'mode-image': `${PANEL} .ri-mode-btn[data-mode="image"]`,
+ 'mode-basemap': `${PANEL} .ri-mode-btn[data-mode="basemap"]`,
+ 'mode-ab': `${PANEL} .ri-mode-btn[data-mode="ab"]`,
+ 'row-a': '#ri-slot-a',
+ 'row-b': '#ri-slot-b',
+ 'unpin-a': '#ri-unpin-a',
+ 'unpin-b': '#ri-unpin-b',
+ opacity: '#ri-opacity',
+ swap: '#ri-swap',
+ 'export-a': `${PANEL} [data-action-id="export-a"]`,
+ 'export-b': `${PANEL} [data-action-id="export-b"]`,
+ details: '#ri-details .rail-card-header',
+};
+const measure = () =>
+ page.evaluate((controls) => {
+ const body = document.getElementById('recent-imagery-panel-body');
+ const out = { bodyScroll: body?.scrollTop ?? null, rects: {} };
+ for (const [name, selector] of Object.entries(controls)) {
+ const rect = document.querySelector(selector)?.getBoundingClientRect();
+ out.rects[name] = rect
+ ? [rect.left, rect.top, rect.width, rect.height].map(
+ (v) => Math.round(v * 10) / 10,
+ )
+ : null;
+ }
+ return out;
+ }, CONTROLS);
+let layoutBaseline = null;
+/** Every control's rect matches the baseline within 1 px. */
+async function checkLayout(label) {
+ await wait(150);
+ const now = await measure();
+ if (!layoutBaseline) {
+ layoutBaseline = now;
+ check(
+ `layout baseline recorded (${label})`,
+ Object.values(now.rects).every(Boolean),
+ JSON.stringify(now.rects),
+ );
+ return;
+ }
+ const moved = Object.entries(now.rects).filter(([name, rect]) => {
+ const base = layoutBaseline.rects[name];
+ return (
+ !rect || !base || rect.some((value, i) => Math.abs(value - base[i]) > 1)
+ );
+ });
+ check(
+ `no control moves: ${label}`,
+ moved.length === 0,
+ moved
+ .map(
+ ([name, rect]) =>
+ `${name} ${JSON.stringify(layoutBaseline.rects[name])}→${JSON.stringify(rect)}`,
+ )
+ .join(' '),
+ );
+}
+
+try {
+ await page.setViewport({ width: 1280, height: 860 });
+ await page.goto(`${BASE_URL}/?welcome=0`, { waitUntil: 'domcontentloaded' });
+ const booted = await until(
+ () =>
+ window.__gevRecentImagery &&
+ window.__godsEyeView &&
+ document.getElementById('loading-screen')?.classList.contains('hidden'),
+ null,
+ 90_000,
+ );
+ check('the app boots with the Recent Imagery handle', booted);
+ if (!booted) throw new Error('boot timeout');
+ await page.evaluate(installPageHelpers);
+
+ await setView(AUSTIN);
+ await tilesSettled();
+ const initialStack = await page.evaluate(() => window.__riQa.activeStack());
+ const photoreal = initialStack === 'photoreal';
+ console.log(`initial map stack: ${initialStack}`);
+ const onPhotorealOnly = (name, passed, detail) =>
+ photoreal
+ ? check(name, passed, detail)
+ : console.log(`[SKIP] ${name} — the boot stack is ${initialStack}`);
+
+ // ── enable: the panel in the rail, per the rail readout pattern ─────────
+ const layerToggle = '[data-layer-id="recent-imagery"] .data-toggle-btn';
+ check(
+ 'the data panel carries a Recent Imagery toggle',
+ await click(layerToggle),
+ );
+ check(
+ 'the layer enables from its toggle',
+ await until(() =>
+ window.__godsEyeView.dataManager.isEnabled('recent-imagery'),
+ ),
+ );
+ check(
+ 'the panel appears on the right rail once enabled',
+ await until(
+ () => document.getElementById('recent-imagery-panel')?.hidden === false,
+ ),
+ );
+ const railPlacement = await page.evaluate(() => {
+ const rail = document.getElementById('right-context-rail');
+ const panel = document.getElementById('recent-imagery-panel');
+ const body = document.getElementById('recent-imagery-panel-body');
+ const order = [...rail.children].map((child) => child.id);
+ const at = (id) => order.indexOf(id);
+ return {
+ inRail: panel?.parentElement === rail,
+ beforeContext: at('recent-imagery-panel') < at('global-context-panel'),
+ afterWeather: at('weather-panel') < at('recent-imagery-panel'),
+ collapsed: panel?.classList.contains('collapsed'),
+ scroller: body?.hasAttribute('data-rail-scroller'),
+ readoutInBody: body?.firstElementChild?.classList.contains(
+ 'recent-imagery-readout',
+ ),
+ body: body?.offsetHeight,
+ title: panel?.querySelector('.panel-title')?.textContent,
+ order,
+ };
+ });
+ check(
+ 'the panel sits in the rail after WEATHER and before CONTEXT, expanded on first appearance',
+ railPlacement.inRail &&
+ railPlacement.beforeContext &&
+ railPlacement.afterWeather &&
+ railPlacement.collapsed === false &&
+ railPlacement.body > 0,
+ JSON.stringify(railPlacement),
+ );
+ check(
+ 'the body is a rail scroller holding the readout, under a RECENT IMAGERY header',
+ railPlacement.scroller &&
+ railPlacement.readoutInBody &&
+ railPlacement.title === 'RECENT IMAGERY',
+ JSON.stringify(railPlacement),
+ );
+ const listOf = (selector, key) =>
+ page.evaluate(
+ (sel, field) =>
+ [...document.querySelectorAll(sel)]
+ .map((node) => node.dataset[field] || node.id)
+ .join(','),
+ selector,
+ key,
+ );
+ check(
+ 'the data-panel row carries only the two source chips',
+ (await listOf(
+ '[data-layer-id="recent-imagery"] .data-toggle-chip',
+ 'chipId',
+ )) === 'hls,viirs',
+ );
+ const actionIds = await listOf(
+ `${PANEL} .ri-actions-row [data-action-id]`,
+ 'actionId',
+ );
+ check(
+ 'the actions row holds SELECT BOX, USE VIEW and CLEAR',
+ actionIds === 'select-box,use-view,clear',
+ actionIds,
+ );
+ const blocks = await listOf(`${PANEL} .recent-imagery-readout > *`, 'none');
+ check(
+ 'the body is the fixed stack: actions, notice, strip, hint, selection, controls, details',
+ blocks ===
+ 'ri-actions,ri-notice,ri-strip,ri-hint,ri-selection,ri-controls,ri-details',
+ blocks,
+ );
+ let current = await state();
+ check(
+ 'before a box: CLEAR is disabled, the hint says what to do, IMAGE is the mode',
+ (await page.evaluate(
+ () =>
+ document.querySelector('#recent-imagery-panel [data-action-id="clear"]')
+ ?.disabled,
+ )) === true &&
+ current.hintText === 'Select a box or use the view' &&
+ current.mode === 'image',
+ `${current.hintText} ${current.mode}`,
+ );
+ await checkLayout('empty');
+ await panelShot('01-enabled-panel');
+
+ // ── USE VIEW at 12 km: days and the live preview ────────────────────────
+ const beforeColour = await probe();
+ const viewKm = await viewWidthKm();
+ check('USE VIEW is clickable', await clickAction('use-view'));
+ const searched = await until(() => {
+ const snapshot = window.__riQa.snap();
+ return !snapshot.searching && snapshot.candidates.length > 0;
+ });
+ current = await state();
+ check(
+ `USE VIEW at 12 km (${viewKm.toFixed(1)} km wide) sets a box and the catalog returns days within 20 s`,
+ searched && Boolean(current.box),
+ `${current.candidates.length} candidates, error=${current.error} boxError=${current.boxError}`,
+ );
+ check(
+ 'the strip renders one card per day',
+ current.cards === current.candidates.length && current.cards > 0,
+ `${current.cards} cards`,
+ );
+ const countText = await page.evaluate(
+ () => document.getElementById('recent-imagery-panel-count')?.textContent,
+ );
+ check(
+ 'the header count reads the day count',
+ /^\d+ DAYS?$/.test(countText),
+ countText,
+ );
+ const startHere = await page.evaluate(() =>
+ [...document.querySelectorAll('#recent-imagery-panel .ri-card-start')]
+ .filter((node) => !node.hidden)
+ .map((node) => node.closest('.ri-card').dataset.key),
+ );
+ check(
+ 'exactly one card wears START HERE',
+ startHere.length === 1,
+ startHere.join(','),
+ );
+ check(
+ 'the START HERE day previews on its own',
+ await until(() => {
+ const snap = window.__riQa.snap();
+ return (
+ Boolean(snap.preview.key) &&
+ snap.preview.slot === 'a' &&
+ window.__riQa.owned() === 1
+ );
+ }),
+ );
+ current = await state();
+ check(
+ 'row A reads the previewed day, row B reads the basemap',
+ /^IMAGE.+ · preview×$/.test(current.rowA) && current.rowB === 'VSBasemap×',
+ `${current.rowA} | ${current.rowB}`,
+ );
+ onPhotorealOnly(
+ 'on Google 3D a shown image borrows the Esri surface',
+ await onEsri(),
+ );
+ const esriNote = await until(
+ () =>
+ document.getElementById('ri-notice-text')?.textContent ===
+ 'Imagery on Esri · Google 3D returns when cleared',
+ undefined,
+ 5_000,
+ );
+ current = await state();
+ onPhotorealOnly(
+ 'the notice says the imagery is on Esri until cleared',
+ esriNote,
+ current.noticeText,
+ );
+ check(
+ 'one owned imagery layer on the globe, no divider',
+ current.diagnostics.ownedCount === 1 &&
+ current.gibsOnGlobe + current.gibsOnTileset === 1 &&
+ !current.splitVisible,
+ hostsDetail(current),
+ );
+ check(
+ 'at least one thumbnail settles (present/empty/error)',
+ await until(() =>
+ window.__riQa
+ .snap()
+ .candidates.some((c) => c.thumbnail.status !== 'unknown'),
+ ),
+ );
+ await tilesSettled();
+ let changedColour = null;
+ for (let deadline = Date.now() + STEP_TIMEOUT; Date.now() < deadline;) {
+ const colour = await probe();
+ if (Math.hypot(...colour.map((c, i) => c - beforeColour[i])) > 6) {
+ changedColour = colour;
+ break;
+ }
+ await page.evaluate(() =>
+ window.__godsEyeView.viewer.scene.requestRender(),
+ );
+ await wait(1000);
+ }
+ check(
+ 'the rendered frame inside the box changes once the preview drapes',
+ Boolean(changedColour),
+ `before=${beforeColour} after=${changedColour || (await probe())}`,
+ );
+ await checkLayout('preview');
+ await shot('02-preview');
+ await panelShot('02-preview-panel');
+
+ // ── arrow keys: the preview follows the focus ───────────────────────────
+ await until(() => {
+ const snap = window.__riQa.snap();
+ return snap.candidates.some(
+ (c) => c.key !== snap.preview.key && c.drapable,
+ );
+ });
+ current = await state();
+ const targetIndex = current.candidates.findIndex(
+ (c) => c.key !== current.preview && c.drapable,
+ );
+ check(
+ 'a present day other than the preview is reachable',
+ targetIndex >= 0,
+ String(targetIndex),
+ );
+ const targetKey = current.candidates[targetIndex]?.key;
+ await focusStrip();
+ await page.keyboard.press('Home');
+ for (let i = 0; i < targetIndex; i += 1)
+ await page.keyboard.press('ArrowRight');
+ const pressedAt = Date.now();
+ check(
+ 'arrow keys move focus at once',
+ await until(
+ (key) => window.__riQa.snap().focus?.key === key,
+ targetKey,
+ 2_000,
+ ),
+ targetKey,
+ );
+ const followed = await until(
+ (key) => window.__riQa.snap().preview.key === key,
+ targetKey,
+ 1_500,
+ );
+ const elapsed = Date.now() - pressedAt;
+ check(
+ 'the focused present day previews within 1 s',
+ followed && elapsed <= 1_250,
+ `preview after ${elapsed} ms`,
+ );
+ current = await state();
+ check(
+ 'still a single owned layer while scrubbing',
+ current.diagnostics.ownedCount === 1,
+ hostsDetail(current),
+ );
+ const flag = await page.evaluate(
+ (key) =>
+ document.querySelector(
+ `#recent-imagery-panel .ri-card[data-key="${key}"] .ri-card-flag`,
+ )?.textContent,
+ targetKey,
+ );
+ check('the previewed card wears PREVIEW', flag === 'PREVIEW', flag);
+
+ // ── IMAGE: S pins the focused day; focus no longer moves the map ────────
+ await pressOnStrip('s');
+ current = await state();
+ check(
+ 'S pins the focused day and its SHOW chip lights',
+ current.pinA === targetKey &&
+ (await page.evaluate(
+ (key) =>
+ document
+ .querySelector(
+ `#recent-imagery-panel .ri-card[data-key="${key}"] .ri-chip[data-slot="a"]`,
+ )
+ ?.getAttribute('aria-pressed'),
+ targetKey,
+ )) === 'true',
+ current.pinA,
+ );
+ check(
+ 'row A reads the pinned day without "preview"',
+ /^IMAGE.+×$/.test(current.rowA) && !/preview/.test(current.rowA),
+ current.rowA,
+ );
+ await checkLayout('pinned');
+ await page.keyboard.press(targetIndex > 0 ? 'ArrowLeft' : 'ArrowRight');
+ await wait(700);
+ current = await state();
+ check(
+ 'with the image pinned, focus moves without changing the map',
+ current.focus !== targetKey && current.shown.a === targetKey,
+ `focus=${current.focus} shown=${current.shown.a}`,
+ );
+ await page.evaluate(() => {
+ const slider = document.getElementById('ri-opacity');
+ slider.value = '60';
+ slider.dispatchEvent(new Event('input', { bubbles: true }));
+ });
+ current = await state();
+ check(
+ 'OPACITY sets the draped alpha',
+ Math.abs((await page.evaluate(() => window.__riQa.snap().alpha)) - 0.6) <
+ 1e-9,
+ );
+ await checkLayout('opacity');
+
+ // ── VS BASEMAP: the pinned day swipes against the basemap ───────────────
+ check('VS BASEMAP is one click', await clickMode('basemap'));
+ const basemapSwipe = await until(
+ () =>
+ window.__riQa.snap().shown.swipe === 'basemap' &&
+ window.__riQa.splitShown(),
+ );
+ current = await state();
+ check(
+ 'VS BASEMAP shows the divider, labelled IMAGE / BASEMAP',
+ basemapSwipe &&
+ JSON.stringify(current.splitLabels) === '["IMAGE","BASEMAP"]',
+ JSON.stringify(current.splitLabels),
+ );
+ check(
+ 'the image is on the left and the basemap on the right: one layer, split LEFT',
+ current.diagnostics.ownedCount === 1 &&
+ JSON.stringify(current.gibsSplits) === '[-1]',
+ hostsDetail(current),
+ );
+ check(
+ 'the divider starts centred',
+ Math.abs(current.splitPosition - 0.5) < 0.01 &&
+ Math.abs(current.splitLeft - current.canvasWidth / 2) <= 2,
+ `splitPosition=${current.splitPosition} left=${current.splitLeft} canvas=${current.canvasWidth}`,
+ );
+ check(
+ 'row B reads the basemap, lit',
+ current.rowB === 'VSBasemap×' &&
+ (await page.evaluate(() =>
+ document.getElementById('ri-slot-b').classList.contains('lit'),
+ )),
+ current.rowB,
+ );
+ onPhotorealOnly(
+ 'the swipe runs on Esri',
+ current.activeStack === 'esri-imagery',
+ current.activeStack,
+ );
+ check(
+ 'the hint says SWAP trades the sides (VS BASEMAP)',
+ current.hintText === 'Drag the divider · SWAP trades sides',
+ current.hintText,
+ );
+ await checkLayout('vs basemap');
+ await tilesSettled();
+ await shot('03-vs-basemap');
+ await panelShot('03-vs-basemap-panel');
+
+ // ── A / B: pin A, preview then pin B, swipe, SWAP, unpin ────────────────
+ check('A / B is one click', await clickMode('ab'));
+ current = await state();
+ check(
+ 'A / B keeps the pin as A and waits for B',
+ current.mode === 'ab' &&
+ current.pinA === targetKey &&
+ !current.splitVisible &&
+ /^A.+×$/.test(current.rowA) &&
+ current.rowB === 'BNot set×',
+ `${current.rowA} | ${current.rowB}`,
+ );
+ const chipLabels = await page.evaluate(
+ (key) =>
+ [
+ ...document.querySelectorAll(
+ `#recent-imagery-panel .ri-card[data-key="${key}"] .ri-chip`,
+ ),
+ ].map((chip) => chip.textContent),
+ targetKey,
+ );
+ check(
+ 'the cards now carry A and B chips',
+ JSON.stringify(chipLabels) === '["A","B"]',
+ JSON.stringify(chipLabels),
+ );
+ await checkLayout('A / B, A pinned');
+ const secondIndex = (await state()).candidates.findIndex(
+ (c) => c.key !== targetKey && c.drapable,
+ );
+ const secondKey = current.candidates[secondIndex]?.key;
+ await focusStrip();
+ await page.keyboard.press('Home');
+ for (let i = 0; i < secondIndex; i += 1)
+ await page.keyboard.press('ArrowRight');
+ check(
+ 'arrows preview the other day as B against A',
+ await until(
+ (key) => {
+ const snap = window.__riQa.snap();
+ return snap.preview.key === key && snap.preview.slot === 'b';
+ },
+ secondKey,
+ 3_000,
+ ),
+ secondKey,
+ );
+ await page.keyboard.press('b');
+ const abSwipe = await until(
+ (key) =>
+ window.__riQa.snap().pins.b.key === key &&
+ window.__riQa.snap().shown.swipe === 'ab' &&
+ window.__riQa.splitShown(),
+ secondKey,
+ );
+ current = await state();
+ check(
+ 'B pins the second day and the divider reads A / B',
+ abSwipe && JSON.stringify(current.splitLabels) === '["A","B"]',
+ JSON.stringify(current.splitLabels),
+ );
+ check(
+ 'two owned layers: A left, B right',
+ current.diagnostics.ownedCount === 2 &&
+ JSON.stringify([...current.gibsSplits].sort()) === '[-1,1]',
+ hostsDetail(current),
+ );
+ check(
+ 'the divider recentres for the new comparison',
+ Math.abs(current.splitPosition - 0.5) < 0.01,
+ String(current.splitPosition),
+ );
+ check(
+ 'row B reads the pinned day',
+ /^B.+×$/.test(current.rowB) && !/Not set/.test(current.rowB),
+ current.rowB,
+ );
+ onPhotorealOnly(
+ 'the A / B swipe runs on Esri',
+ current.activeStack === 'esri-imagery',
+ current.activeStack,
+ );
+ check(
+ 'the hint says SWAP trades the sides (A / B)',
+ current.hintText === 'Drag the divider · SWAP trades sides',
+ current.hintText,
+ );
+ await checkLayout('A / B, both pinned');
+ await tilesSettled();
+ await shot('04-ab');
+ await panelShot('04-ab-panel');
+ // SWAP trades the sides (Space is push-to-talk, so no key does this).
+ const splitBeforeSwap = current.splitPosition;
+ check('SWAP is one click while the swipe is live', await click('#ri-swap'));
+ const swapped = await until(
+ () =>
+ window.__riQa.snap().swapped === true &&
+ document.querySelector('#recent-imagery-split-line span.before')
+ ?.textContent === 'B',
+ );
+ current = await state();
+ check(
+ 'SWAP trades the divider labels to B / A and keeps the divider where it was',
+ swapped &&
+ JSON.stringify(current.splitLabels) === '["B","A"]' &&
+ JSON.stringify([...current.gibsSplits].sort()) === '[-1,1]' &&
+ Math.abs(current.splitPosition - splitBeforeSwap) < 0.01,
+ `${JSON.stringify(current.splitLabels)} ${hostsDetail(current)} split=${current.splitPosition}`,
+ );
+ await checkLayout('A / B, swapped');
+ check('SWAP again is one click', await click('#ri-swap'));
+ const restored = await until(
+ () =>
+ window.__riQa.snap().swapped === false &&
+ document.querySelector('#recent-imagery-split-line span.before')
+ ?.textContent === 'A',
+ );
+ current = await state();
+ check(
+ 'SWAP again restores A / B',
+ restored && JSON.stringify(current.splitLabels) === '["A","B"]',
+ JSON.stringify(current.splitLabels),
+ );
+ check('B unpins from its ×', await click('#ri-unpin-b'));
+ const singleA = await until(
+ () =>
+ window.__riQa.owned() === 1 &&
+ !document.getElementById('recent-imagery-split-line'),
+ );
+ current = await state();
+ check(
+ 'unpinning B drops its layer at once: A alone, no divider',
+ singleA && current.shown.a === targetKey && !current.shown.b,
+ hostsDetail(current),
+ );
+ check(
+ 'without a swipe SWAP keeps its slot, disabled and invisible',
+ await page.evaluate(() => {
+ const swap = document.getElementById('ri-swap');
+ return Boolean(
+ swap?.disabled &&
+ swap.classList.contains('is-reserved') &&
+ getComputedStyle(swap).visibility === 'hidden',
+ );
+ }),
+ );
+ onPhotorealOnly(
+ 'A alone stays on Esri',
+ current.activeStack === 'esri-imagery',
+ current.activeStack,
+ );
+ await checkLayout('B unpinned');
+ check('A unpins from its ×', await click('#ri-unpin-a'));
+ check('with nothing shown, no layer remains', await ownedIs(0));
+ onPhotorealOnly(
+ 'with nothing shown, Google 3D comes back',
+ await backOnPhotoreal(),
+ );
+ await checkLayout('A unpinned');
+
+ // ── the share link carries the pins and the mode ────────────────────────
+ await clickMode('basemap');
+ await pressOnStrip('a');
+ const shared = await until(
+ () => {
+ const params = new URLSearchParams(window.location.hash.slice(1));
+ const layers = (params.get('l') || '').split('.');
+ const options = params.get('lo') || '';
+ return (
+ layers.includes('1') &&
+ /(^|_)1\.a\.[SLV]\d{8}/.test(options) &&
+ /(^|_)1\.m\.1(_|$)/.test(options) &&
+ /(^|_)1\.w\.-?\d+/.test(options)
+ );
+ },
+ undefined,
+ 8_000,
+ );
+ check(
+ 'the share link carries the layer, the box, the pin and the mode',
+ shared,
+ await page.evaluate(() => window.location.hash.slice(0, 400)),
+ );
+
+ // ── Escape order: preview, then the box tool ────────────────────────────
+ await click('#ri-unpin-a');
+ await clickMode('image');
+ await ownedIs(0);
+ await focusStrip();
+ await page.keyboard.press('ArrowRight');
+ check(
+ 'a focus move previews again',
+ await until(
+ () => Boolean(window.__riQa.snap().preview.key),
+ undefined,
+ 5_000,
+ ),
+ );
+ check(
+ 'SELECT BOX arms the box tool',
+ (await clickAction('select-box')) &&
+ (await until(() => window.__gevRecentImagery.tool.isActive())),
+ );
+ await page.keyboard.press('Escape');
+ const escapedPreview = await until(
+ () => !window.__riQa.snap().preview.key,
+ undefined,
+ 3_000,
+ );
+ const stillArmed = await page.evaluate(() =>
+ window.__gevRecentImagery.tool.isActive(),
+ );
+ check(
+ 'Escape clears the preview first and leaves SELECT BOX armed',
+ escapedPreview && stillArmed,
+ `preview cleared=${escapedPreview} toolActive=${stillArmed}`,
+ );
+ await page.keyboard.press('Escape');
+ check(
+ 'the next Escape cancels the box tool',
+ await until(() => !window.__gevRecentImagery.tool.isActive()),
+ );
+ onPhotorealOnly(
+ 'with the preview cleared Google 3D comes back',
+ await backOnPhotoreal(),
+ );
+
+ // ── oversized USE VIEW → refusal in the notice line ─────────────────────
+ let refusalWidth = 0;
+ for (const height of [400_000, 900_000, 1_800_000]) {
+ await setView({ ...AUSTIN, height });
+ await wait(300);
+ refusalWidth = await viewWidthKm();
+ if (refusalWidth > 1_100) break;
+ }
+ const boxBefore = JSON.stringify((await state()).box);
+ await clickAction('use-view');
+ const refused = await until(
+ () =>
+ /^View is/.test(
+ document.getElementById('ri-notice-text')?.textContent || '',
+ ),
+ undefined,
+ 5_000,
+ );
+ current = await state();
+ check(
+ `an oversized USE VIEW (${Math.round(refusalWidth)} km) is refused in the notice line`,
+ refused &&
+ /^View is [\d,]+ km wide · limit 1,000 km$/.test(current.noticeText),
+ current.noticeText,
+ );
+ check(
+ 'the hint says to zoom in or draw a smaller box',
+ current.hintText === 'Zoom in or draw a smaller box',
+ current.hintText,
+ );
+ check('the refusal keeps the box', JSON.stringify(current.box) === boxBefore);
+ const refusalLine = await page.evaluate(() => {
+ const text = document.getElementById('ri-notice-text');
+ const chip = document.getElementById('ri-zoom-in');
+ const line = document.getElementById('ri-notice').getBoundingClientRect();
+ const rect = chip.getBoundingClientRect();
+ return {
+ fits: text.scrollWidth <= text.clientWidth,
+ scrollWidth: text.scrollWidth,
+ clientWidth: text.clientWidth,
+ chipVisible:
+ getComputedStyle(chip).visibility === 'visible' &&
+ !chip.disabled &&
+ rect.width > 0,
+ chipAtEnd: Math.abs(rect.right - line.right) <= 1,
+ lineHeight: line.height,
+ };
+ });
+ check(
+ 'the refusal fits the notice line without an ellipsis',
+ refusalLine.fits,
+ JSON.stringify(refusalLine),
+ );
+ check(
+ 'ZOOM IN shows at the right end of the notice line, which keeps its height',
+ refusalLine.chipVisible &&
+ refusalLine.chipAtEnd &&
+ Math.abs(refusalLine.lineHeight - 14) <= 0.5,
+ JSON.stringify(refusalLine),
+ );
+ await checkLayout('refused');
+ await panelShot('05-refused-panel');
+ // ZOOM IN: straight down to the height whose view is 400 km wide.
+ const fitHeight = await page.evaluate(() => {
+ const viewer = window.__godsEyeView.viewer;
+ const canvas = viewer.scene.canvas;
+ const halfWidth =
+ Math.tan(viewer.camera.frustum.fovy / 2) *
+ (canvas.clientWidth / canvas.clientHeight);
+ return Math.min(400, Math.max(5, 400 / (2 * halfWidth))) * 1000;
+ });
+ check('ZOOM IN is clickable', await click('#ri-zoom-in'));
+ const zoomed = await until(
+ (target) =>
+ Math.abs(
+ window.__godsEyeView.viewer.camera.positionCartographic.height - target,
+ ) <=
+ target * 0.01,
+ fitHeight,
+ 2_000,
+ );
+ const zoomedHeight = await page.evaluate(
+ () => window.__godsEyeView.viewer.camera.positionCartographic.height,
+ );
+ check(
+ `ZOOM IN flies to ${Math.round(fitHeight / 1000)} km within 2 s`,
+ zoomed,
+ `height=${Math.round(zoomedHeight)} target=${Math.round(fitHeight)}`,
+ );
+ await tilesSettled();
+ const fittedWidth = await viewWidthKm();
+ await clickAction('use-view');
+ const fitted = await until(
+ () => {
+ const snap = window.__riQa.snap();
+ return Boolean(snap.box) && !snap.boxError && !snap.zoomToFit;
+ },
+ undefined,
+ 5_000,
+ );
+ current = await state();
+ check(
+ `the next USE VIEW (${Math.round(fittedWidth)} km) succeeds and ZOOM IN is reserved again`,
+ fitted &&
+ (await page.evaluate(() =>
+ document.getElementById('ri-zoom-in').classList.contains('is-reserved'),
+ )),
+ `boxError=${current.boxError} notice=${current.noticeText}`,
+ );
+ await checkLayout('fitted');
+
+ // ── CLEAR ────────────────────────────────────────────────────────────────
+ await setView(AUSTIN);
+ await pressOnStrip('ArrowRight');
+ await until(() => window.__riQa.owned() === 1, undefined, 5_000);
+ await clickAction('clear');
+ const clearedAll = await until(() => {
+ const snap = window.__riQa.snap();
+ return (
+ !snap.box &&
+ !snap.pins.a.key &&
+ !snap.pins.b.key &&
+ !snap.preview.key &&
+ window.__riQa.owned() === 0
+ );
+ });
+ current = await state();
+ check(
+ 'CLEAR empties the box, the pins and the preview',
+ clearedAll && current.noticeText === 'Box and images cleared',
+ `box=${JSON.stringify(current.box)} owned=${current.diagnostics.ownedCount} notice=${current.noticeText}`,
+ );
+ onPhotorealOnly('CLEAR hands Google 3D back', await backOnPhotoreal());
+ await wait(1_500);
+ onPhotorealOnly(
+ 'after CLEAR the layer does not take Esri again',
+ (await page.evaluate(() => window.__riQa.activeStack())) === 'photoreal',
+ );
+ await checkLayout('cleared');
+
+ // ── map-switch rebind ───────────────────────────────────────────────────
+ await tilesSettled();
+ await page.evaluate(
+ (view) => window.__riQa.layer().boxFromPinAt(view.lon, view.lat),
+ AUSTIN,
+ );
+ const redraped = await until(() => {
+ const snap = window.__riQa.snap();
+ return (
+ !snap.searching &&
+ Boolean(snap.preview.key) &&
+ window.__riQa.owned() === 1
+ );
+ });
+ check('a 10 km box previews the START HERE day again', redraped);
+ const stacks = await page.evaluate(() =>
+ window.__godsEyeView.mapStackController
+ .getStacks()
+ .filter((s) => s.available)
+ .map((s) => s.id),
+ );
+ const setStack = (id) =>
+ page.evaluate(
+ (stack) => window.__godsEyeView.mapStackController.setStack(stack),
+ id,
+ );
+ /** Wait for the one GIBS drape to sit on the given host only. */
+ const drapeOn = (host) =>
+ until(
+ (where) => {
+ const hosts = window.__riQa.hosts();
+ return where === 'globe'
+ ? hosts.globeShown && hosts.globe === 1 && hosts.tileset === 0
+ : !hosts.globeShown && hosts.globe === 0 && hosts.tileset === 1;
+ },
+ host,
+ 10_000,
+ );
+ if (photoreal && stacks.includes('esri-imagery')) {
+ check('the preview sits on the Esri globe', await drapeOn('globe'));
+ // While a day is shown the imagery keeps Esri: a manual switch to
+ // Google 3D is answered at once with a new lease.
+ await setStack('photoreal');
+ const kept = (await onEsri()) && (await drapeOn('globe'));
+ current = await state();
+ check(
+ 'switching to Google 3D by hand while a day is shown keeps the imagery on the Esri globe',
+ kept && current.diagnostics.host === 'globe',
+ `${hostsDetail(current)} stack=${current.activeStack}`,
+ );
+ check(
+ 'the notice says the imagery stays on Esri until CLEAR, and fits',
+ current.noticeText === 'Imagery stays on Esri · CLEAR to use Google 3D' &&
+ (await page.evaluate(() => {
+ const text = document.getElementById('ri-notice-text');
+ return text.scrollWidth <= text.clientWidth;
+ })),
+ current.noticeText,
+ );
+ await wait(1_500);
+ check(
+ 'Esri holds after the re-lease: no switch loop',
+ (await page.evaluate(() => window.__riQa.activeStack())) ===
+ 'esri-imagery',
+ );
+ // The divider survives a manual switch too.
+ await clickMode('basemap');
+ await until(() => window.__riQa.splitShown(), undefined, 10_000);
+ await setStack('photoreal');
+ const dividerKept =
+ (await onEsri()) &&
+ (await until(
+ () =>
+ window.__riQa.splitShown() && window.__riQa.snap().comparison.active,
+ undefined,
+ 10_000,
+ ));
+ current = await state();
+ check(
+ 'a manual switch to Google 3D during VS BASEMAP keeps the divider',
+ dividerKept &&
+ current.shown.swipe === 'basemap' &&
+ !current.comparison.suspended,
+ `${hostsDetail(current)} stack=${current.activeStack} comparison=${JSON.stringify(current.comparison)}`,
+ );
+ await tilesSettled();
+ await shot('06-google-3d-kept-on-esri');
+ await clickMode('image');
+ await until(() => !window.__riQa.splitShown(), undefined, 5_000);
+ } else {
+ const other = stacks.find(
+ (id) => id !== initialStack && id !== 'photoreal',
+ );
+ if (other) {
+ await setStack(other);
+ check(
+ `switching to ${other} keeps the drape on the globe`,
+ await drapeOn('globe'),
+ );
+ await setStack(initialStack);
+ } else {
+ console.log('[SKIP] map-switch rebind — no second map stack available');
+ }
+ }
+
+ // ── collapse / expand, CCTV stability, DISPLAY + CONTEXT reachable ──────
+ /** Click a panel's collapse button; resolves to whether it is expanded. */
+ const togglePanel = (id) =>
+ page.evaluate((panelId) => {
+ const button = document.querySelector(
+ `.panel-collapse-btn[data-collapse-target="${panelId}"]`,
+ );
+ if (!button) return null;
+ button.click();
+ return !document.getElementById(panelId)?.classList.contains('collapsed');
+ }, id);
+ const imageryPanel = 'recent-imagery-panel';
+ const collapsedNow = (await togglePanel(imageryPanel)) === false;
+ await wait(400);
+ const headerOnly = await page.evaluate(() => ({
+ panel: document.getElementById('recent-imagery-panel')?.offsetHeight,
+ body: document.getElementById('recent-imagery-panel-body')?.offsetHeight,
+ }));
+ check(
+ 'collapsing the panel leaves a header bar only',
+ collapsedNow &&
+ headerOnly.body === 0 &&
+ headerOnly.panel > 0 &&
+ headerOnly.panel <= 80,
+ JSON.stringify(headerOnly),
+ );
+ const expandedAgain = await togglePanel(imageryPanel);
+ await wait(400);
+ const bodyBack = await page.evaluate(
+ () => document.getElementById('recent-imagery-panel-body')?.offsetHeight,
+ );
+ check(
+ 'expanding the panel brings the body back',
+ expandedAgain === true && bodyBack > 0,
+ `body=${bodyBack}`,
+ );
+
+ /** Sample both panel heights for 2 s; settled = the tail never changes. */
+ const settledHeights = async () => {
+ const samples = [];
+ for (let i = 0; i < 20; i += 1) {
+ samples.push(
+ await page.evaluate(() => ({
+ imagery: document.getElementById('recent-imagery-panel')
+ ?.offsetHeight,
+ cctv: document.getElementById('cctv-panel')?.offsetHeight,
+ })),
+ );
+ await wait(100);
+ }
+ const tail = (key) => new Set(samples.slice(8).map((s) => s[key]));
+ return {
+ settled: tail('imagery').size === 1 && tail('cctv').size === 1,
+ detail: `imagery=${samples.map((s) => s.imagery).join(',')} cctv=${samples.map((s) => s.cctv).join(',')}`,
+ };
+ };
+ const railFit = () =>
+ page.evaluate(() => {
+ const px = (element) =>
+ parseFloat(
+ element?.style.getPropertyValue('--right-panel-allocated-height'),
+ );
+ const cctv = document.getElementById('cctv-panel');
+ const imagery = document.getElementById('recent-imagery-panel');
+ const rail = document.getElementById('right-context-rail');
+ // Reachable: its header is on screen, or the rail's exclusive policy
+ // (an expanded panel under the tactical HUD) hid it while collapsed.
+ const reach = (id) => {
+ const element = document.getElementById(id);
+ return (
+ window.__riQa.headerOnScreen(id) ||
+ (rail?.classList.contains('layout-exclusive') &&
+ element?.classList.contains('collapsed') &&
+ element.getBoundingClientRect().height === 0)
+ );
+ };
+ return {
+ cctvRendered: cctv?.offsetHeight ?? null,
+ cctvAllocated: px(cctv),
+ imageryRendered: imagery?.offsetHeight ?? null,
+ imageryAllocated: px(imagery),
+ imageryCollapsed: Boolean(imagery?.classList.contains('collapsed')),
+ imageryDisplay: imagery ? getComputedStyle(imagery).display : null,
+ focus: rail?.classList.contains('layout-focus'),
+ exclusive: rail?.classList.contains('layout-exclusive'),
+ display: reach('pp-toggles'),
+ context: reach('global-context-panel'),
+ };
+ });
+ for (const height of [900, 700]) {
+ const at = `(${height} px viewport)`;
+ await page.setViewport({ width: 1280, height });
+ await wait(600);
+ check(
+ `the CCTV panel expands from its launcher ${at}`,
+ (await togglePanel('cctv-panel')) === true,
+ );
+ const expanded = await settledHeights();
+ check(
+ `the imagery and CCTV panel heights settle with CCTV expanded ${at}`,
+ expanded.settled,
+ expanded.detail,
+ );
+ const fit = await railFit();
+ check(
+ `CCTV renders at its allocated height beside the imagery panel ${at}`,
+ !fit.focus ||
+ (Number.isFinite(fit.cctvAllocated) &&
+ Math.abs(fit.cctvRendered - fit.cctvAllocated) <= 2),
+ JSON.stringify(fit),
+ );
+ // A collapsed panel has no allocation, so it is judged on its own terms:
+ // the exclusive rail (one expanded panel) hides it outright, otherwise it
+ // is a header bar. An expanded panel must have been allocated a height.
+ const imageryFits = fit.imageryCollapsed
+ ? fit.exclusive
+ ? fit.imageryRendered === 0 && fit.imageryDisplay === 'none'
+ : fit.imageryRendered > 0 && fit.imageryRendered <= 80
+ : !fit.focus ||
+ (Number.isFinite(fit.imageryAllocated) &&
+ fit.imageryRendered <= fit.imageryAllocated + 2);
+ check(
+ `the imagery panel stays within its allocation, or takes no room when the exclusive rail hides it collapsed ${at}`,
+ imageryFits,
+ JSON.stringify(fit),
+ );
+ check(
+ `DISPLAY and CONTEXT stay reachable ${at}`,
+ fit.display && fit.context,
+ JSON.stringify(fit),
+ );
+ await shot(`07-cctv-expanded-${height}`);
+ check(
+ `the CCTV panel collapses again ${at}`,
+ (await togglePanel('cctv-panel')) === false,
+ );
+ const collapsed = await settledHeights();
+ check(
+ `the panel heights settle with CCTV collapsed ${at}`,
+ collapsed.settled,
+ collapsed.detail,
+ );
+ const imageryExpanded = await page.evaluate(
+ () =>
+ !document
+ .getElementById('recent-imagery-panel')
+ ?.classList.contains('collapsed'),
+ );
+ if (imageryExpanded) await togglePanel(imageryPanel);
+ await wait(400);
+ const onScreen = await page.evaluate(() =>
+ Object.fromEntries(
+ [
+ 'pp-toggles',
+ 'global-context-panel',
+ 'recent-imagery-panel',
+ 'cctv-panel',
+ ].map((id) => [id, window.__riQa.headerOnScreen(id)]),
+ ),
+ );
+ check(
+ `DISPLAY, CONTEXT, CCTV and the imagery header are all on screen once everything is collapsed ${at}`,
+ Object.values(onScreen).every(Boolean),
+ JSON.stringify(onScreen),
+ );
+ await togglePanel(imageryPanel);
+ await wait(400);
+ }
+
+ // ── scroll preservation (700 px viewport, body scrollable) ───────────────
+ await page.evaluate(() => {
+ const header = document.querySelector('#ri-details .rail-card-header');
+ if (header?.getAttribute('aria-expanded') !== 'true') header?.click();
+ });
+ await wait(300);
+ const bodyScroll = () =>
+ page.evaluate(() => {
+ const body = document.getElementById('recent-imagery-panel-body');
+ return {
+ top: body.scrollTop,
+ max: body.scrollHeight - body.clientHeight,
+ };
+ });
+ const start = await page.evaluate(() => {
+ const body = document.getElementById('recent-imagery-panel-body');
+ const max = body.scrollHeight - body.clientHeight;
+ if (max > 0) body.scrollTop = Math.round(max / 2);
+ return { max, top: body.scrollTop };
+ });
+ check(
+ 'with DETAILS open at 700 px the body scrolls',
+ start.max > 0,
+ JSON.stringify(start),
+ );
+ if (start.max > 0) {
+ await page.evaluate(() => {
+ const layer = window.__riQa.layer();
+ layer.setAlpha(0.9);
+ layer.setToolActive(true);
+ layer.setToolActive(false);
+ });
+ await wait(300);
+ const afterNotify = await bodyScroll();
+ check(
+ 'the body scroll position survives alpha and state notifications',
+ Math.abs(afterNotify.top - start.top) <= 1,
+ `before=${start.top} after=${afterNotify.top}`,
+ );
+ await focusStrip();
+ await page.keyboard.press('ArrowRight');
+ await page.keyboard.press('ArrowRight');
+ await wait(600);
+ const afterArrows = await bodyScroll();
+ check(
+ 'the body scroll position survives arrow navigation',
+ Math.abs(afterArrows.top - start.top) <= 1,
+ `before=${start.top} after=${JSON.stringify(afterArrows)}`,
+ );
+ await shot('08-scrolled');
+ }
+ await page.setViewport({ width: 1280, height: 860 });
+
+ // ── disable ───────────────────────────────────────────────────────────────
+ await click(layerToggle);
+ check(
+ 'the layer disables from its toggle',
+ await until(
+ () => !window.__godsEyeView.dataManager.isEnabled('recent-imagery'),
+ ),
+ );
+ const cleared = await until(() => {
+ const hosts = window.__riQa.hosts();
+ return (
+ window.__riQa.owned() === 0 &&
+ hosts.globe + hosts.tileset === 0 &&
+ !document.getElementById('recent-imagery-split-line')
+ );
+ });
+ current = await state();
+ check(
+ 'disabling leaves zero owned layers and no split line',
+ cleared,
+ `${hostsDetail(current)} split=${current.splitVisible}`,
+ );
+ check('the panel hides when the layer is off', current.panelHidden === true);
+ onPhotorealOnly('disabling hands Google 3D back', await backOnPhotoreal());
+ await wait(1_500);
+ onPhotorealOnly(
+ 'disabled, the layer never takes Esri again',
+ (await page.evaluate(() => window.__riQa.activeStack())) === 'photoreal',
+ );
+ await shot('09-disabled');
+
+ check(
+ 'no page errors during the run',
+ errors.length === 0,
+ errors.join(' | '),
+ );
+} catch (error) {
+ check(
+ 'run completed',
+ false,
+ error?.stack || error?.message || String(error),
+ );
+} finally {
+ await browser.close();
+}
+console.log(`shots: ${shots.join(', ')}`);
+console.log(`RESULT: ${checks} checks, ${failures} failures`);
+process.exitCode = failures ? 1 : 0;
diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js
index b7a6fb3f1..26222c6cb 100644
--- a/src/app/constructCatalog.js
+++ b/src/app/constructCatalog.js
@@ -13,6 +13,7 @@ import { createApplicationRadio } from './layers/radio.js';
import { createApplicationTraffic } from './layers/traffic.js';
import { createApplicationBikeshare } from './layers/bikeshare.js';
import { createApplicationDirections } from './layers/directions.js';
+import { createApplicationRecentImagery } from './layers/recentImagery.js';
import { createApplicationTransit } from './layers/transit.js';
import { createApplicationInstallations } from './layers/militaryInstallations.js';
import { createApplicationSatellites } from './layers/satellites.js';
@@ -129,6 +130,7 @@ export function createApplicationCatalog({
createApplicationTransit({ surface, source: sources.transit }),
createApplicationBikeshare({ source: sources.bikeshare }),
createApplicationDirections(),
+ createApplicationRecentImagery(),
vessels,
installations,
createApplicationAwareness({
diff --git a/src/app/constructCatalog.test.mjs b/src/app/constructCatalog.test.mjs
index 34f140101..1d8d33f80 100644
--- a/src/app/constructCatalog.test.mjs
+++ b/src/app/constructCatalog.test.mjs
@@ -39,7 +39,7 @@ test('catalogs construct distinct layers and classification from their supplied
signal: b.signal,
surface: fixtureSurface(b.signal),
});
- assert.equal(first.layers.length, 26);
+ assert.equal(first.layers.length, 27);
assert.notEqual(first.weatherClock, second.weatherClock);
await first.weatherClock.setTarget('2026-09-21T12:00:00.000Z');
assert.match(
diff --git a/src/app/layerPresentation.js b/src/app/layerPresentation.js
index 13211f742..eaa0e0b57 100644
--- a/src/app/layerPresentation.js
+++ b/src/app/layerPresentation.js
@@ -70,6 +70,12 @@ export class LayerPresentation {
mount(container) {
this.panel.mount(container);
}
+ /** Hand the Recent Imagery readout factory to the panel (see LayerPanel). */
+ attachRecentImagery(factory) {
+ // Detaching never builds a panel that teardown already released.
+ if (factory) this.panel.attachRecentImagery(factory);
+ else this._panel?.attachRecentImagery(null);
+ }
refresh() {
this._panel?._refreshTogglePanel();
}
diff --git a/src/app/layers/recentImagery.js b/src/app/layers/recentImagery.js
new file mode 100644
index 000000000..1de4b22d4
--- /dev/null
+++ b/src/app/layers/recentImagery.js
@@ -0,0 +1,19 @@
+import { createRecentImageryLayer } from '../../layers/recentImagery/index.js';
+import { createRecentImageryRenderer } from '../../layers/recentImagery/rendering.js';
+import { createThumbnailLoader } from '../../layers/recentImagery/thumbnails.js';
+import { governorRequestRender } from '../../renderGovernor.js';
+
+/**
+ * Construct the Recent Imagery layer with the real GIBS renderer and the
+ * browser thumbnail loader. It takes no data source: the catalog is NASA
+ * CMR and the tiles are GIBS, both browser-direct and keyless.
+ * @returns {object} A fresh layer instance for this catalog.
+ */
+export function createApplicationRecentImagery() {
+ return createRecentImageryLayer({
+ renderer: createRecentImageryRenderer({
+ requestRender: governorRequestRender,
+ }),
+ thumbnails: createThumbnailLoader(),
+ });
+}
diff --git a/src/app/tools.js b/src/app/tools.js
index 27d0cc2d1..613add71c 100644
--- a/src/app/tools.js
+++ b/src/app/tools.js
@@ -1,6 +1,8 @@
import { SceneDirector } from '../scenes/director.js';
import { initAnnotations } from '../annotations/index.js';
import { initDrawTool } from '../annotations/drawTool.js';
+import { initImageryBoxTool } from '../ui/imageryBoxTool.js';
+import { createRecentImageryPanel } from '../ui/recentImagery.js';
import { initGevVoiceCommands } from '../voice/gevRealtime.js';
import { installScopeMask, destroyScopeMask } from '../scopeMask.js';
import {
@@ -53,6 +55,43 @@ export function createApplicationTools({
// lifetime rather than to whoever last pressed the button.
const drawTool = initDrawTool({ viewer, annotations });
defer(() => drawTool?.destroy());
+ // DATA ▸ Recent Imagery: the box tool claims the pointer like Draw and the
+ // panel lives on the right rail, so both belong to the application
+ // lifetime. The tileset lets the layer drape while the globe is hidden.
+ const recentImagery = dataManager.layers.get('recent-imagery')?.module;
+ if (recentImagery) {
+ recentImagery.attachTileset(tileset);
+ const imageryBoxTool = initImageryBoxTool({
+ viewer,
+ onBox: (box) => recentImagery.setBox(box),
+ onCancel: (reason, message, box) => {
+ if (message) recentImagery.reportBoxRefusal(message, box);
+ },
+ onActive: (active) => recentImagery.setToolActive(active),
+ // The tool takes Escape in a capture listener, so the panel's order
+ // (clear a preview before cancelling the tool) is applied here.
+ onEscape: () => recentImagery.clearPreview(),
+ });
+ // The readout mounts in its rail body through the layer panel, like the
+ // weather readout.
+ data.presentation.attachRecentImagery((container) =>
+ createRecentImageryPanel({
+ container,
+ viewer,
+ layer: recentImagery,
+ tool: imageryBoxTool,
+ }),
+ );
+ // The live gate's handle (scripts/qa-recent-imagery.mjs).
+ const recentImageryHandle = { layer: recentImagery, tool: imageryBoxTool };
+ window.__gevRecentImagery = recentImageryHandle;
+ defer(() => {
+ if (window.__gevRecentImagery === recentImageryHandle)
+ delete window.__gevRecentImagery;
+ data.presentation.attachRecentImagery(null);
+ imageryBoxTool?.destroy();
+ });
+ }
if (startChrome)
defer(startChrome({ loadingScreen, styleManager, dataManager, signal }));
// Idle render governor: flips the scene into requestRenderMode whenever
diff --git a/src/data/bhoteKoshiEvent.js b/src/data/bhoteKoshiEvent.js
index ce8d4829b..1f56099f9 100644
--- a/src/data/bhoteKoshiEvent.js
+++ b/src/data/bhoteKoshiEvent.js
@@ -22,6 +22,8 @@ import {
createBhoteKoshiEmbeddedMedia,
resolveEmbeddedMediaSource,
} from './bhoteKoshiEmbeddedMedia.js';
+import { acquireImageryComparison } from '../maps/imageryComparison.js';
+import { createImagerySplit } from '../ui/imagerySplit.js';
export const BHOTE_KOSHI_LAYER_ID = 'bhote-koshi-2026';
export const BHOTE_KOSHI_OVERLAY_SOURCE_ID = 'bhote-koshi-witnesses';
@@ -964,10 +966,7 @@ export function createBhoteKoshiEventLayer({
let _enabled = false;
let _panel = null;
let _panelRefs = null;
- let _splitLine = null;
- let _splitHandle = null;
- let _splitDragging = false;
- let _splitPointerId = null;
+ let _splitControl = null;
let _beforeLayer = null;
let _afterLayer = null;
let _imageryLayerCollection = null;
@@ -1022,14 +1021,11 @@ export function createBhoteKoshiEventLayer({
let _playStartedAt = 0;
let _playStartProgress = 0;
let _animationFrame = null;
- let _previousMapStack = null;
- let _eventMapGeneration = null;
- let _previousSplitPosition = null;
+ let _comparisonLease = null;
let _introTimer = null;
let _introGeneration = 0;
let _lastSurgePosition = null;
let _lastSurgeVisible = null;
- let _lastSplitCssValue = null;
let _presentation = 'standalone';
let _sceneBeatId = null;
let _sceneBeatReveal = 0.36;
@@ -2556,79 +2552,20 @@ export function createBhoteKoshiEventLayer({
createFloodDataSource();
}
- function ensureSplitLine(event) {
- _splitLine = document.createElement('div');
- _splitLine.id = 'bhote-koshi-split-line';
- const beforeLabel = document.createElement('span');
- const afterLabel = document.createElement('span');
- const splitHandle = document.createElement('button');
- beforeLabel.className = 'before';
- afterLabel.className = 'after';
- beforeLabel.textContent = 'A';
- beforeLabel.title = event.imagery.before.label;
- afterLabel.textContent = 'B';
- afterLabel.title = event.imagery.after.label;
- splitHandle.type = 'button';
- splitHandle.className = 'bhote-koshi-split-handle';
- splitHandle.setAttribute('role', 'slider');
- splitHandle.setAttribute(
- 'aria-label',
- 'Historical reference and post-event image divider',
- );
- splitHandle.setAttribute('aria-valuemin', '0');
- splitHandle.setAttribute('aria-valuemax', '100');
- splitHandle.setAttribute('aria-orientation', 'horizontal');
- splitHandle.textContent = '↔';
- splitHandle.addEventListener('pointerdown', handleSplitPointerDown);
- splitHandle.addEventListener('pointermove', handleSplitPointerMove);
- splitHandle.addEventListener('pointerup', finishSplitPointerDrag);
- splitHandle.addEventListener('pointercancel', finishSplitPointerDrag);
- splitHandle.addEventListener('keydown', handleSplitKeyDown);
- _splitHandle = splitHandle;
- _splitLine.append(beforeLabel, splitHandle, afterLabel);
- document.body.appendChild(_splitLine);
- }
-
- function setSplitFromClientX(clientX) {
- const viewportWidth =
- Number(document.documentElement?.clientWidth) ||
- Number(window.innerWidth) ||
- 0;
- if (viewportWidth <= 0 || !Number.isFinite(Number(clientX))) return;
- setSplit(Number(clientX) / viewportWidth);
- }
-
- function handleSplitPointerDown(event) {
- if (event.button != null && event.button !== 0) return;
- event.preventDefault?.();
- _splitDragging = true;
- _splitPointerId = event.pointerId ?? null;
- _splitHandle?.setPointerCapture?.(event.pointerId);
- setSplitFromClientX(event.clientX);
- }
-
- function handleSplitPointerMove(event) {
- if (!_splitDragging) return;
- if (_splitPointerId != null && event.pointerId !== _splitPointerId) return;
- event.preventDefault?.();
- setSplitFromClientX(event.clientX);
- }
-
- function finishSplitPointerDrag(event) {
- if (!_splitDragging) return;
- if (_splitPointerId != null && event.pointerId !== _splitPointerId) return;
- _splitHandle?.releasePointerCapture?.(event.pointerId);
- _splitDragging = false;
- _splitPointerId = null;
- }
-
- function handleSplitKeyDown(event) {
- if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
- event.preventDefault?.();
- const step = event.shiftKey ? 0.05 : 0.01;
- if (event.key === 'Home') setSplit(0);
- else if (event.key === 'End') setSplit(1);
- else setSplit(_split + (event.key === 'ArrowRight' ? step : -step));
+ function createSplitControl(event) {
+ _splitControl = createImagerySplit({
+ scene: _viewer.scene,
+ initialValue: _split,
+ id: 'bhote-koshi-split-line',
+ handleClass: 'bhote-koshi-split-handle',
+ cssProperty: '--bhote-koshi-split',
+ beforeTitle: event.imagery.before.label,
+ afterTitle: event.imagery.after.label,
+ ariaLabel: 'Historical reference and post-event image divider',
+ formatValueText: (beforePercent, afterPercent) =>
+ `A historical reference ${beforePercent} percent, B post-event ${afterPercent} percent`,
+ onChange: setSplit,
+ });
}
function applyCinematicCamera() {
@@ -3211,7 +3148,7 @@ export function createBhoteKoshiEventLayer({
_panelRefs.observedStatus.hidden = !imageryAvailable;
if (_panelRefs?.progressInput)
_panelRefs.progressInput.hidden = !scrubberAvailable;
- if (_splitLine) _splitLine.hidden = !imageryAvailable;
+ _splitControl?.setVisible(imageryAvailable);
if (_beforeLayer) _beforeLayer.show = imageryAvailable;
if (_afterLayer) _afterLayer.show = imageryAvailable;
if (_panelRefs?.caveat) {
@@ -3328,24 +3265,7 @@ export function createBhoteKoshiEventLayer({
function setSplit(split) {
_split = clampUnit(split);
- if (_viewer && _viewer.scene.splitPosition !== _split)
- _viewer.scene.splitPosition = _split;
- const cssValue = `${_split * 100}%`;
- if (_lastSplitCssValue !== cssValue) {
- document.documentElement.style.setProperty(
- '--bhote-koshi-split',
- cssValue,
- );
- _lastSplitCssValue = cssValue;
- }
- if (_splitHandle) {
- const beforePercent = Math.round(_split * 100);
- _splitHandle.setAttribute('aria-valuenow', String(beforePercent));
- _splitHandle.setAttribute(
- 'aria-valuetext',
- `A historical reference ${beforePercent} percent, B post-event ${100 - beforePercent} percent`,
- );
- }
+ _splitControl?.setValue(_split);
syncPanel();
renderHost.request('bhote-koshi-split');
}
@@ -3428,7 +3348,6 @@ export function createBhoteKoshiEventLayer({
async function enable(viewer, { origin = 'local-restore', signal } = {}) {
_viewer = viewer;
_enabled = true;
- _previousSplitPosition = viewer.scene.splitPosition;
// Public lifecycle passes signal, not intent origin, to enable. Its atomic
// restore has already applied the scene parameters; those own presentation.
// A missing origin is passive, never authority to focus or start playback.
@@ -3437,24 +3356,20 @@ export function createBhoteKoshiEventLayer({
const event = await loadEvent();
throwIfEnableCancelled(signal, _enabled);
registerDynamicCredit(viewer, BHOTE_KOSHI_CREDIT);
- _previousMapStack = _mapStackController?.getActiveId?.() || null;
- if (
- !sceneDirected &&
- _mapStackController &&
- _previousMapStack !== 'esri-imagery'
- ) {
- await _mapStackController.setStack('esri-imagery');
+ if (_mapStackController) {
+ _comparisonLease = acquireImageryComparison(_mapStackController, {
+ owner: BHOTE_KOSHI_LAYER_ID,
+ switchPolicy: sceneDirected ? 'preserve' : 'esri',
+ });
+ await _comparisonLease.ready;
}
- _eventMapGeneration = sceneDirected
- ? null
- : (_mapStackController?.getSwitchGeneration?.() ?? null);
throwIfEnableCancelled(signal, _enabled);
await createImagery(event);
throwIfEnableCancelled(signal, _enabled);
await syncTerrainComparison();
throwIfEnableCancelled(signal, _enabled);
createFloodDataSource();
- ensureSplitLine(event);
+ createSplitControl(event);
const panelView = createPanel(event, {
close: () =>
_dataManager?.setEnabled(BHOTE_KOSHI_LAYER_ID, false, {
@@ -3573,17 +3488,8 @@ export function createBhoteKoshiEventLayer({
?.classList.remove('bhote-event-active');
_panel = null;
_panelRefs = null;
- _splitDragging = false;
- _splitPointerId = null;
- _splitLine?.remove();
- _splitLine = null;
- _splitHandle = null;
- document.documentElement.style.removeProperty('--bhote-koshi-split');
- _lastSplitCssValue = null;
- if (_viewer?.scene && _previousSplitPosition != null) {
- _viewer.scene.splitPosition = _previousSplitPosition;
- }
- _previousSplitPosition = null;
+ _splitControl?.destroy();
+ _splitControl = null;
_progress = 0;
_split = 0.5;
_presentation = 'standalone';
@@ -3601,20 +3507,10 @@ export function createBhoteKoshiEventLayer({
cancel?.(_sceneSeekFrame);
_sceneSeekFrame = null;
}
- const previousMapStack = _previousMapStack;
- const eventMapGeneration = _eventMapGeneration;
- _previousMapStack = null;
- _eventMapGeneration = null;
+ const comparisonLease = _comparisonLease;
+ _comparisonLease = null;
try {
- if (
- _mapStackController &&
- previousMapStack &&
- previousMapStack !== 'esri-imagery' &&
- _mapStackController.getActiveId?.() === 'esri-imagery' &&
- _mapStackController.getSwitchGeneration?.() === eventMapGeneration
- ) {
- await _mapStackController.setStack(previousMapStack);
- }
+ await comparisonLease?.release();
} finally {
renderHost.request('bhote-koshi-disable');
}
diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js
index 79931d218..e5ef9d2b2 100644
--- a/src/data/dataCredits.js
+++ b/src/data/dataCredits.js
@@ -77,6 +77,15 @@ export const DATA_CREDITS = [
key: 'usgs',
html: 'Earthquakes: Data courtesy of the U.S. Geological Survey',
},
+ {
+ key: 'nasa-gibs',
+ html:
+ 'Recent imagery: We acknowledge the use of imagery provided by services from ' +
+ "NASA's Global Imagery Browse Services (GIBS), part of NASA's Earth Science " +
+ 'Data and Information System (ESDIS). ' +
+ 'gibs.earthdata.nasa.gov · ' +
+ 'HLS product page',
+ },
{
key: 'overpass',
html:
diff --git a/src/data/layerState.js b/src/data/layerState.js
index 9dfa099af..da1b86b9e 100644
--- a/src/data/layerState.js
+++ b/src/data/layerState.js
@@ -212,6 +212,84 @@ function integerOption(key, token, defaultValue) {
});
}
+/**
+ * A share-link-only option: the whole-state codec carries it, but the stored
+ * local blob always holds its default.
+ */
+function shareOnlyOption(spec) {
+ return Object.freeze({ ...spec, shareOnly: true });
+}
+
+/**
+ * A signed integer in `[min, max]`, rejected (never clamped) outside it: a
+ * clamped box edge would be a different box, not a shorter spelling.
+ */
+function boundedIntegerOption(key, token, defaultValue, { min, max }) {
+ const parse = (value) => {
+ const number =
+ typeof value === 'number'
+ ? value
+ : /^-?\d{1,9}$/.test(String(value).trim())
+ ? Number(String(value).trim())
+ : NaN;
+ return Number.isInteger(number) && number >= min && number <= max
+ ? number
+ : null;
+ };
+ return Object.freeze({
+ key,
+ token,
+ defaultValue,
+ normalize: parse,
+ encode: (value) => String(value),
+ decode: parse,
+ });
+}
+
+/**
+ * Recent Imagery day: runtime key `S30:2026-09-18`, URL form `S20260918`.
+ * The calendar is checked both ways, and "today" is never consulted, so a
+ * link decodes the same whenever it is opened.
+ */
+const IMAGERY_LETTERS = Object.freeze({ S30: 'S', L30: 'L', VIIRS: 'V' });
+const IMAGERY_PRODUCTS = Object.freeze({ S: 'S30', L: 'L30', V: 'VIIRS' });
+
+function imageryKey(product, year, month, day) {
+ const time = Date.UTC(Number(year), Number(month) - 1, Number(day));
+ const iso = Number.isFinite(time)
+ ? new Date(time).toISOString().slice(0, 10)
+ : '';
+ return product && iso === `${year}-${month}-${day}`
+ ? `${product}:${iso}`
+ : null;
+}
+
+function imageryPinOption(key, token) {
+ return Object.freeze({
+ key,
+ token,
+ defaultValue: null,
+ normalize: (value) => {
+ const match = /^(S30|L30|VIIRS):(\d{4})-(\d{2})-(\d{2})$/.exec(
+ typeof value === 'string' ? value.trim() : '',
+ );
+ return match ? imageryKey(match[1], match[2], match[3], match[4]) : null;
+ },
+ encode: (value) => {
+ const [product, day] = value.split(':');
+ return `${IMAGERY_LETTERS[product]}${day.replaceAll('-', '')}`;
+ },
+ decode: (value) => {
+ const match = /^([SLV])(\d{4})(\d{2})(\d{2})$/.exec(
+ typeof value === 'string' ? value : '',
+ );
+ return match
+ ? imageryKey(IMAGERY_PRODUCTS[match[1]], match[2], match[3], match[4])
+ : null;
+ },
+ });
+}
+
const OPTION_GROUPS = Object.freeze({
'weather-lightning': Object.freeze([
enumOption('opacity', 'o', 'strong', ['light', 'strong'], {
@@ -303,6 +381,23 @@ const OPTION_GROUPS = Object.freeze({
booleanOption('showProjection', 'p', true),
booleanOption('autoHop', 'a', false),
]),
+ 'recent-imagery': Object.freeze([
+ // Box edges in degrees × 100000; latitudes stop at the Web-Mercator limit.
+ boundedIntegerOption('west', 'w', null, { min: -18000000, max: 18000000 }),
+ boundedIntegerOption('south', 's', null, { min: -8505110, max: 8505110 }),
+ boundedIntegerOption('east', 'e', null, { min: -18000000, max: 18000000 }),
+ boundedIntegerOption('north', 'n', null, { min: -8505110, max: 8505110 }),
+ imageryPinOption('a', 'a'),
+ imageryPinOption('b', 'b'),
+ // 0 one image, 1 image against the basemap, 2 two images A / B.
+ boundedIntegerOption('mode', 'm', 0, { min: 0, max: 2 }),
+ // The swipe position is one comparison's framing, not a preference: a
+ // stored split resurfaced in the next session's first comparison.
+ shareOnlyOption(
+ boundedIntegerOption('split', 'p', 50, { min: 0, max: 100 }),
+ ),
+ booleanOption('viirs', 'v', false),
+ ]),
radio: Object.freeze([
Object.freeze({
key: 'filter',
@@ -420,6 +515,12 @@ export const LAYER_STATE_REGISTRY = Object.freeze([
disposition: 'enabled+options',
optionOwner: 'radio',
}),
+ Object.freeze({
+ id: 'recent-imagery',
+ token: '1',
+ disposition: 'enabled+options',
+ optionOwner: 'recent-imagery',
+ }),
Object.freeze({
id: 'rocket-launches',
token: 'x',
@@ -699,13 +800,28 @@ export function decodeLayerStateParams(params) {
return normalizeLayerState({ enabledLayerIds, options: rawOptions });
}
-/** Stable local-storage representation (full IDs for debuggability). */
+/** Options with share-link-only values reset to their defaults. */
+function withoutShareOnlyOptions(options) {
+ const out = {};
+ for (const ownerId of OPTION_OWNER_IDS) {
+ out[ownerId] = { ...(options?.[ownerId] || {}) };
+ for (const spec of optionSpecs(ownerId)) {
+ if (spec.shareOnly) out[ownerId][spec.key] = spec.defaultValue;
+ }
+ }
+ return out;
+}
+
+/**
+ * Stable local-storage representation (full IDs for debuggability), with
+ * share-link-only options at their defaults.
+ */
export function serializeStoredLayerState(state) {
const normalized = normalizeLayerState(state);
return JSON.stringify({
v: LAYER_STATE_VERSION,
l: normalized.enabledLayerIds,
- o: normalized.options,
+ o: withoutShareOnlyOptions(normalized.options),
});
}
@@ -717,7 +833,7 @@ export function parseStoredLayerState(raw) {
return null;
return normalizeLayerState({
enabledLayerIds: parsed.l,
- options: parsed.o,
+ options: withoutShareOnlyOptions(parsed.o),
});
} catch {
return null;
diff --git a/src/data/layerState.test.mjs b/src/data/layerState.test.mjs
index 81110286b..51d39bc61 100644
--- a/src/data/layerState.test.mjs
+++ b/src/data/layerState.test.mjs
@@ -25,7 +25,10 @@ import { stampInitialShareGesture } from '../navigationPolicy.js';
function deferred() {
let resolve;
let reject;
- const promise = new Promise((res, rej) => { resolve = res; reject = rej; });
+ const promise = new Promise((res, rej) => {
+ resolve = res;
+ reject = rej;
+ });
return { promise, resolve, reject };
}
@@ -33,7 +36,8 @@ function paramsForLayer(id) {
if (id === 'flights' || id === 'military') {
return { models3d: false, models3dMode: 'proximity', irBoost: true };
}
- if (id === 'satellites') return { catalog: 'core', showPoints: false, showOrbits: false };
+ if (id === 'satellites')
+ return { catalog: 'core', showPoints: false, showOrbits: false };
if (id === 'cctv') {
return {
coverageMode: 'on',
@@ -64,41 +68,62 @@ function fakeLayer(id, hooks = {}) {
name: id,
icon: '',
source: 'test',
- async init() { return hooks.init ? hooks.init() : true; },
- async enable() { return hooks.enable ? hooks.enable() : true; },
- async update() { return hooks.update ? hooks.update() : true; },
- async disable() { return hooks.disable ? hooks.disable() : true; },
- ...(hooks.resolveTrackingRestoreTarget ? {
- async resolveTrackingRestoreTarget(targetId, options) {
- return hooks.resolveTrackingRestoreTarget(targetId, options);
- },
- } : {}),
- ...(params ? {
- setParams(next = {}, options = {}) {
- if (hooks.setParams) {
- const result = hooks.setParams(next, options);
- if (result === false) return false;
- // 'defer' models the production tracking latch: the layer ACCEPTS the
- // request and holds it pending, but getParams() keeps reporting the
- // previous (still-untracked) value until the subject really arrives.
- if (result === 'defer') return true;
+ async init() {
+ return hooks.init ? hooks.init() : true;
+ },
+ async enable() {
+ return hooks.enable ? hooks.enable() : true;
+ },
+ async update() {
+ return hooks.update ? hooks.update() : true;
+ },
+ async disable() {
+ return hooks.disable ? hooks.disable() : true;
+ },
+ ...(hooks.resolveTrackingRestoreTarget
+ ? {
+ async resolveTrackingRestoreTarget(targetId, options) {
+ return hooks.resolveTrackingRestoreTarget(targetId, options);
+ },
}
- params = { ...params, ...next };
- return true;
- },
- /** Test seam: a deferred subject finally arrives on a later poll. */
- _arrive(next) { params = { ...params, ...next }; },
- getParams() { return { ...params }; },
- ...(hooks.cancelPendingTrackingRestore ? {
- cancelPendingTrackingRestore(options) { hooks.cancelPendingTrackingRestore(options); },
- } : {}),
- } : {}),
+ : {}),
+ ...(params
+ ? {
+ setParams(next = {}, options = {}) {
+ if (hooks.setParams) {
+ const result = hooks.setParams(next, options);
+ if (result === false) return false;
+ // 'defer' models the production tracking latch: the layer ACCEPTS the
+ // request and holds it pending, but getParams() keeps reporting the
+ // previous (still-untracked) value until the subject really arrives.
+ if (result === 'defer') return true;
+ }
+ params = { ...params, ...next };
+ return true;
+ },
+ /** Test seam: a deferred subject finally arrives on a later poll. */
+ _arrive(next) {
+ params = { ...params, ...next };
+ },
+ getParams() {
+ return { ...params };
+ },
+ ...(hooks.cancelPendingTrackingRestore
+ ? {
+ cancelPendingTrackingRestore(options) {
+ hooks.cancelPendingTrackingRestore(options);
+ },
+ }
+ : {}),
+ }
+ : {}),
};
}
function productionManager(hooksById = {}) {
const manager = new DataLayerManager({});
- for (const id of REGISTERED_LAYER_IDS) manager.register(fakeLayer(id, hooksById[id] || {}));
+ for (const id of REGISTERED_LAYER_IDS)
+ manager.register(fakeLayer(id, hooksById[id] || {}));
manager.finalizeRegistrations(LAYER_STATE_REGISTRY);
return manager;
}
@@ -112,8 +137,13 @@ function manualTimers(startMs = 0) {
let queued = null;
return {
now: () => nowMs,
- setTimer: (fn, ms) => { queued = { fn, ms }; return queued; },
- clearTimer: (handle) => { if (queued === handle) queued = null; },
+ setTimer: (fn, ms) => {
+ queued = { fn, ms };
+ return queued;
+ },
+ clearTimer: (handle) => {
+ if (queued === handle) queued = null;
+ },
/** Fire queued polls, advancing the clock, until nothing re-arms. */
runUntilIdle(maxTicks = 2_000) {
for (let tick = 0; tick < maxTicks; tick += 1) {
@@ -133,7 +163,9 @@ function memoryStorage(initial = null) {
if (initial !== null) values.set(LAYER_STATE_STORAGE_KEY, initial);
return {
writes: [],
- getItem(key) { return values.has(key) ? values.get(key) : null; },
+ getItem(key) {
+ return values.has(key) ? values.get(key) : null;
+ },
setItem(key, value) {
values.set(key, value);
this.writes.push([key, value]);
@@ -145,8 +177,12 @@ function shareSink() {
return {
provider: null,
updates: 0,
- setLayerStateProvider(provider) { this.provider = provider; },
- onLayerStateChange() { this.updates += 1; },
+ setLayerStateProvider(provider) {
+ this.provider = provider;
+ },
+ onLayerStateChange() {
+ this.updates += 1;
+ },
};
}
@@ -158,32 +194,53 @@ function encode(state) {
test('production registry is exact, canonical, and rejects incomplete contracts', async () => {
assert.equal(validateLayerStateRegistry(), true);
- assert.equal(REGISTERED_LAYER_IDS.length, 26);
- assert.equal(new Set(REGISTERED_LAYER_IDS).size, 26);
+ assert.equal(REGISTERED_LAYER_IDS.length, 27);
+ assert.equal(new Set(REGISTERED_LAYER_IDS).size, 27);
assert.ok(REGISTERED_LAYER_IDS.includes('transit'));
assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort());
assert.throws(
- () => validateLayerStateRegistry([...LAYER_STATE_REGISTRY, LAYER_STATE_REGISTRY[0]]),
+ () =>
+ validateLayerStateRegistry([
+ ...LAYER_STATE_REGISTRY,
+ LAYER_STATE_REGISTRY[0],
+ ]),
/Duplicate layer-state id/,
);
const manager = new DataLayerManager({});
manager.register(fakeLayer('earthquakes'));
- assert.throws(() => manager.register(fakeLayer('earthquakes')), /Duplicate data-layer id/);
- await assert.rejects(manager.restoreLayerState('earthquakes', { enabled: true }), /finalized/);
+ assert.throws(
+ () => manager.register(fakeLayer('earthquakes')),
+ /Duplicate data-layer id/,
+ );
+ await assert.rejects(
+ manager.restoreLayerState('earthquakes', { enabled: true }),
+ /finalized/,
+ );
assert.throws(() => manager.finalizeRegistrations([]), /registry mismatch/);
assert.throws(
- () => manager.finalizeRegistrations([{ id: 'earthquakes', disposition: 'default' }]),
+ () =>
+ manager.finalizeRegistrations([
+ { id: 'earthquakes', disposition: 'default' },
+ ]),
/Invalid layer serialization disposition/,
);
- assert.equal(manager.finalizeRegistrations([
- { id: 'earthquakes', disposition: 'enabled-only' },
- ]), true);
+ assert.equal(
+ manager.finalizeRegistrations([
+ { id: 'earthquakes', disposition: 'enabled-only' },
+ ]),
+ true,
+ );
assert.throws(() => manager.register(fakeLayer('radio')), /finalized/);
- assert.throws(() => manager.registerForQa(fakeLayer('radio')), /not authorized/);
+ assert.throws(
+ () => manager.registerForQa(fakeLayer('radio')),
+ /not authorized/,
+ );
const qaManager = new DataLayerManager({}, { allowQaRegistration: true });
qaManager.register(fakeLayer('earthquakes'));
- qaManager.finalizeRegistrations([{ id: 'earthquakes', disposition: 'enabled-only' }]);
+ qaManager.finalizeRegistrations([
+ { id: 'earthquakes', disposition: 'enabled-only' },
+ ]);
qaManager.registerForQa(fakeLayer('radio'));
assert.equal(qaManager.layers.has('radio'), true);
assert.equal(await qaManager.unregisterForQa('radio'), true);
@@ -191,7 +248,10 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
});
test('v2 codec distinguishes absent from empty and keeps canonical deterministic ordering', () => {
- assert.equal(decodeLayerStateParams(new URLSearchParams('lat=1&lon=2')), null);
+ assert.equal(
+ decodeLayerStateParams(new URLSearchParams('lat=1&lon=2')),
+ null,
+ );
assert.equal(decodeLayerStateParams(new URLSearchParams('v=1&l=e')), null);
assert.equal(decodeLayerStateParams(new URLSearchParams('v=3&l=e')), null);
assert.equal(decodeLayerStateParams(new URLSearchParams('v=2')), null);
@@ -223,24 +283,38 @@ test('v2 codec distinguishes absent from empty and keeps canonical deterministic
},
});
assert.equal(encode(first), encode(second));
- assert.deepEqual(decodeLayerStateParams(new URLSearchParams(encode(first))), first);
+ assert.deepEqual(
+ decodeLayerStateParams(new URLSearchParams(encode(first))),
+ first,
+ );
});
test('unknown enabled-layer tokens reject the payload instead of becoming an empty set', () => {
- assert.equal(decodeLayerStateParams(new URLSearchParams('v=2&l=unknown')), null);
- assert.equal(decodeLayerStateParams(new URLSearchParams('v=2&l=c.unknown')), null);
+ assert.equal(
+ decodeLayerStateParams(new URLSearchParams('v=2&l=unknown')),
+ null,
+ );
+ assert.equal(
+ decodeLayerStateParams(new URLSearchParams('v=2&l=c.unknown')),
+ null,
+ );
});
test('Nepal event and locator have distinct enabled-only share tokens', () => {
const decoded = decodeLayerStateParams(new URLSearchParams('v=2&l=h.z'));
- assert.deepEqual(decoded.enabledLayerIds, ['bhote-koshi-2026', 'bhote-koshi-locator']);
+ assert.deepEqual(decoded.enabledLayerIds, [
+ 'bhote-koshi-2026',
+ 'bhote-koshi-locator',
+ ]);
assert.ok(encode(decoded).includes('l=h.z'));
});
test('unknown and forbidden option fields are ignored while missing options use codec defaults', () => {
- const decoded = decodeLayerStateParams(new URLSearchParams(
- 'v=2&l=c.e&lo=c.c.v_c.z.1_z.c.1_f.e.1_f.m.a_r.f.n_r.v.35',
- ));
+ const decoded = decodeLayerStateParams(
+ new URLSearchParams(
+ 'v=2&l=c.e&lo=c.c.v_c.z.1_z.c.1_f.e.1_f.m.a_r.f.n_r.v.35',
+ ),
+ );
assert.deepEqual(decoded.enabledLayerIds, ['cctv', 'earthquakes']);
assert.deepEqual(decoded.options.cctv, {
coverageMode: 'viewshed',
@@ -278,9 +352,18 @@ test('unknown and forbidden option fields are ignored while missing options use
});
const serialized = `${encode(raw)} ${serializeStoredLayerState(raw)}`;
for (const forbidden of [
- 'private-camera', 'calibration', 'autoHopSec', 'irBoost', 'showPoints',
- 'showOrbits', 'private-station', 'audioState', 'voiceDucked', 'secret',
- ]) assert.equal(serialized.includes(forbidden), false, forbidden);
+ 'private-camera',
+ 'calibration',
+ 'autoHopSec',
+ 'irBoost',
+ 'showPoints',
+ 'showOrbits',
+ 'private-station',
+ 'audioState',
+ 'voiceDucked',
+ 'secret',
+ ])
+ assert.equal(serialized.includes(forbidden), false, forbidden);
});
test('Radio genre ids with spaces and ampersands round-trip through v2', () => {
@@ -295,19 +378,23 @@ test('Radio genre ids with spaces and ampersands round-trip through v2', () => {
});
test('mirrored option owners decode into the owner owner-options bucket', () => {
- const decoded = decodeLayerStateParams(new URLSearchParams(
- 'v=2&l=f.m&lo=m.u.xyz',
- ));
+ const decoded = decodeLayerStateParams(
+ new URLSearchParams('v=2&l=f.m&lo=m.u.xyz'),
+ );
assert.deepEqual(decoded.enabledLayerIds, ['flights', 'military']);
assert.equal(decoded.options.flights.selectedFlightsTrackingId, null);
assert.equal(decoded.options.flights.selectedMilitaryTrackingId, 'xyz');
});
test('ambiguous cross-family tracking IDs fail closed instead of racing feed arrival', () => {
- const decoded = decodeLayerStateParams(new URLSearchParams(
- 'v=2&l=f.m.s&lo=f.t.flightA_f.u.militaryB_s.t.25544',
- ));
- assert.deepEqual(decoded.enabledLayerIds, ['flights', 'military', 'satellites']);
+ const decoded = decodeLayerStateParams(
+ new URLSearchParams('v=2&l=f.m.s&lo=f.t.flightA_f.u.militaryB_s.t.25544'),
+ );
+ assert.deepEqual(decoded.enabledLayerIds, [
+ 'flights',
+ 'military',
+ 'satellites',
+ ]);
assert.equal(decoded.options.flights.selectedFlightsTrackingId, null);
assert.equal(decoded.options.flights.selectedMilitaryTrackingId, null);
assert.equal(decoded.options.satellites.selectedSatTrackingId, null);
@@ -329,8 +416,18 @@ test('compact URL omits absent-meaning option state and still resolves to it', (
// meant to the links already in the wild, so `false` is what belongs in a
// URL-omission test. The default's own value is pinned in the fresh-boot test
// below, and the divergence itself in the two codec tests above.
- state.options.flights = { models3d: false, models3dMode: 'proximity', selectedFlightsTrackingId: null, selectedMilitaryTrackingId: null };
- state.options.satellites = { catalog: 'core', showPoints: true, showOrbits: true, selectedSatTrackingId: null };
+ state.options.flights = {
+ models3d: false,
+ models3dMode: 'proximity',
+ selectedFlightsTrackingId: null,
+ selectedMilitaryTrackingId: null,
+ };
+ state.options.satellites = {
+ catalog: 'core',
+ showPoints: true,
+ showOrbits: true,
+ selectedSatTrackingId: null,
+ };
state.options.wind.overlay = 'speed'; // Frozen v2 omitted-token meaning; new boots use trails.
const params = encodeLayerStateParams(new URLSearchParams('v=2'), state);
assert.equal(params.has('lo'), false);
@@ -360,16 +457,34 @@ test('a fresh boot starts 3D aircraft ON in proximity — codec, both layers, an
const paramsCalls = [];
const manager = productionManager({
- flights: { setParams: (params) => { paramsCalls.push({ ...params }); return true; } },
- military: { setParams: (params) => { paramsCalls.push({ ...params }); return true; } },
+ flights: {
+ setParams: (params) => {
+ paramsCalls.push({ ...params });
+ return true;
+ },
+ },
+ military: {
+ setParams: (params) => {
+ paramsCalls.push({ ...params });
+ return true;
+ },
+ },
+ });
+ const coordinator = new LayerStateCoordinator(manager, shareSink(), {
+ storage: memoryStorage(),
});
- const coordinator = new LayerStateCoordinator(manager, shareSink(), { storage: memoryStorage() });
await coordinator.start();
assert.equal(coordinator.source, 'defaults');
assert.equal(coordinator.getDurableState().options.flights.models3d, true);
- assert.equal(coordinator.getDurableState().options.flights.models3dMode, 'proximity');
- assert.deepEqual(paramsCalls, [],
- 'a fresh boot restores nothing — which is exactly why the module initializers below must match');
+ assert.equal(
+ coordinator.getDurableState().options.flights.models3dMode,
+ 'proximity',
+ );
+ assert.deepEqual(
+ paramsCalls,
+ [],
+ 'a fresh boot restores nothing — which is exactly why the module initializers below must match',
+ );
coordinator.destroy();
// The other three surfaces, read from source, because each is the literal a
@@ -377,25 +492,51 @@ test('a fresh boot starts 3D aircraft ON in proximity — codec, both layers, an
const { readFile } = await import('node:fs/promises');
for (const name of ['flights.js', 'militaryFlights.js']) {
const source = readLayerSource(new URL(`./${name}`, import.meta.url));
- assert.match(source, /^\s*(?:let |flightState\.)_models3dEnabled = true;$/m,
- `${name}: the fleet starts armed, matching the codec default`);
- assert.match(source, /^\s*(?:let |flightState\.)_models3dMode = 'proximity';/m,
- `${name}: and starts in proximity, matching the codec default`);
+ assert.match(
+ source,
+ /^\s*(?:let |flightState\.)_models3dEnabled = true;$/m,
+ `${name}: the fleet starts armed, matching the codec default`,
+ );
+ assert.match(
+ source,
+ /^\s*(?:let |flightState\.)_models3dMode = 'proximity';/m,
+ `${name}: and starts in proximity, matching the codec default`,
+ );
}
const ui = await readShellSource();
- assert.match(ui, /^\s*this\.(?:flightState\.)?_models3dEnabled = true;$/m,
- 'ui.js: the DISPLAY rail believes 3D is on before any layer-state sync arrives');
- assert.match(ui, /this\.(?:flightState\.)?_models3dMode = 'proximity';/,
- 'ui.js: and believes the mode is proximity');
- const html = expandApplicationHtml(await readFile(new URL('../../index.html', import.meta.url), 'utf8'));
- assert.match(html, /class="pp-toggle-btn active" id="models3d-toggle" aria-pressed="true"/,
- 'index.html: the 3D button paints lit on first paint, before ui.js runs — and says so');
- assert.match(ui, /this\._models3dBtn\?\.setAttribute\(\s*'aria-pressed',\s*String\(this\.(?:flightState\.)?_models3dEnabled\),?\s*\)/,
- 'ui.js: and keeps aria-pressed synchronized, so the lit state is not colour-only');
- assert.match(html, /class="pp-slider-row visible" id="models3d-mode-row"/,
- 'index.html: and the Proximity/All row paints open with it');
- assert.match(html, /id="models3d-mode-proximity"[^>]*aria-checked="true"/,
- 'index.html: Proximity is the selected mode in the markup');
+ assert.match(
+ ui,
+ /^\s*this\.(?:flightState\.)?_models3dEnabled = true;$/m,
+ 'ui.js: the DISPLAY rail believes 3D is on before any layer-state sync arrives',
+ );
+ assert.match(
+ ui,
+ /this\.(?:flightState\.)?_models3dMode = 'proximity';/,
+ 'ui.js: and believes the mode is proximity',
+ );
+ const html = expandApplicationHtml(
+ await readFile(new URL('../../index.html', import.meta.url), 'utf8'),
+ );
+ assert.match(
+ html,
+ /class="pp-toggle-btn active" id="models3d-toggle" aria-pressed="true"/,
+ 'index.html: the 3D button paints lit on first paint, before ui.js runs — and says so',
+ );
+ assert.match(
+ ui,
+ /this\._models3dBtn\?\.setAttribute\(\s*'aria-pressed',\s*String\(this\.(?:flightState\.)?_models3dEnabled\),?\s*\)/,
+ 'ui.js: and keeps aria-pressed synchronized, so the lit state is not colour-only',
+ );
+ assert.match(
+ html,
+ /class="pp-slider-row visible" id="models3d-mode-row"/,
+ 'index.html: and the Proximity/All row paints open with it',
+ );
+ assert.match(
+ html,
+ /id="models3d-mode-proximity"[^>]*aria-checked="true"/,
+ 'index.html: Proximity is the selected mode in the markup',
+ );
});
test('a v2 link written before the flip still means what its author saw: 3D OFF', () => {
@@ -406,20 +547,29 @@ test('a v2 link written before the flip still means what its author saw: 3D OFF'
//
// `v=2&l=f` is the canonical parent-era link. It must decode OFF forever.
const parentEra = decodeLayerStateParams(new URLSearchParams('v=2&l=f'));
- assert.equal(parentEra.options.flights.models3d, false,
- 'an omitted `e` is a v2 author saying OFF, not "whatever today\'s default is"');
+ assert.equal(
+ parentEra.options.flights.models3d,
+ false,
+ 'an omitted `e` is a v2 author saying OFF, not "whatever today\'s default is"',
+ );
// The nastier one: an OFF link that DID remember a non-default mode. Reading
// the omission as the new default would resurrect it as ON + All.
- const parentEraAll = decodeLayerStateParams(new URLSearchParams('v=2&l=f&lo=f.m.a'));
+ const parentEraAll = decodeLayerStateParams(
+ new URLSearchParams('v=2&l=f&lo=f.m.a'),
+ );
assert.equal(parentEraAll.options.flights.models3d, false);
- assert.equal(parentEraAll.options.flights.models3dMode, 'all',
- 'the mode it really carried survives; only the omission is read as OFF');
+ assert.equal(
+ parentEraAll.options.flights.models3dMode,
+ 'all',
+ 'the mode it really carried survives; only the omission is read as OFF',
+ );
// A parent-era link that carried 3D ON wrote it explicitly (ON was non-default
// then), so it still restores ON. Both eras round-trip.
assert.equal(
- decodeLayerStateParams(new URLSearchParams('v=2&l=f&lo=f.e.1')).options.flights.models3d,
+ decodeLayerStateParams(new URLSearchParams('v=2&l=f&lo=f.e.1')).options
+ .flights.models3d,
true,
);
});
@@ -431,11 +581,22 @@ test('the new default is written EXPLICITLY, so no omission is ambiguous', () =>
// OFF links for their recipients.
const on = createDefaultLayerState();
on.enabledLayerIds = ['flights'];
- assert.equal(on.options.flights.models3d, true, 'precondition: ON is the default');
+ assert.equal(
+ on.options.flights.models3d,
+ true,
+ 'precondition: ON is the default',
+ );
const onEncoded = encode(on);
- assert.match(onEncoded, /(^|&)lo=[^&]*f\.e\.1/,
- 'a fresh ON link states ON outright rather than relying on omission');
- assert.equal(decodeLayerStateParams(new URLSearchParams(onEncoded)).options.flights.models3d, true);
+ assert.match(
+ onEncoded,
+ /(^|&)lo=[^&]*f\.e\.1/,
+ 'a fresh ON link states ON outright rather than relying on omission',
+ );
+ assert.equal(
+ decodeLayerStateParams(new URLSearchParams(onEncoded)).options.flights
+ .models3d,
+ true,
+ );
// And OFF is the omission, matching what v2 already meant — one meaning, one
// encoding, across both eras.
@@ -443,13 +604,21 @@ test('the new default is written EXPLICITLY, so no omission is ambiguous', () =>
off.enabledLayerIds = ['flights'];
off.options.flights = { ...off.options.flights, models3d: false };
const offEncoded = encode(off);
- assert.doesNotMatch(offEncoded, /f\.e\./,
- 'OFF is the absent token, exactly as it was before the flip');
- assert.equal(decodeLayerStateParams(new URLSearchParams(offEncoded)).options.flights.models3d, false);
+ assert.doesNotMatch(
+ offEncoded,
+ /f\.e\./,
+ 'OFF is the absent token, exactly as it was before the flip',
+ );
+ assert.equal(
+ decodeLayerStateParams(new URLSearchParams(offEncoded)).options.flights
+ .models3d,
+ false,
+ );
// An interim build briefly emitted `f.e.0`; it must still decode as OFF.
assert.equal(
- decodeLayerStateParams(new URLSearchParams('v=2&l=f&lo=f.e.0')).options.flights.models3d,
+ decodeLayerStateParams(new URLSearchParams('v=2&l=f&lo=f.e.0')).options
+ .flights.models3d,
false,
);
});
@@ -464,41 +633,82 @@ test('a share link that carries 3D OFF still restores OFF at both aircraft layer
const encoded = encode(state);
const decoded = decodeLayerStateParams(new URLSearchParams(encoded));
- assert.equal(decoded.options.flights.models3d, false, 'the link decodes back to OFF');
+ assert.equal(
+ decoded.options.flights.models3d,
+ false,
+ 'the link decodes back to OFF',
+ );
assert.equal(decoded.options.flights.models3dMode, 'proximity');
// And the restore really pushes OFF at both aircraft layers — `military`
// mirrors the `flights` option owner, so one shared link disarms both.
const calls = { flights: [], military: [] };
const manager = productionManager({
- flights: { setParams: (params) => { calls.flights.push({ ...params }); return true; } },
- military: { setParams: (params) => { calls.military.push({ ...params }); return true; } },
+ flights: {
+ setParams: (params) => {
+ calls.flights.push({ ...params });
+ return true;
+ },
+ },
+ military: {
+ setParams: (params) => {
+ calls.military.push({ ...params });
+ return true;
+ },
+ },
});
- const coordinator = new LayerStateCoordinator(manager, shareSink(), { storage: memoryStorage() });
- await coordinator.start({ shareLayerState: decoded, shareCreatedAtMs: 1_000 });
- assert.deepEqual(calls.flights, [{ models3d: false, models3dMode: 'proximity' }]);
- assert.deepEqual(calls.military, [{ models3d: false, models3dMode: 'proximity' }]);
+ const coordinator = new LayerStateCoordinator(manager, shareSink(), {
+ storage: memoryStorage(),
+ });
+ await coordinator.start({
+ shareLayerState: decoded,
+ shareCreatedAtMs: 1_000,
+ });
+ assert.deepEqual(calls.flights, [
+ { models3d: false, models3dMode: 'proximity' },
+ ]);
+ assert.deepEqual(calls.military, [
+ { models3d: false, models3dMode: 'proximity' },
+ ]);
assert.equal(coordinator.getDurableState().options.flights.models3d, false);
coordinator.destroy();
});
test('explicit navigation tracking clear removes the active durable ID from the generated hash', async () => {
const manager = productionManager();
- const coordinator = new LayerStateCoordinator(manager, shareSink(), { storage: memoryStorage() });
+ const coordinator = new LayerStateCoordinator(manager, shareSink(), {
+ storage: memoryStorage(),
+ });
await coordinator.start();
await manager.setEnabled('flights', true, { origin: 'user' });
- manager.setLayerParams('flights', { selectedFlightsTrackingId: 'abc123' }, { origin: 'user' });
- assert.equal(coordinator.getDurableState().options.flights.selectedFlightsTrackingId, 'abc123');
+ manager.setLayerParams(
+ 'flights',
+ { selectedFlightsTrackingId: 'abc123' },
+ { origin: 'user' },
+ );
+ assert.equal(
+ coordinator.getDurableState().options.flights.selectedFlightsTrackingId,
+ 'abc123',
+ );
assert.equal(encode(coordinator.getDurableState()).includes('abc123'), true);
- manager.setLayerParams('flights', { selectedFlightsTrackingId: null }, { origin: 'tool' });
- assert.equal(coordinator.getDurableState().options.flights.selectedFlightsTrackingId, null);
+ manager.setLayerParams(
+ 'flights',
+ { selectedFlightsTrackingId: null },
+ { origin: 'tool' },
+ );
+ assert.equal(
+ coordinator.getDurableState().options.flights.selectedFlightsTrackingId,
+ null,
+ );
assert.equal(encode(coordinator.getDurableState()).includes('abc123'), false);
coordinator.destroy();
});
test('a direct Context selection promotes its owned tracker layer before persisting the ID', async () => {
const manager = productionManager();
- const coordinator = new LayerStateCoordinator(manager, shareSink(), { storage: memoryStorage() });
+ const coordinator = new LayerStateCoordinator(manager, shareSink(), {
+ storage: memoryStorage(),
+ });
await coordinator.start();
await manager.setEnabled('flights', true, { origin: 'programmatic' });
manager.setLayerParams(
@@ -506,75 +716,140 @@ test('a direct Context selection promotes its owned tracker layer before persist
{ selectedFlightsTrackingId: 'context-flight' },
{ origin: 'programmatic' },
);
- assert.equal(manager.adoptLayerVisibility('flights', true, {
- origin: 'user',
- adoptedFromSelection: true,
- }), true);
- assert.equal(manager.adoptLayerParams(
- 'flights',
- { selectedFlightsTrackingId: 'context-flight' },
- { origin: 'user' },
- ), true);
+ assert.equal(
+ manager.adoptLayerVisibility('flights', true, {
+ origin: 'user',
+ adoptedFromSelection: true,
+ }),
+ true,
+ );
+ assert.equal(
+ manager.adoptLayerParams(
+ 'flights',
+ { selectedFlightsTrackingId: 'context-flight' },
+ { origin: 'user' },
+ ),
+ true,
+ );
const durable = coordinator.getDurableState();
assert.equal(durable.enabledLayerIds.includes('flights'), true);
- assert.equal(durable.options.flights.selectedFlightsTrackingId, 'context-flight');
+ assert.equal(
+ durable.options.flights.selectedFlightsTrackingId,
+ 'context-flight',
+ );
assert.equal(encode(durable).includes('context-flight'), true);
coordinator.destroy();
});
test('explicit Flight to Satellite replacement keeps the new Satellite ID durable', async () => {
const manager = productionManager();
- const coordinator = new LayerStateCoordinator(manager, shareSink(), { storage: memoryStorage() });
+ const coordinator = new LayerStateCoordinator(manager, shareSink(), {
+ storage: memoryStorage(),
+ });
await coordinator.start();
await manager.setEnabled('flights', true, { origin: 'user' });
await manager.setEnabled('satellites', true, { origin: 'user' });
- manager.setLayerParams('flights', { selectedFlightsTrackingId: 'abc123' }, { origin: 'user' });
- assert.equal(coordinator.getDurableState().options.flights.selectedFlightsTrackingId, 'abc123');
+ manager.setLayerParams(
+ 'flights',
+ { selectedFlightsTrackingId: 'abc123' },
+ { origin: 'user' },
+ );
+ assert.equal(
+ coordinator.getDurableState().options.flights.selectedFlightsTrackingId,
+ 'abc123',
+ );
// UI replacement ordering clears the prior family before a direct tracker
// publishes its already-applied selection through adoptLayerParams().
- manager.setLayerParams('flights', { selectedFlightsTrackingId: null }, { origin: 'user' });
- manager.setLayerParams('satellites', { selectedSatTrackingId: 25544 }, { origin: 'programmatic' });
- assert.equal(manager.adoptLayerParams(
+ manager.setLayerParams(
+ 'flights',
+ { selectedFlightsTrackingId: null },
+ { origin: 'user' },
+ );
+ manager.setLayerParams(
'satellites',
{ selectedSatTrackingId: 25544 },
- { origin: 'user' },
- ), true);
+ { origin: 'programmatic' },
+ );
+ assert.equal(
+ manager.adoptLayerParams(
+ 'satellites',
+ { selectedSatTrackingId: 25544 },
+ { origin: 'user' },
+ ),
+ true,
+ );
const durable = coordinator.getDurableState();
assert.equal(durable.options.flights.selectedFlightsTrackingId, null);
assert.equal(durable.options.flights.selectedMilitaryTrackingId, null);
assert.equal(durable.options.satellites.selectedSatTrackingId, 25544);
- assert.equal(new URLSearchParams(encode(durable)).get('lo')?.includes('s.t.25544'), true);
+ assert.equal(
+ new URLSearchParams(encode(durable)).get('lo')?.includes('s.t.25544'),
+ true,
+ );
coordinator.destroy();
});
test('explicit layer OFF clears its durable selected entity and prevents later resurrection', async () => {
const manager = productionManager();
- const coordinator = new LayerStateCoordinator(manager, shareSink(), { storage: memoryStorage() });
+ const coordinator = new LayerStateCoordinator(manager, shareSink(), {
+ storage: memoryStorage(),
+ });
await coordinator.start();
await manager.setEnabled('flights', true, { origin: 'user' });
- manager.setLayerParams('flights', { selectedFlightsTrackingId: 'abc123' }, { origin: 'user' });
- assert.equal(coordinator.getDurableState().options.flights.selectedFlightsTrackingId, 'abc123');
+ manager.setLayerParams(
+ 'flights',
+ { selectedFlightsTrackingId: 'abc123' },
+ { origin: 'user' },
+ );
+ assert.equal(
+ coordinator.getDurableState().options.flights.selectedFlightsTrackingId,
+ 'abc123',
+ );
await manager.setEnabled('flights', false, { origin: 'user' });
- assert.equal(coordinator.getDurableState().options.flights.selectedFlightsTrackingId, null);
- assert.equal(Boolean(
- new URLSearchParams(encode(coordinator.getDurableState())).get('lo')?.includes('f.t.'),
- ), false);
+ assert.equal(
+ coordinator.getDurableState().options.flights.selectedFlightsTrackingId,
+ null,
+ );
+ assert.equal(
+ Boolean(
+ new URLSearchParams(encode(coordinator.getDurableState()))
+ .get('lo')
+ ?.includes('f.t.'),
+ ),
+ false,
+ );
coordinator.destroy();
});
test('unrelated explicit params retain the family live active ID while cancelling only pending state', async () => {
const manager = productionManager();
- const coordinator = new LayerStateCoordinator(manager, shareSink(), { storage: memoryStorage() });
+ const coordinator = new LayerStateCoordinator(manager, shareSink(), {
+ storage: memoryStorage(),
+ });
await coordinator.start();
- assert.equal(manager.setLayerParams('flights', {
- selectedFlightsTrackingId: 'active001',
- }, { origin: 'programmatic' }), true);
+ assert.equal(
+ manager.setLayerParams(
+ 'flights',
+ {
+ selectedFlightsTrackingId: 'active001',
+ },
+ { origin: 'programmatic' },
+ ),
+ true,
+ );
await manager.setEnabled('flights', true, { origin: 'user' });
- assert.equal(manager.setLayerParams('flights', {
- models3d: true,
- }, { origin: 'user' }), true);
+ assert.equal(
+ manager.setLayerParams(
+ 'flights',
+ {
+ models3d: true,
+ },
+ { origin: 'user' },
+ ),
+ true,
+ );
assert.equal(
coordinator.getDurableState().options.flights.selectedFlightsTrackingId,
'active001',
@@ -587,7 +862,11 @@ test('stored state is deterministic, rejects other versions, and stays within a
state.enabledLayerIds = [...REGISTERED_LAYER_IDS].reverse();
state.options.flights = { models3d: true, models3dMode: 'all' };
state.options.satellites = { catalog: 'dense' };
- state.options.cctv = { coverageMode: 'viewshed', showProjection: false, autoHop: true };
+ state.options.cctv = {
+ coverageMode: 'viewshed',
+ showProjection: false,
+ autoHop: true,
+ };
state.options.radio = { filter: 'genre:experimental-ambient', volume: 1 };
const stored = serializeStoredLayerState(state);
assert.deepEqual(parseStoredLayerState(stored), normalizeLayerState(state));
@@ -599,33 +878,58 @@ test('restore applies sanitized params after init and before enable', async () =
const order = [];
const manager = productionManager({
flights: {
- init: () => { order.push('init'); return true; },
- setParams: () => { order.push('params'); return true; },
- enable: () => { order.push('enable'); return true; },
- update: () => { order.push('update'); return true; },
+ init: () => {
+ order.push('init');
+ return true;
+ },
+ setParams: () => {
+ order.push('params');
+ return true;
+ },
+ enable: () => {
+ order.push('enable');
+ return true;
+ },
+ update: () => {
+ order.push('update');
+ return true;
+ },
},
});
- const outcome = await manager.restoreLayerState('flights', {
- enabled: true,
- params: { models3d: true, models3dMode: 'all' },
- }, { origin: 'share-restore' });
+ const outcome = await manager.restoreLayerState(
+ 'flights',
+ {
+ enabled: true,
+ params: { models3d: true, models3dMode: 'all' },
+ },
+ { origin: 'share-restore' },
+ );
assert.deepEqual(order, ['init', 'params', 'enable', 'update']);
assert.equal(outcome.succeeded, true);
assert.equal(outcome.persistenceWrite, false);
- assert.deepEqual(outcome.appliedOptions, { models3d: true, models3dMode: 'all' });
+ assert.deepEqual(outcome.appliedOptions, {
+ models3d: true,
+ models3dMode: 'all',
+ });
});
test('manager forwards passive restore origin into module parameter application', async () => {
const seen = [];
const manager = productionManager({
flights: {
- setParams: (_params, options) => { seen.push(options); },
+ setParams: (_params, options) => {
+ seen.push(options);
+ },
},
});
- await manager.restoreLayerState('flights', {
- enabled: false,
- params: { selectedFlightsTrackingId: 'abc123' },
- }, { origin: 'share-restore' });
+ await manager.restoreLayerState(
+ 'flights',
+ {
+ enabled: false,
+ params: { selectedFlightsTrackingId: 'abc123' },
+ },
+ { origin: 'share-restore' },
+ );
assert.deepEqual(seen, [{ origin: 'share-restore', paramsIntentEpoch: 1 }]);
});
@@ -633,15 +937,25 @@ test('explicit manager params and visibility revoke module-owned pending trackin
const cancellations = [];
const manager = productionManager({
flights: {
- cancelPendingTrackingRestore: (options) => { cancellations.push(options); },
+ cancelPendingTrackingRestore: (options) => {
+ cancellations.push(options);
+ },
},
});
- await manager.restoreLayerState('flights', {
- enabled: false,
- params: { selectedFlightsTrackingId: 'late001' },
- }, { origin: 'share-restore' });
- assert.deepEqual(cancellations, [], 'passive restoration cannot cancel itself');
+ await manager.restoreLayerState(
+ 'flights',
+ {
+ enabled: false,
+ params: { selectedFlightsTrackingId: 'late001' },
+ },
+ { origin: 'share-restore' },
+ );
+ assert.deepEqual(
+ cancellations,
+ [],
+ 'passive restoration cannot cancel itself',
+ );
manager.setLayerParams('flights', { models3d: true }, { origin: 'voice' });
await manager.setEnabled('flights', false, { origin: 'user' });
@@ -667,18 +981,29 @@ test('share payload wins over local, passive restore writes nothing, and explici
assert.equal(share.provider().enabledLayerIds.length, 0);
await manager.setEnabled('earthquakes', true, { origin: 'user' });
- assert.deepEqual(coordinator.getDurableState().enabledLayerIds, ['earthquakes']);
+ assert.deepEqual(coordinator.getDurableState().enabledLayerIds, [
+ 'earthquakes',
+ ]);
assert.equal(storage.writes.length, 1);
await manager.setEnabled('traffic', true, { origin: 'scene' });
assert.equal(storage.writes.length, 1);
- assert.deepEqual(coordinator.getDurableState().enabledLayerIds, ['earthquakes']);
+ assert.deepEqual(coordinator.getDurableState().enabledLayerIds, [
+ 'earthquakes',
+ ]);
await manager.setEnabled('traffic', true, { origin: 'tool' });
assert.equal(storage.writes.length, 2);
- assert.deepEqual(coordinator.getDurableState().enabledLayerIds, ['earthquakes', 'traffic']);
+ assert.deepEqual(coordinator.getDurableState().enabledLayerIds, [
+ 'earthquakes',
+ 'traffic',
+ ]);
- manager.setLayerParams('cctv', { selectedCameraId: 'private-camera' }, { origin: 'user' });
+ manager.setLayerParams(
+ 'cctv',
+ { selectedCameraId: 'private-camera' },
+ { origin: 'user' },
+ );
assert.equal(storage.writes.length, 2);
assert.deepEqual(coordinator.getDurableState().options.cctv, {
coverageMode: 'on',
@@ -690,9 +1015,16 @@ test('share payload wins over local, passive restore writes nothing, and explici
assert.equal(storage.writes.length, 2);
assert.equal(coordinator.getDurableState().options.cctv.coverageMode, 'on');
- manager.setLayerParams('cctv', { coverageMode: 'viewshed' }, { origin: 'voice' });
+ manager.setLayerParams(
+ 'cctv',
+ { coverageMode: 'viewshed' },
+ { origin: 'voice' },
+ );
assert.equal(storage.writes.length, 3);
- assert.equal(coordinator.getDurableState().options.cctv.coverageMode, 'viewshed');
+ assert.equal(
+ coordinator.getDurableState().options.cctv.coverageMode,
+ 'viewshed',
+ );
coordinator.destroy();
});
@@ -702,7 +1034,9 @@ test('absent share payload restores local state without rewriting it', async ()
local.options.radio = { filter: 'talk', volume: 0.42 };
const storage = memoryStorage(serializeStoredLayerState(local));
const manager = productionManager();
- const coordinator = new LayerStateCoordinator(manager, shareSink(), { storage });
+ const coordinator = new LayerStateCoordinator(manager, shareSink(), {
+ storage,
+ });
const results = await coordinator.start();
assert.equal(coordinator.source, 'local');
assert.equal(manager.isEnabled('earthquakes'), true);
@@ -714,7 +1048,10 @@ test('absent share payload restores local state without rewriting it', async ()
audioState: 'playing',
voiceDucked: true,
});
- assert.equal(results.every((result) => result.persistenceWrite === false), true);
+ assert.equal(
+ results.every((result) => result.persistenceWrite === false),
+ true,
+ );
assert.deepEqual(storage.writes, []);
coordinator.destroy();
});
@@ -724,7 +1061,9 @@ test('historical share payload suppresses unrelated local layer preferences', as
local.enabledLayerIds = ['traffic', 'radio'];
const storage = memoryStorage(serializeStoredLayerState(local));
const manager = productionManager();
- const coordinator = new LayerStateCoordinator(manager, shareSink(), { storage });
+ const coordinator = new LayerStateCoordinator(manager, shareSink(), {
+ storage,
+ });
await coordinator.start({ allowLocalState: false });
assert.equal(coordinator.source, 'legacy-share');
assert.deepEqual(coordinator.getDurableState().enabledLayerIds, []);
@@ -735,11 +1074,17 @@ test('historical share payload suppresses unrelated local layer preferences', as
test('one layer failure is isolated from sibling restoration', async () => {
const manager = productionManager({
- cctv: { init: () => { throw new Error('missing key'); } },
+ cctv: {
+ init: () => {
+ throw new Error('missing key');
+ },
+ },
});
const state = createDefaultLayerState();
state.enabledLayerIds = ['cctv', 'earthquakes'];
- const coordinator = new LayerStateCoordinator(manager, shareSink(), { storage: memoryStorage() });
+ const coordinator = new LayerStateCoordinator(manager, shareSink(), {
+ storage: memoryStorage(),
+ });
const results = await coordinator.start({ shareLayerState: state });
assert.equal(manager.isEnabled('cctv'), false);
assert.equal(manager.isEnabled('earthquakes'), true);
@@ -748,7 +1093,10 @@ test('one layer failure is isolated from sibling restoration', async () => {
assert.equal(failed.phase, 'init');
assert.equal(failed.errorClass, 'Error');
assert.equal(failed.error, 'missing key');
- assert.equal(results.find((result) => result.layerId === 'earthquakes').succeeded, true);
+ assert.equal(
+ results.find((result) => result.layerId === 'earthquakes').succeeded,
+ true,
+ );
coordinator.destroy();
});
@@ -768,7 +1116,10 @@ test('later explicit visibility during delayed restore wins for that layer only'
const results = await restore;
assert.equal(manager.isEnabled('radio'), false);
assert.equal(manager.isEnabled('earthquakes'), true);
- assert.equal(results.find((result) => result.layerId === 'radio').cancellationReason, 'superseded');
+ assert.equal(
+ results.find((result) => result.layerId === 'radio').cancellationReason,
+ 'superseded',
+ );
assert.equal(storage.writes.length, 1);
coordinator.destroy();
});
@@ -796,19 +1147,33 @@ test('share restore waits for a superseding same-target visibility successor', a
});
const state = createDefaultLayerState();
state.enabledLayerIds = ['radio'];
- const coordinator = new LayerStateCoordinator(manager, shareSink(), { storage: memoryStorage() });
+ const coordinator = new LayerStateCoordinator(manager, shareSink(), {
+ storage: memoryStorage(),
+ });
let restoreSettled = false;
- const restore = coordinator.start({ shareLayerState: state })
- .then((result) => { restoreSettled = true; return result; });
+ const restore = coordinator
+ .start({ shareLayerState: state })
+ .then((result) => {
+ restoreSettled = true;
+ return result;
+ });
await firstUpdateStarted.promise;
let successorSettled = false;
- const explicitOn = manager.setEnabled('radio', true, { origin: 'user' })
- .then((result) => { successorSettled = true; return result; });
+ const explicitOn = manager
+ .setEnabled('radio', true, { origin: 'user' })
+ .then((result) => {
+ successorSettled = true;
+ return result;
+ });
releaseFirstUpdate.resolve();
await secondUpdateStarted.promise;
await Promise.resolve();
- assert.equal(restoreSettled, false, 'aggregate must wait for the authoritative successor');
+ assert.equal(
+ restoreSettled,
+ false,
+ 'aggregate must wait for the authoritative successor',
+ );
assert.equal(successorSettled, false);
releaseSecondUpdate.resolve();
@@ -820,7 +1185,10 @@ test('share restore waits for a superseding same-target visibility successor', a
assert.equal(radio.authoritativeIntentEpoch, radio.successorIntentEpoch);
assert.equal(radio.authoritativeEnabled, true);
assert.equal(radio.succeeded, true);
- assert.equal(manager.getLayerLifecycleState('radio').lifecycleState, 'enabled');
+ assert.equal(
+ manager.getLayerLifecycleState('radio').lifecycleState,
+ 'enabled',
+ );
coordinator.destroy();
});
@@ -845,17 +1213,27 @@ test('share restore waits for a superseding opposite-target visibility successor
});
const state = createDefaultLayerState();
state.enabledLayerIds = ['radio'];
- const coordinator = new LayerStateCoordinator(manager, shareSink(), { storage: memoryStorage() });
+ const coordinator = new LayerStateCoordinator(manager, shareSink(), {
+ storage: memoryStorage(),
+ });
let restoreSettled = false;
- const restore = coordinator.start({ shareLayerState: state })
- .then((result) => { restoreSettled = true; return result; });
+ const restore = coordinator
+ .start({ shareLayerState: state })
+ .then((result) => {
+ restoreSettled = true;
+ return result;
+ });
await updateStarted.promise;
const explicitOff = manager.setEnabled('radio', false, { origin: 'user' });
releaseUpdate.resolve();
await disableStarted.promise;
await Promise.resolve();
- assert.equal(restoreSettled, false, 'aggregate must wait for the OFF successor to settle');
+ assert.equal(
+ restoreSettled,
+ false,
+ 'aggregate must wait for the OFF successor to settle',
+ );
releaseDisable.resolve();
assert.equal(await explicitOff, true);
@@ -865,8 +1243,15 @@ test('share restore waits for a superseding opposite-target visibility successor
assert.equal(radio.successorEnabled, false);
assert.equal(radio.authoritativeIntentEpoch, radio.successorIntentEpoch);
assert.equal(radio.authoritativeEnabled, false);
- assert.equal(radio.succeeded, false, 'the newer OFF must not count as successful shared ON');
- assert.equal(manager.getLayerLifecycleState('radio').lifecycleState, 'disabled');
+ assert.equal(
+ radio.succeeded,
+ false,
+ 'the newer OFF must not count as successful shared ON',
+ );
+ assert.equal(
+ manager.getLayerLifecycleState('radio').lifecycleState,
+ 'disabled',
+ );
coordinator.destroy();
});
@@ -894,10 +1279,17 @@ test('later explicit params during init replace options without cancelling visib
const coordinator = new LayerStateCoordinator(manager, share, { storage });
const restore = coordinator.start({ shareLayerState: state });
await initStarted.promise;
- assert.equal(manager.setLayerParams('flights', {
- models3d: false,
- models3dMode: 'proximity',
- }, { origin: 'user' }), true);
+ assert.equal(
+ manager.setLayerParams(
+ 'flights',
+ {
+ models3d: false,
+ models3dMode: 'proximity',
+ },
+ { origin: 'user' },
+ ),
+ true,
+ );
initGate.resolve();
const results = await restore;
assert.equal(manager.isEnabled('flights'), true);
@@ -907,10 +1299,15 @@ test('later explicit params during init replace options without cancelling visib
selectedFlightsTrackingId: null,
selectedMilitaryTrackingId: null,
});
- assert.equal(results.find((result) => result.layerId === 'flights').succeeded, true);
+ assert.equal(
+ results.find((result) => result.layerId === 'flights').succeeded,
+ true,
+ );
assert.equal(storage.writes.length, 1);
assert.equal(
- new URLSearchParams(encode(share.provider())).get('lo')?.includes('f.t.late001') || false,
+ new URLSearchParams(encode(share.provider()))
+ .get('lo')
+ ?.includes('f.t.late001') || false,
false,
);
coordinator.destroy();
@@ -927,7 +1324,10 @@ test('explicit navigation preserves unrelated visibility and option restoration'
await initGate.promise;
return true;
},
- setParams: (params) => { paramsCalls.push(params); return true; },
+ setParams: (params) => {
+ paramsCalls.push(params);
+ return true;
+ },
},
});
const state = createDefaultLayerState();
@@ -938,7 +1338,9 @@ test('explicit navigation preserves unrelated visibility and option restoration'
selectedFlightsTrackingId: 'stale-flight',
selectedMilitaryTrackingId: null,
};
- const coordinator = new LayerStateCoordinator(manager, shareSink(), { storage: memoryStorage() });
+ const coordinator = new LayerStateCoordinator(manager, shareSink(), {
+ storage: memoryStorage(),
+ });
const restore = coordinator.start({ shareLayerState: state });
await initStarted.promise;
// Navigation owns only camera and pending entity selection. It must not
@@ -946,11 +1348,16 @@ test('explicit navigation preserves unrelated visibility and option restoration'
initGate.resolve();
const results = await restore;
assert.equal(manager.isEnabled('flights'), true);
- assert.deepEqual(paramsCalls, [{
- models3d: true,
- models3dMode: 'all',
- }]);
- assert.equal(results.find((result) => result.layerId === 'flights').succeeded, true);
+ assert.deepEqual(paramsCalls, [
+ {
+ models3d: true,
+ models3dMode: 'all',
+ },
+ ]);
+ assert.equal(
+ results.find((result) => result.layerId === 'flights').succeeded,
+ true,
+ );
coordinator.destroy();
});
@@ -985,7 +1392,9 @@ test('startup gesture clears passive Follow but preserves slow layer and display
};
state.options.radio = { filter: 'genre:r&b', volume: 0.42 };
const storage = memoryStorage();
- const coordinator = new LayerStateCoordinator(manager, shareSink(), { storage });
+ const coordinator = new LayerStateCoordinator(manager, shareSink(), {
+ storage,
+ });
const restore = coordinator.start({ shareLayerState: state });
await Promise.all([flightsStarted.promise, radioStarted.promise]);
@@ -993,7 +1402,9 @@ test('startup gesture clears passive Follow but preserves slow layer and display
stampInitialShareGesture(({ cancelPendingSelection }) => {
cameraGeneration += 1;
if (cancelPendingSelection) {
- coordinator.cancelPendingShareTracking('startup-gesture', { clearSelection: true });
+ coordinator.cancelPendingShareTracking('startup-gesture', {
+ clearSelection: true,
+ });
}
});
flightsInit.resolve();
@@ -1016,8 +1427,14 @@ test('startup gesture clears passive Follow but preserves slow layer and display
audioState: 'playing',
voiceDucked: true,
});
- assert.equal(results.find((result) => result.layerId === 'flights').succeeded, true);
- assert.equal(results.find((result) => result.layerId === 'radio').succeeded, true);
+ assert.equal(
+ results.find((result) => result.layerId === 'flights').succeeded,
+ true,
+ );
+ assert.equal(
+ results.find((result) => result.layerId === 'radio').succeeded,
+ true,
+ );
assert.deepEqual(storage.writes, []);
coordinator.destroy();
});
@@ -1026,7 +1443,10 @@ test('tracking restore refreshes the destination feed before the module resolves
const order = [];
const manager = productionManager({
flights: {
- update: () => { order.push('refresh'); return true; },
+ update: () => {
+ order.push('refresh');
+ return true;
+ },
resolveTrackingRestoreTarget: (targetId, options) => {
order.push(`resolve:${targetId}:${options.origin}`);
return { status: 'found', source: 'test' };
@@ -1049,8 +1469,14 @@ test('shared flight selection is deferred until destination refresh and then fol
const paramsCalls = [];
const manager = productionManager({
flights: {
- setParams: (params) => { paramsCalls.push({ ...params }); return true; },
- resolveTrackingRestoreTarget: (targetId) => ({ status: 'found', targetId }),
+ setParams: (params) => {
+ paramsCalls.push({ ...params });
+ return true;
+ },
+ resolveTrackingRestoreTarget: (targetId) => ({
+ status: 'found',
+ targetId,
+ }),
},
});
const state = createDefaultLayerState();
@@ -1065,14 +1491,19 @@ test('shared flight selection is deferred until destination refresh and then fol
onTrackingRestoreStatus: (status) => statuses.push(status),
});
await coordinator.start({ shareLayerState: state, shareCreatedAtMs: 1_000 });
- assert.deepEqual(paramsCalls, [{ models3d: true, models3dMode: 'proximity' }]);
+ assert.deepEqual(paramsCalls, [
+ { models3d: true, models3dMode: 'proximity' },
+ ]);
const result = await coordinator.restoreShareTrackingSelection();
assert.equal(result.status, 'found');
assert.equal(result.classification, 'followed');
assert.equal(result.cleared, false);
assert.equal(statuses.length, 1);
- assert.equal(coordinator.getDurableState().options.flights.selectedFlightsTrackingId, 'abc123');
+ assert.equal(
+ coordinator.getDurableState().options.flights.selectedFlightsTrackingId,
+ 'abc123',
+ );
assert.deepEqual(storage.writes, []);
coordinator.destroy();
});
@@ -1083,11 +1514,11 @@ test('authoritative missing target clears only the passive ID and uses strict ex
const manager = productionManager({
flights: {
resolveTrackingRestoreTarget: () => ({ status: 'missing' }),
- setParams: (next) => (
- Object.hasOwn(next, 'selectedFlightsTrackingId') && next.selectedFlightsTrackingId
+ setParams: (next) =>
+ Object.hasOwn(next, 'selectedFlightsTrackingId') &&
+ next.selectedFlightsTrackingId
? 'defer'
- : true
- ),
+ : true,
},
});
const state = createDefaultLayerState();
@@ -1109,10 +1540,21 @@ test('authoritative missing target clears only the passive ID and uses strict ex
clearTimer: timers.clearTimer,
onTrackingRestoreStatus: (status) => statuses.push(status),
});
- await coordinator.start({ shareLayerState: state, shareCreatedAtMs: copiedAtMs });
+ await coordinator.start({
+ shareLayerState: state,
+ shareCreatedAtMs: copiedAtMs,
+ });
const pending = await coordinator.restoreShareTrackingSelection();
- assert.equal(pending.classification, 'pending', 'absence must first be held pending');
- assert.equal(statuses.length, 1, 'pending is published for soft progress feedback');
+ assert.equal(
+ pending.classification,
+ 'pending',
+ 'absence must first be held pending',
+ );
+ assert.equal(
+ statuses.length,
+ 1,
+ 'pending is published for soft progress feedback',
+ );
assert.equal(statuses[0].classification, 'pending');
timers.runUntilIdle();
const result = statuses.at(-1);
@@ -1124,7 +1566,10 @@ test('authoritative missing target clears only the passive ID and uses strict ex
const boundary = await run({ nowMs: 190_000, copiedAtMs: 100_000 });
assert.equal(boundary.result.classification, 'unavailable');
assert.equal(boundary.result.cleared, true);
- assert.equal(boundary.durable.options.flights.selectedFlightsTrackingId, null);
+ assert.equal(
+ boundary.durable.options.flights.selectedFlightsTrackingId,
+ null,
+ );
assert.equal(boundary.durable.options.flights.models3d, true);
assert.equal(boundary.durable.options.flights.models3dMode, 'all');
assert.deepEqual(boundary.writes, []);
@@ -1138,12 +1583,15 @@ test('feed failure is not misreported as target absence and malformed time never
const statuses = [];
const manager = productionManager({
satellites: {
- resolveTrackingRestoreTarget: () => ({ status: 'source-unavailable', reason: 'partial catalog' }),
- setParams: (next) => (
- Object.hasOwn(next, 'selectedSatTrackingId') && next.selectedSatTrackingId
+ resolveTrackingRestoreTarget: () => ({
+ status: 'source-unavailable',
+ reason: 'partial catalog',
+ }),
+ setParams: (next) =>
+ Object.hasOwn(next, 'selectedSatTrackingId') &&
+ next.selectedSatTrackingId
? 'defer'
- : true
- ),
+ : true,
},
});
const state = createDefaultLayerState();
@@ -1187,7 +1635,10 @@ test('feed failure is not misreported as target absence and malformed time never
// the share. These pin both directions.
// ---------------------------------------------------------------------------
-function pendingTrackingFixture({ resolveStatus = 'missing', copiedAtMs = null } = {}) {
+function pendingTrackingFixture({
+ resolveStatus = 'missing',
+ copiedAtMs = null,
+} = {}) {
const storage = memoryStorage();
const statuses = [];
const paramsCalls = [];
@@ -1199,7 +1650,8 @@ function pendingTrackingFixture({ resolveStatus = 'missing', copiedAtMs = null }
paramsCalls.push({ params: { ...next }, origin: options?.origin });
// Production holds the id pending; getParams keeps reporting untracked
// until the subject actually arrives on a later poll.
- return Object.hasOwn(next, 'selectedFlightsTrackingId') && next.selectedFlightsTrackingId
+ return Object.hasOwn(next, 'selectedFlightsTrackingId') &&
+ next.selectedFlightsTrackingId
? 'defer'
: true;
},
@@ -1258,14 +1710,21 @@ test('share tracking policies pin each owner, key, label, and acquisition deadli
test('a shared subject that has not arrived yet is held pending, never declared gone', async () => {
const f = pendingTrackingFixture();
- await f.coordinator.start({ shareLayerState: f.state, shareCreatedAtMs: f.copiedAtMs });
+ await f.coordinator.start({
+ shareLayerState: f.state,
+ shareCreatedAtMs: f.copiedAtMs,
+ });
const pending = await f.coordinator.restoreShareTrackingSelection();
assert.equal(pending.status, 'pending');
assert.equal(pending.classification, 'pending');
assert.equal(pending.cleared, false);
// No notice, and the subject survives in durable state AND in the URL.
- assert.equal(f.statuses.length, 1, 'a not-yet-arrived subject publishes progress');
+ assert.equal(
+ f.statuses.length,
+ 1,
+ 'a not-yet-arrived subject publishes progress',
+ );
assert.equal(f.statuses[0].classification, 'pending');
assert.equal(
f.coordinator.getDurableState().options.flights.selectedFlightsTrackingId,
@@ -1273,22 +1732,33 @@ test('a shared subject that has not arrived yet is held pending, never declared
'the shared subject must not be cleared while it is still pending',
);
// The layer's own deferred-restore latch was armed, under the passive origin.
- const armed = f.paramsCalls.filter((call) => call.params.selectedFlightsTrackingId === 'late007');
+ const armed = f.paramsCalls.filter(
+ (call) => call.params.selectedFlightsTrackingId === 'late007',
+ );
assert.equal(armed.length, 1, 'the layer latch is armed exactly once');
assert.equal(armed[0].origin, 'share-restore', 'arming must stay passive');
- assert.deepEqual(f.storage.writes, [], 'recipient preferences are never written');
+ assert.deepEqual(
+ f.storage.writes,
+ [],
+ 'recipient preferences are never written',
+ );
f.coordinator.destroy();
});
test('a pending shared subject latches on when it arrives on a later poll', async () => {
const f = pendingTrackingFixture();
- await f.coordinator.start({ shareLayerState: f.state, shareCreatedAtMs: f.copiedAtMs });
+ await f.coordinator.start({
+ shareLayerState: f.state,
+ shareCreatedAtMs: f.copiedAtMs,
+ });
await f.coordinator.restoreShareTrackingSelection();
assert.equal(f.statuses.length, 1);
assert.equal(f.statuses[0].classification, 'pending');
// The contact shows up on a later feed poll, exactly as the layer latch would.
- f.manager.layers.get('flights').module._arrive({ selectedFlightsTrackingId: 'late007' });
+ f.manager.layers
+ .get('flights')
+ .module._arrive({ selectedFlightsTrackingId: 'late007' });
f.timers.runUntilIdle();
assert.equal(f.statuses.length, 2);
@@ -1305,17 +1775,29 @@ test('a pending shared subject latches on when it arrives on a later poll', asyn
test('caller abort after pending handoff revokes the watch and layer latch exactly once', async () => {
const f = pendingTrackingFixture();
const caller = new AbortController();
- await f.coordinator.start({ shareLayerState: f.state, shareCreatedAtMs: f.copiedAtMs });
- const pending = await f.coordinator.restoreShareTrackingSelection({ signal: caller.signal });
+ await f.coordinator.start({
+ shareLayerState: f.state,
+ shareCreatedAtMs: f.copiedAtMs,
+ });
+ const pending = await f.coordinator.restoreShareTrackingSelection({
+ signal: caller.signal,
+ });
assert.equal(pending.classification, 'pending');
caller.abort('caller-cancelled');
f.timers.runUntilIdle();
caller.abort('late-repeat');
- assert.deepEqual(f.statuses.map((status) => status.classification), ['pending', 'cancelled']);
+ assert.deepEqual(
+ f.statuses.map((status) => status.classification),
+ ['pending', 'cancelled'],
+ );
assert.equal(f.statuses[1].reason, 'caller-cancelled');
- assert.equal(f.cancellations.length, 1, 'the module latch is revoked exactly once');
+ assert.equal(
+ f.cancellations.length,
+ 1,
+ 'the module latch is revoked exactly once',
+ );
assert.equal(f.cancellations[0].reason, 'caller-cancelled');
assert.equal(
f.coordinator.getDurableState().options.flights.selectedFlightsTrackingId,
@@ -1329,16 +1811,20 @@ test('a failed deferred-latch arm publishes one terminal failure and never ACQUI
for (const armFailure of [
() => false,
() => Promise.reject(new Error('async latch rejection')),
- () => { throw new Error('sync latch throw'); },
+ () => {
+ throw new Error('sync latch throw');
+ },
]) {
const f = pendingTrackingFixture();
- await f.coordinator.start({ shareLayerState: f.state, shareCreatedAtMs: f.copiedAtMs });
+ await f.coordinator.start({
+ shareLayerState: f.state,
+ shareCreatedAtMs: f.copiedAtMs,
+ });
const setLayerParams = f.manager.setLayerParams.bind(f.manager);
- f.manager.setLayerParams = (layerId, params, options) => (
+ f.manager.setLayerParams = (layerId, params, options) =>
params?.selectedFlightsTrackingId
? armFailure()
- : setLayerParams(layerId, params, options)
- );
+ : setLayerParams(layerId, params, options);
const result = await f.coordinator.restoreShareTrackingSelection();
f.timers.runUntilIdle();
@@ -1351,14 +1837,20 @@ test('a failed deferred-latch arm publishes one terminal failure and never ACQUI
['source-unavailable'],
'a rejected latch never publishes pending progress',
);
- assert.equal(f.coordinator.getDurableState().options.flights.selectedFlightsTrackingId, null);
+ assert.equal(
+ f.coordinator.getDurableState().options.flights.selectedFlightsTrackingId,
+ null,
+ );
f.coordinator.destroy();
}
});
test('same-target supersession cancels the old acquisition before publishing the successor', async () => {
const f = pendingTrackingFixture();
- await f.coordinator.start({ shareLayerState: f.state, shareCreatedAtMs: f.copiedAtMs });
+ await f.coordinator.start({
+ shareLayerState: f.state,
+ shareCreatedAtMs: f.copiedAtMs,
+ });
await f.coordinator.restoreShareTrackingSelection();
await f.coordinator.restoreShareTrackingSelection();
@@ -1374,11 +1866,15 @@ test('same-target supersession cancels the old acquisition before publishing the
test('destroying or superseding the coordinator clears pending progress explicitly', async () => {
for (const teardown of [
(coordinator) => coordinator.destroy(),
- (coordinator) => coordinator.cancelPendingShareTracking('explicit-navigation'),
+ (coordinator) =>
+ coordinator.cancelPendingShareTracking('explicit-navigation'),
(coordinator) => coordinator.cancelPendingRestores('explicit-navigation'),
]) {
const f = pendingTrackingFixture();
- await f.coordinator.start({ shareLayerState: f.state, shareCreatedAtMs: f.copiedAtMs });
+ await f.coordinator.start({
+ shareLayerState: f.state,
+ shareCreatedAtMs: f.copiedAtMs,
+ });
await f.coordinator.restoreShareTrackingSelection();
teardown(f.coordinator);
f.timers.runUntilIdle();
@@ -1406,7 +1902,9 @@ test('tracking resolver false and rejection stay source-unavailable while AbortE
const semanticFalse = await run(() => false);
assert.equal(semanticFalse.status, 'source-unavailable');
- const rejected = await run(() => Promise.reject(new Error('feed unavailable')));
+ const rejected = await run(() =>
+ Promise.reject(new Error('feed unavailable')),
+ );
assert.equal(rejected.status, 'source-unavailable');
assert.equal(rejected.reason, 'feed unavailable');
@@ -1461,16 +1959,25 @@ test('newer explicit tracked target cancels stale shared resolution without clea
...state.options.flights,
selectedFlightsTrackingId: 'stale001',
};
- const coordinator = new LayerStateCoordinator(manager, shareSink(), { storage });
+ const coordinator = new LayerStateCoordinator(manager, shareSink(), {
+ storage,
+ });
await coordinator.start({ shareLayerState: state });
const restore = coordinator.restoreShareTrackingSelection();
- manager.setLayerParams('flights', {
- selectedFlightsTrackingId: 'newer002',
- }, { origin: 'user' });
+ manager.setLayerParams(
+ 'flights',
+ {
+ selectedFlightsTrackingId: 'newer002',
+ },
+ { origin: 'user' },
+ );
resolution.resolve({ status: 'missing' });
const result = await restore;
assert.equal(result.status, 'cancelled');
- assert.equal(coordinator.getDurableState().options.flights.selectedFlightsTrackingId, 'newer002');
+ assert.equal(
+ coordinator.getDurableState().options.flights.selectedFlightsTrackingId,
+ 'newer002',
+ );
assert.equal(storage.writes.length, 1);
coordinator.destroy();
});
@@ -1507,21 +2014,40 @@ test('oversized and out-of-grammar tracking IDs are rejected, never truncated',
);
// Decoding (the untrusted-URL door).
- const decoded = decodeLayerStateParams(new URLSearchParams([
- ['v', '2'], ['l', 'f'], ['lo', `f.t.${huge}`],
- ]));
+ const decoded = decodeLayerStateParams(
+ new URLSearchParams([
+ ['v', '2'],
+ ['l', 'f'],
+ ['lo', `f.t.${huge}`],
+ ]),
+ );
assert.equal(decoded, null, 'an oversized lo payload fails closed');
// A merely long-but-under-cap ID is still out of grammar, and rejected
// WITHOUT taking the rest of the payload down with it.
- const longish = decodeLayerStateParams(new URLSearchParams([
- ['v', '2'], ['l', 'f'], ['lo', `f.e.1_f.t.${'b'.repeat(40)}`],
- ]));
+ const longish = decodeLayerStateParams(
+ new URLSearchParams([
+ ['v', '2'],
+ ['l', 'f'],
+ ['lo', `f.e.1_f.t.${'b'.repeat(40)}`],
+ ]),
+ );
assert.deepEqual(longish.enabledLayerIds, ['flights']);
assert.equal(longish.options.flights.selectedFlightsTrackingId, null);
- assert.equal(longish.options.flights.models3d, true, 'sibling options survive');
+ assert.equal(
+ longish.options.flights.models3d,
+ true,
+ 'sibling options survive',
+ );
- for (const bad of ['ab cd', 'ab/cd', '../../etc', 'a'.repeat(17), '