mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
feat(layers): Recent Imagery — open satellite imagery for a selected box (#716)
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 <[email protected]>
This commit is contained in:
1 parent
25491f7e33
commit
082074a006
50 files changed
+13169
-440
No files matched your search
@@ -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
|
||||
|
||||
@@ -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) |
|
||||
|
||||
+14
-2
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
@@ -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",
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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({
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+32
-136
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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). ' +
|
||||
'<a href="https://gibs.earthdata.nasa.gov" target="_blank" rel="noopener">gibs.earthdata.nasa.gov</a> · ' +
|
||||
'<a href="https://lpdaac.usgs.gov/products/hlss30v002/" target="_blank" rel="noopener">HLS product page</a>',
|
||||
},
|
||||
{
|
||||
key: 'overpass',
|
||||
html:
|
||||
|
||||
+119
-3
@@ -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;
|
||||
|
||||
+1040
-284
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* NASA CMR granule catalog for the HLS products, folded with the VIIRS
|
||||
* overview days into one candidate list.
|
||||
*/
|
||||
import {
|
||||
CATALOG_DAYS,
|
||||
PRODUCTS,
|
||||
coverageFor,
|
||||
groupGranulesByDay,
|
||||
mergeCandidates,
|
||||
utcDay,
|
||||
validateBox,
|
||||
viirsCandidates,
|
||||
} from './model.js';
|
||||
|
||||
const CMR_ENDPOINT = 'https://cmr.earthdata.nasa.gov/search';
|
||||
const DAY_MS = 86_400_000;
|
||||
const CMR_PAGE_SIZE = 200;
|
||||
/**
|
||||
* Most records read per collection. A 1,000 km box can touch hundreds of HLS
|
||||
* tiles a day; past this cap the newest days (the sort order) are kept and
|
||||
* the result is `truncated`.
|
||||
*/
|
||||
const CMR_MAX_RECORDS = 2000;
|
||||
const CMR_SEARCH_AFTER = 'CMR-Search-After';
|
||||
|
||||
/**
|
||||
* Read one product's granules across CMR pages, sending back each response's
|
||||
* `CMR-Search-After` cursor until a short page, a missing cursor or the
|
||||
* record cap ends the walk. `truncated` is measured against CMR's `hits`.
|
||||
* @param {{ product: string, url: string, fetchImpl: typeof fetch, signal?: AbortSignal, maxRecords?: number }} request
|
||||
* @returns {Promise<{ granules: Array<object>, truncated: boolean }>}
|
||||
*/
|
||||
export async function fetchCmrPages({
|
||||
product,
|
||||
url,
|
||||
fetchImpl,
|
||||
signal,
|
||||
maxRecords = CMR_MAX_RECORDS,
|
||||
}) {
|
||||
const granules = [];
|
||||
let hits = 0;
|
||||
let cursor = null;
|
||||
for (;;) {
|
||||
const init = { signal };
|
||||
if (cursor) init.headers = { [CMR_SEARCH_AFTER]: cursor };
|
||||
const response = await fetchImpl(url, init);
|
||||
if (!response?.ok)
|
||||
throw new Error(`HTTP ${response?.status ?? 'error'} from CMR`);
|
||||
const page = parseCmrUmm(await response.json(), product);
|
||||
granules.push(...page.granules);
|
||||
hits = Math.max(hits, page.hits);
|
||||
cursor = response.headers?.get?.(CMR_SEARCH_AFTER) ?? null;
|
||||
if (
|
||||
!cursor ||
|
||||
page.granules.length < CMR_PAGE_SIZE ||
|
||||
granules.length >= maxRecords ||
|
||||
granules.length >= hits
|
||||
)
|
||||
break;
|
||||
}
|
||||
return { granules, truncated: hits > granules.length };
|
||||
}
|
||||
|
||||
function finiteOrNull(value) {
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
const numeric = Number(value);
|
||||
return Number.isFinite(numeric) ? numeric : null;
|
||||
}
|
||||
|
||||
function isoOrNull(value) {
|
||||
return typeof value === 'string' && Number.isFinite(Date.parse(value))
|
||||
? value
|
||||
: null;
|
||||
}
|
||||
|
||||
function ringOf(points) {
|
||||
const ring = points
|
||||
.map(([lon, lat]) => [finiteOrNull(lon), finiteOrNull(lat)])
|
||||
.filter(([lon, lat]) => lon !== null && lat !== null);
|
||||
return ring.length >= 3 ? ring : null;
|
||||
}
|
||||
|
||||
function footprintOf(geometry) {
|
||||
const polygon = geometry?.GPolygons?.[0]?.Boundary?.Points;
|
||||
if (Array.isArray(polygon)) {
|
||||
const ring = ringOf(polygon.map((p) => [p?.Longitude, p?.Latitude]));
|
||||
if (ring) return ring;
|
||||
}
|
||||
const r = geometry?.BoundingRectangles?.[0];
|
||||
if (!r) return null;
|
||||
const [w, s, e, n] = [
|
||||
r.WestBoundingCoordinate,
|
||||
r.SouthBoundingCoordinate,
|
||||
r.EastBoundingCoordinate,
|
||||
r.NorthBoundingCoordinate,
|
||||
];
|
||||
const ring = ringOf([
|
||||
[w, s],
|
||||
[e, s],
|
||||
[e, n],
|
||||
[w, n],
|
||||
]);
|
||||
return ring?.length === 4 ? ring : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a CMR `granules.umm_json` response. Every field is optional upstream;
|
||||
* granules without a parseable start time are dropped.
|
||||
* @param {object} json
|
||||
* @param {string} product `S30` | `L30`
|
||||
* @returns {{ granules: Array<object>, hits: number }}
|
||||
*/
|
||||
export function parseCmrUmm(json, product) {
|
||||
const granules = [];
|
||||
for (const item of Array.isArray(json?.items) ? json.items : []) {
|
||||
const umm = item?.umm || {};
|
||||
const meta = item?.meta || {};
|
||||
const range = umm.TemporalExtent?.RangeDateTime || {};
|
||||
const timeStart = isoOrNull(range.BeginningDateTime);
|
||||
if (!timeStart) continue;
|
||||
const cloud = Array.isArray(umm.AdditionalAttributes)
|
||||
? umm.AdditionalAttributes.find((a) => a?.Name === 'CLOUD_COVERAGE')
|
||||
?.Values?.[0]
|
||||
: null;
|
||||
granules.push({
|
||||
id: String(
|
||||
meta['concept-id'] || umm.GranuleUR || meta['native-id'] || '',
|
||||
),
|
||||
product,
|
||||
timeStart,
|
||||
timeEnd: isoOrNull(range.EndingDateTime) || timeStart,
|
||||
cloud: finiteOrNull(cloud),
|
||||
footprint: footprintOf(
|
||||
umm.SpatialExtent?.HorizontalSpatialDomain?.Geometry,
|
||||
),
|
||||
});
|
||||
}
|
||||
return { granules, hits: finiteOrNull(json?.hits) ?? granules.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* CMR granule search URL for one HLS product over a box and time window.
|
||||
* @param {{ product: string, box: object, startIso: string, endIso: string }} query
|
||||
* @returns {string}
|
||||
*/
|
||||
export function cmrSearchUrl({ product, box, startIso, endIso }) {
|
||||
const spec = PRODUCTS[product];
|
||||
if (!spec?.cmrCollection)
|
||||
throw new TypeError(`No CMR collection for product: ${product}`);
|
||||
const result = validateBox(box);
|
||||
if (!result.ok) throw new TypeError(result.message);
|
||||
const { west, south, east, north } = result.box;
|
||||
return (
|
||||
`${CMR_ENDPOINT}/granules.umm_json` +
|
||||
`?collection_concept_id=${spec.cmrCollection}` +
|
||||
`&bounding_box=${west},${south},${east},${north}` +
|
||||
`&temporal=${startIso},${endIso}` +
|
||||
`&sort_key=-start_date&page_size=${CMR_PAGE_SIZE}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search both HLS collections concurrently and fold the results with the
|
||||
* VIIRS overview days. One product failing still yields the other's days plus
|
||||
* an `errors` entry; an invalid box or clock throws a TypeError.
|
||||
* @param {{ box: object, days?: number, now?: Date, fetchImpl?: typeof fetch, signal?: AbortSignal }} request
|
||||
* @returns {Promise<{ candidates: Array<object>, truncated: boolean, errors: Array<{ product: string, message: string }> }>}
|
||||
*/
|
||||
export async function searchHls({
|
||||
box,
|
||||
days = CATALOG_DAYS,
|
||||
now = new Date(),
|
||||
fetchImpl = globalThis.fetch,
|
||||
signal,
|
||||
} = {}) {
|
||||
const validated = validateBox(box);
|
||||
if (!validated.ok) throw new TypeError(validated.message);
|
||||
if (typeof fetchImpl !== 'function')
|
||||
throw new TypeError('A fetch implementation is required');
|
||||
const nowMs = now instanceof Date ? now.getTime() : Date.parse(now);
|
||||
if (!Number.isFinite(nowMs)) throw new TypeError('A valid clock is required');
|
||||
const endIso = new Date(nowMs).toISOString();
|
||||
const startIso = `${utcDay(new Date(nowMs - (days - 1) * DAY_MS))}T00:00:00Z`;
|
||||
const products = ['S30', 'L30'];
|
||||
const settled = await Promise.allSettled(
|
||||
products.map((product) =>
|
||||
fetchCmrPages({
|
||||
product,
|
||||
url: cmrSearchUrl({ product, box: validated.box, startIso, endIso }),
|
||||
fetchImpl,
|
||||
signal,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const granules = [];
|
||||
const errors = [];
|
||||
let truncated = false;
|
||||
settled.forEach((result, index) => {
|
||||
if (result.status === 'fulfilled') {
|
||||
granules.push(...result.value.granules);
|
||||
truncated ||= result.value.truncated;
|
||||
} else {
|
||||
errors.push({
|
||||
product: products[index],
|
||||
message: String(result.reason?.message || result.reason),
|
||||
});
|
||||
}
|
||||
});
|
||||
const hls = groupGranulesByDay(granules).map((candidate) => ({
|
||||
...candidate,
|
||||
coverage: coverageFor(candidate, validated.box),
|
||||
}));
|
||||
return {
|
||||
candidates: mergeCandidates([hls, viirsCandidates(endIso, days)]),
|
||||
truncated,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
cmrSearchUrl,
|
||||
fetchCmrPages,
|
||||
parseCmrUmm,
|
||||
searchHls,
|
||||
} from './catalog.js';
|
||||
import { BOX, response } from './testDoubles.mjs';
|
||||
|
||||
function ummItem({
|
||||
conceptId = 'G1-LPCLOUD',
|
||||
begin = '2026-09-18T17:12:01.000Z',
|
||||
end = '2026-09-18T17:12:31.000Z',
|
||||
cloud = '12',
|
||||
polygon = [
|
||||
[-98, 30],
|
||||
[-97, 30],
|
||||
[-97, 31],
|
||||
[-98, 31],
|
||||
],
|
||||
rectangle = null,
|
||||
} = {}) {
|
||||
const Geometry = {};
|
||||
if (polygon)
|
||||
Geometry.GPolygons = [
|
||||
{
|
||||
Boundary: {
|
||||
Points: polygon.map(([Longitude, Latitude]) => ({
|
||||
Longitude,
|
||||
Latitude,
|
||||
})),
|
||||
},
|
||||
},
|
||||
];
|
||||
if (rectangle)
|
||||
Geometry.BoundingRectangles = [
|
||||
{
|
||||
WestBoundingCoordinate: rectangle[0],
|
||||
SouthBoundingCoordinate: rectangle[1],
|
||||
EastBoundingCoordinate: rectangle[2],
|
||||
NorthBoundingCoordinate: rectangle[3],
|
||||
},
|
||||
];
|
||||
return {
|
||||
meta: { 'concept-id': conceptId },
|
||||
umm: {
|
||||
TemporalExtent: {
|
||||
RangeDateTime: { BeginningDateTime: begin, EndingDateTime: end },
|
||||
},
|
||||
AdditionalAttributes: [
|
||||
{ Name: 'SPATIAL_COVERAGE', Values: ['100'] },
|
||||
...(cloud === undefined
|
||||
? []
|
||||
: [{ Name: 'CLOUD_COVERAGE', Values: [cloud] }]),
|
||||
],
|
||||
SpatialExtent: { HorizontalSpatialDomain: { Geometry } },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('umm_json granules carry id, times, scene cloud and a footprint ring', () => {
|
||||
const { granules, hits } = parseCmrUmm(
|
||||
{
|
||||
hits: 9,
|
||||
items: [
|
||||
ummItem(),
|
||||
ummItem({
|
||||
conceptId: 'G2',
|
||||
cloud: '3.5',
|
||||
polygon: null,
|
||||
rectangle: [-98.5, 29.5, -97.5, 30.5],
|
||||
}),
|
||||
{
|
||||
meta: {},
|
||||
umm: {
|
||||
GranuleUR: 'HLS.L30.only-ur',
|
||||
TemporalExtent: {
|
||||
RangeDateTime: { BeginningDateTime: '2026-09-01T16:00:00Z' },
|
||||
},
|
||||
},
|
||||
},
|
||||
ummItem({ conceptId: 'G-bad-cloud', cloud: 'n/a' }),
|
||||
ummItem({
|
||||
conceptId: 'G-two-points',
|
||||
polygon: [
|
||||
[0, 0],
|
||||
[1, 1],
|
||||
],
|
||||
}),
|
||||
{ meta: { 'concept-id': 'G-no-time' }, umm: {} },
|
||||
{
|
||||
meta: { 'concept-id': 'G-bad-time' },
|
||||
umm: {
|
||||
TemporalExtent: { RangeDateTime: { BeginningDateTime: 'soon' } },
|
||||
},
|
||||
},
|
||||
null,
|
||||
],
|
||||
},
|
||||
'S30',
|
||||
);
|
||||
assert.equal(hits, 9);
|
||||
assert.deepEqual(granules[0], {
|
||||
id: 'G1-LPCLOUD',
|
||||
product: 'S30',
|
||||
timeStart: '2026-09-18T17:12:01.000Z',
|
||||
timeEnd: '2026-09-18T17:12:31.000Z',
|
||||
cloud: 12,
|
||||
footprint: [
|
||||
[-98, 30],
|
||||
[-97, 30],
|
||||
[-97, 31],
|
||||
[-98, 31],
|
||||
],
|
||||
});
|
||||
assert.equal(granules[1].cloud, 3.5);
|
||||
assert.deepEqual(granules[1].footprint, [
|
||||
[-98.5, 29.5],
|
||||
[-97.5, 29.5],
|
||||
[-97.5, 30.5],
|
||||
[-98.5, 30.5],
|
||||
]);
|
||||
assert.deepEqual(
|
||||
granules.slice(2).map((g) => [g.id, g.cloud, g.footprint]),
|
||||
[
|
||||
['HLS.L30.only-ur', null, null],
|
||||
['G-bad-cloud', null, granules[0].footprint],
|
||||
['G-two-points', 12, null],
|
||||
],
|
||||
'missing fields stay unknown; granules without a start time are dropped',
|
||||
);
|
||||
assert.equal(granules[2].timeEnd, '2026-09-01T16:00:00Z');
|
||||
assert.deepEqual(parseCmrUmm(null, 'S30'), { granules: [], hits: 0 });
|
||||
});
|
||||
|
||||
test('the CMR query names the collection, box, window and page size with literal commas', () => {
|
||||
assert.equal(
|
||||
cmrSearchUrl({
|
||||
product: 'S30',
|
||||
box: BOX,
|
||||
startIso: '2026-08-23T00:00:00Z',
|
||||
endIso: '2026-09-21T12:00:00.000Z',
|
||||
}),
|
||||
'https://cmr.earthdata.nasa.gov/search/granules.umm_json?collection_concept_id=C2021957295-LPCLOUD&bounding_box=-97.8,30.2,-97.7,30.3&temporal=2026-08-23T00:00:00Z,2026-09-21T12:00:00.000Z&sort_key=-start_date&page_size=200',
|
||||
);
|
||||
assert.match(
|
||||
cmrSearchUrl({ product: 'L30', box: BOX, startIso: 'a', endIso: 'b' }),
|
||||
/collection_concept_id=C2021957657-LPCLOUD/,
|
||||
);
|
||||
assert.throws(
|
||||
() => cmrSearchUrl({ product: 'VIIRS', box: BOX }),
|
||||
/No CMR collection/,
|
||||
);
|
||||
});
|
||||
|
||||
/** Route a CMR request to the S30 or L30 handler by collection id. */
|
||||
function cmrFetch(handlers, calls = []) {
|
||||
return async (url, init) => {
|
||||
calls.push({ url, init });
|
||||
const handler = handlers[url.includes('C2021957295') ? 'S30' : 'L30'];
|
||||
return typeof handler === 'function'
|
||||
? handler()
|
||||
: response({ json: handler });
|
||||
};
|
||||
}
|
||||
|
||||
test('both HLS collections fold with the VIIRS days; one failing product is reported, not fatal', async () => {
|
||||
const calls = [];
|
||||
const signal = new AbortController().signal;
|
||||
const result = await searchHls({
|
||||
box: BOX,
|
||||
now: new Date('2026-09-21T12:00:00Z'),
|
||||
signal,
|
||||
fetchImpl: cmrFetch(
|
||||
{
|
||||
S30: {
|
||||
hits: 2,
|
||||
items: [
|
||||
ummItem(),
|
||||
ummItem({ conceptId: 'G-old', begin: '2026-09-08T17:12:01Z' }),
|
||||
],
|
||||
},
|
||||
L30: () => response({ ok: false, status: 503 }),
|
||||
},
|
||||
calls,
|
||||
),
|
||||
});
|
||||
assert.deepEqual(result.errors, [
|
||||
{ product: 'L30', message: 'HTTP 503 from CMR' },
|
||||
]);
|
||||
assert.equal(result.truncated, false);
|
||||
assert.equal(calls[0].init.signal, signal);
|
||||
assert.match(
|
||||
calls[0].url,
|
||||
/temporal=2026-08-23T00:00:00Z,2026-09-21T12:00:00\.000Z/,
|
||||
'30 UTC days ending now',
|
||||
);
|
||||
const keys = result.candidates.map((c) => c.key);
|
||||
assert.equal(keys.length, 32);
|
||||
assert.deepEqual(keys.slice(2, 5), [
|
||||
'VIIRS:2026-09-19',
|
||||
'S30:2026-09-18',
|
||||
'VIIRS:2026-09-18',
|
||||
]);
|
||||
assert.ok(keys.includes('S30:2026-09-08'));
|
||||
assert.equal(
|
||||
result.candidates.find((c) => c.key === 'S30:2026-09-18').coverage,
|
||||
'full',
|
||||
'coverage is computed against the box',
|
||||
);
|
||||
|
||||
const thrown = await searchHls({
|
||||
box: BOX,
|
||||
days: 2,
|
||||
now: '2026-09-21T00:00:00Z',
|
||||
fetchImpl: cmrFetch({
|
||||
S30: () => {
|
||||
throw new Error('network down');
|
||||
},
|
||||
L30: { hits: 500, items: [ummItem({ conceptId: 'G-L30' })] },
|
||||
}),
|
||||
});
|
||||
assert.deepEqual(thrown.errors, [
|
||||
{ product: 'S30', message: 'network down' },
|
||||
]);
|
||||
assert.equal(thrown.truncated, true, 'CMR has more hits than were read');
|
||||
assert.deepEqual(
|
||||
thrown.candidates.map((c) => c.key),
|
||||
['VIIRS:2026-09-21', 'VIIRS:2026-09-20', 'L30:2026-09-18'],
|
||||
);
|
||||
});
|
||||
|
||||
test('an invalid box or clock is refused before any request', async () => {
|
||||
let called = false;
|
||||
const fetchImpl = async () => {
|
||||
called = true;
|
||||
};
|
||||
await assert.rejects(
|
||||
searchHls({
|
||||
box: { west: 179, south: 0, east: -179, north: 1 },
|
||||
fetchImpl,
|
||||
}),
|
||||
/dateline/,
|
||||
);
|
||||
await assert.rejects(
|
||||
searchHls({ box: BOX, now: 'never', fetchImpl }),
|
||||
/clock/,
|
||||
);
|
||||
assert.equal(called, false);
|
||||
});
|
||||
|
||||
test('CMR pages follow the CMR-Search-After cursor up to 2,000 records and truncation stays honest', async () => {
|
||||
const items = (count) =>
|
||||
Array.from({ length: count }, (_, i) => ummItem({ conceptId: `G-${i}` }));
|
||||
const cursors = [];
|
||||
const paged = await fetchCmrPages({
|
||||
product: 'S30',
|
||||
url: 'https://cmr/x',
|
||||
fetchImpl: async (url, init) => {
|
||||
const page = cursors.push(init.headers?.['CMR-Search-After'] ?? null);
|
||||
return response({
|
||||
headers: { 'CMR-Search-After': `cursor-${page}` },
|
||||
json: { hits: 450, items: items(page < 3 ? 200 : 50) },
|
||||
});
|
||||
},
|
||||
});
|
||||
assert.equal(paged.granules.length, 450);
|
||||
assert.equal(paged.truncated, false);
|
||||
assert.deepEqual(cursors, [null, 'cursor-1', 'cursor-2']);
|
||||
|
||||
let pages = 0;
|
||||
const capped = await fetchCmrPages({
|
||||
product: 'L30',
|
||||
url: 'https://cmr/y',
|
||||
fetchImpl: async () => {
|
||||
pages += 1;
|
||||
return response({
|
||||
headers: { 'cmr-search-after': 'more' },
|
||||
json: { hits: 5000, items: items(200) },
|
||||
});
|
||||
},
|
||||
});
|
||||
assert.equal(pages, 10);
|
||||
assert.equal(capped.granules.length, 2000);
|
||||
assert.equal(capped.truncated, true);
|
||||
|
||||
const noCursor = await fetchCmrPages({
|
||||
product: 'S30',
|
||||
url: 'u',
|
||||
fetchImpl: async () => response({ json: { hits: 900, items: items(200) } }),
|
||||
});
|
||||
assert.equal(noCursor.granules.length, 200);
|
||||
assert.equal(noCursor.truncated, true);
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,613 @@
|
||||
/**
|
||||
* Pure domain model for the Recent Imagery layer: products, the selection box,
|
||||
* per-day candidates, ranking and the keyless URL builders. No DOM, no Cesium,
|
||||
* no network.
|
||||
*/
|
||||
|
||||
/** How far back the catalog looks, in UTC days. */
|
||||
export const CATALOG_DAYS = 30;
|
||||
|
||||
const MAX_BOX_SIDE_KM = 1000;
|
||||
/** ZOOM IN lands where the top-down view is this wide, well inside the cap. */
|
||||
const FIT_VIEW_KM = 400;
|
||||
const FIT_HEIGHT_KM = Object.freeze({ min: 5, max: 400 });
|
||||
/** GIBS EPSG:3857 tiles do not exist beyond the Web-Mercator limit. */
|
||||
const MERCATOR_LAT_LIMIT = 85.0511;
|
||||
const MAX_CLOUD_FOR_CLEAR = 20;
|
||||
const PIN_BOX_SIDE_KM = 10;
|
||||
const WVS_ENDPOINT = 'https://wvs.earthdata.nasa.gov/api/v1/snapshot';
|
||||
const KM_PER_DEG_LAT = 111.32;
|
||||
const QUANTUM = 100000;
|
||||
const DAY_MS = 86_400_000;
|
||||
const PRODUCT_ORDER = ['S30', 'L30', 'VIIRS'];
|
||||
const MONTHS = 'Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec'.split(' ');
|
||||
|
||||
/**
|
||||
* Keyless, browser-direct imagery products. HLS products are searched via
|
||||
* CMR; the VIIRS overview is a daily global mosaic with no granule catalog.
|
||||
* `source` is the on/off group the row chips toggle.
|
||||
*/
|
||||
export const PRODUCTS = Object.freeze({
|
||||
S30: Object.freeze({
|
||||
label: 'HLS S30',
|
||||
name: 'Sentinel-2',
|
||||
source: 'hls',
|
||||
sensor: 'Sentinel-2 via HLS',
|
||||
resolutionM: 30,
|
||||
gibsLayer: 'HLS_S30_Nadir_BRDF_Adjusted_Reflectance',
|
||||
maxLevel: 12,
|
||||
format: 'png',
|
||||
cmrCollection: 'C2021957295-LPCLOUD',
|
||||
overview: false,
|
||||
}),
|
||||
L30: Object.freeze({
|
||||
label: 'HLS L30',
|
||||
name: 'Landsat 8/9',
|
||||
source: 'hls',
|
||||
sensor: 'Landsat 8/9 via HLS',
|
||||
resolutionM: 30,
|
||||
gibsLayer: 'HLS_L30_Nadir_BRDF_Adjusted_Reflectance',
|
||||
maxLevel: 12,
|
||||
format: 'png',
|
||||
cmrCollection: 'C2021957657-LPCLOUD',
|
||||
overview: false,
|
||||
}),
|
||||
VIIRS: Object.freeze({
|
||||
label: 'VIIRS',
|
||||
name: 'Daily overview',
|
||||
source: 'viirs',
|
||||
sensor: 'VIIRS NOAA-21',
|
||||
resolutionM: 250,
|
||||
gibsLayer: 'VIIRS_NOAA21_CorrectedReflectance_TrueColor',
|
||||
maxLevel: 9,
|
||||
format: 'jpg',
|
||||
cmrCollection: null,
|
||||
overview: true,
|
||||
}),
|
||||
});
|
||||
|
||||
const finite = (value) => typeof value === 'number' && Number.isFinite(value);
|
||||
const toRadians = (degrees) => (degrees * Math.PI) / 180;
|
||||
const pad2 = (value) => String(value).padStart(2, '0');
|
||||
|
||||
/**
|
||||
* Coerce a box to `{ west, south, east, north }` with south ≤ north. West and
|
||||
* east are not swapped: west > east crosses the dateline and `validateBox`
|
||||
* rejects it. Null when any edge is nonfinite.
|
||||
*/
|
||||
function normalizeBox(box) {
|
||||
if (!box || typeof box !== 'object') return null;
|
||||
const [west, south, east, north] = [
|
||||
box.west,
|
||||
box.south,
|
||||
box.east,
|
||||
box.north,
|
||||
].map(Number);
|
||||
if (![west, south, east, north].every(Number.isFinite)) return null;
|
||||
return {
|
||||
west,
|
||||
south: Math.min(south, north),
|
||||
east,
|
||||
north: Math.max(south, north),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Side lengths of a box in km: the box's own longitude span (never the
|
||||
* shorter way round the globe) at its mid-latitude, and its latitude span.
|
||||
* @param {object} box Degrees box.
|
||||
* @returns {{ width: number, height: number }}
|
||||
*/
|
||||
export function boxSideKm(box) {
|
||||
const normalized = normalizeBox(box);
|
||||
if (!normalized) return { width: NaN, height: NaN };
|
||||
const midLat = (normalized.south + normalized.north) / 2;
|
||||
return {
|
||||
width:
|
||||
(normalized.east - normalized.west) *
|
||||
KM_PER_DEG_LAT *
|
||||
Math.cos(toRadians(midLat)),
|
||||
height: (normalized.north - normalized.south) * KM_PER_DEG_LAT,
|
||||
};
|
||||
}
|
||||
|
||||
const refuse = (reason, message) => ({ ok: false, reason, message });
|
||||
|
||||
/**
|
||||
* Validate a selection box for the catalog and the tile providers.
|
||||
* @param {object} box Degrees box.
|
||||
* @returns {{ ok: true, box: object } | { ok: false, reason: 'dateline' | 'polar' | 'degenerate' | 'too-large' | 'invalid', message: string }}
|
||||
*/
|
||||
export function validateBox(box) {
|
||||
const normalized = normalizeBox(box);
|
||||
if (!normalized) return refuse('invalid', 'Box edges must be finite');
|
||||
const { west, south, east, north } = normalized;
|
||||
if (Math.abs(west) > 180 || Math.abs(east) > 180)
|
||||
return refuse('invalid', 'Longitudes must be within ±180°');
|
||||
if (west > east) return refuse('dateline', 'Select one side of the dateline');
|
||||
if (
|
||||
Math.abs(south) > MERCATOR_LAT_LIMIT ||
|
||||
Math.abs(north) > MERCATOR_LAT_LIMIT
|
||||
)
|
||||
return refuse(
|
||||
'polar',
|
||||
`Imagery stops at ±${MERCATOR_LAT_LIMIT.toFixed(2)}° latitude`,
|
||||
);
|
||||
if (east === west || north === south)
|
||||
return refuse('degenerate', 'Box must enclose an area');
|
||||
const { width, height } = boxSideKm(normalized);
|
||||
if (width > MAX_BOX_SIDE_KM || height > MAX_BOX_SIDE_KM)
|
||||
return refuse('too-large', tooLargeMessage(Math.max(width, height)));
|
||||
return { ok: true, box: normalized };
|
||||
}
|
||||
|
||||
const formatKm = (km) => Math.round(Number(km) || 0).toLocaleString('en-US');
|
||||
|
||||
// One short line each: the panel's hint says what to do about it.
|
||||
function tooLargeMessage(sideKm) {
|
||||
return `Box is ${formatKm(sideKm)} km wide · limit ${formatKm(MAX_BOX_SIDE_KM)} km`;
|
||||
}
|
||||
|
||||
/** The refusal for USE VIEW when the camera sees more than the cap allows. */
|
||||
export function viewTooLargeMessage(sideKm) {
|
||||
return `View is ${formatKm(sideKm)} km wide · limit ${formatKm(MAX_BOX_SIDE_KM)} km`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The camera height, in metres, at which a top-down view is `FIT_VIEW_KM`
|
||||
* wide: the horizontal half-angle is atan(tan(fovy / 2) · width / height).
|
||||
* Clamped to 5–400 km; without a canvas or FOV, a square 60° view.
|
||||
* @param {{ width?: number, height?: number, fovy?: number }} view Canvas size in px, vertical FOV in radians.
|
||||
* @returns {number}
|
||||
*/
|
||||
export function fitViewHeightM({ width, height, fovy } = {}) {
|
||||
const aspect = Number(width) / Number(height);
|
||||
const valid =
|
||||
Number.isFinite(aspect) && aspect > 0 && fovy > 0 && fovy < Math.PI;
|
||||
const halfWidth = valid ? Math.tan(fovy / 2) * aspect : Math.tan(Math.PI / 6);
|
||||
const km = FIT_VIEW_KM / (2 * halfWidth);
|
||||
return Math.min(FIT_HEIGHT_KM.max, Math.max(FIT_HEIGHT_KM.min, km)) * 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* The centre of a box that does not cross the dateline, in degrees.
|
||||
* @param {object} box Degrees box.
|
||||
* @returns {{ lon: number, lat: number } | null}
|
||||
*/
|
||||
export function boxCentre(box) {
|
||||
const normalized = normalizeBox(box);
|
||||
if (!normalized) return null;
|
||||
return {
|
||||
lon: (normalized.west + normalized.east) / 2,
|
||||
lat: (normalized.south + normalized.north) / 2,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A square box centred on a pin, `sideKm` on the ground (the east–west extent
|
||||
* widens by 1/cos(lat)); null when the result would not validate.
|
||||
* @param {number} lon Degrees.
|
||||
* @param {number} lat Degrees.
|
||||
* @param {number} [sideKm]
|
||||
* @returns {object | null}
|
||||
*/
|
||||
export function boxFromPin(lon, lat, sideKm = PIN_BOX_SIDE_KM) {
|
||||
if (!finite(lon) || !finite(lat) || !finite(sideKm) || sideKm <= 0)
|
||||
return null;
|
||||
const cosLat = Math.cos(toRadians(lat));
|
||||
if (cosLat <= 0) return null;
|
||||
const halfLat = sideKm / 2 / KM_PER_DEG_LAT;
|
||||
const halfLon = sideKm / 2 / (KM_PER_DEG_LAT * cosLat);
|
||||
const clampLat = (value) =>
|
||||
Math.max(-MERCATOR_LAT_LIMIT, Math.min(MERCATOR_LAT_LIMIT, value));
|
||||
const result = validateBox({
|
||||
west: lon - halfLon,
|
||||
south: clampLat(lat - halfLat),
|
||||
east: lon + halfLon,
|
||||
north: clampLat(lat + halfLat),
|
||||
});
|
||||
return result.ok ? result.box : null;
|
||||
}
|
||||
|
||||
function mapBox(box, fn) {
|
||||
if (!box || typeof box !== 'object') return null;
|
||||
return normalizeBox({
|
||||
west: fn(box.west),
|
||||
south: fn(box.south),
|
||||
east: fn(box.east),
|
||||
north: fn(box.north),
|
||||
});
|
||||
}
|
||||
|
||||
/** Degrees box from a radians rectangle (Cesium `Rectangle` shape). */
|
||||
export function boxFromRectangle(rectangle) {
|
||||
return mapBox(rectangle, (radians) => (Number(radians) * 180) / Math.PI);
|
||||
}
|
||||
|
||||
/** Degrees box → signed integers at degrees × 100000 (the share-link form). */
|
||||
export function quantizeBox(box) {
|
||||
return mapBox(normalizeBox(box), (value) => Math.round(value * QUANTUM));
|
||||
}
|
||||
|
||||
/** Inverse of `quantizeBox`. */
|
||||
export function dequantizeBox(ints) {
|
||||
return mapBox(ints, (value) => Number(value) / QUANTUM);
|
||||
}
|
||||
|
||||
function isValidDay(day) {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(day));
|
||||
if (!match) return false;
|
||||
const time = Date.UTC(
|
||||
Number(match[1]),
|
||||
Number(match[2]) - 1,
|
||||
Number(match[3]),
|
||||
);
|
||||
return (
|
||||
Number.isFinite(time) && new Date(time).toISOString().slice(0, 10) === day
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* UTC calendar day (`YYYY-MM-DD`) of an ISO timestamp, Date or day string;
|
||||
* null when unparseable or not a real day.
|
||||
* @param {string | number | Date} value
|
||||
* @returns {string | null}
|
||||
*/
|
||||
export function utcDay(value) {
|
||||
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value))
|
||||
return isValidDay(value) ? value : null;
|
||||
const time = value instanceof Date ? value.getTime() : Date.parse(value);
|
||||
return Number.isFinite(time)
|
||||
? new Date(time).toISOString().slice(0, 10)
|
||||
: null;
|
||||
}
|
||||
|
||||
/** `Sep 18` for a `YYYY-MM-DD` day; '' otherwise. */
|
||||
export function shortDay(day) {
|
||||
const match = /^\d{4}-(\d{2})-(\d{2})$/.exec(String(day || ''));
|
||||
return match ? `${MONTHS[Number(match[1]) - 1]} ${Number(match[2])}` : '';
|
||||
}
|
||||
|
||||
/** `17:12` (UTC) for an ISO timestamp; '' when unparseable. */
|
||||
export function hhmm(iso) {
|
||||
const time = Date.parse(iso);
|
||||
if (!Number.isFinite(time)) return '';
|
||||
const date = new Date(time);
|
||||
return `${pad2(date.getUTCHours())}:${pad2(date.getUTCMinutes())}`;
|
||||
}
|
||||
|
||||
/** Candidate key: `PRODUCT:YYYY-MM-DD`. */
|
||||
export function candidateKey(product, day) {
|
||||
return `${product}:${day}`;
|
||||
}
|
||||
|
||||
/** Split a candidate key; null when the product is unknown or the day invalid. */
|
||||
export function parseCandidateKey(key) {
|
||||
const match = /^(S30|L30|VIIRS):(\d{4}-\d{2}-\d{2})$/.exec(String(key));
|
||||
if (!match || !isValidDay(match[2])) return null;
|
||||
return { product: match[1], day: match[2] };
|
||||
}
|
||||
|
||||
const cloudOf = (granule) => (finite(granule?.cloud) ? granule.cloud : null);
|
||||
|
||||
function buildCandidate(product, day, granules) {
|
||||
const clouds = granules.map(cloudOf).filter((value) => value !== null);
|
||||
const starts = granules
|
||||
.map((granule) => Date.parse(granule.timeStart))
|
||||
.filter(Number.isFinite);
|
||||
const ends = granules
|
||||
.map((granule) => Date.parse(granule.timeEnd ?? granule.timeStart))
|
||||
.filter(Number.isFinite);
|
||||
return {
|
||||
key: candidateKey(product, day),
|
||||
product,
|
||||
day,
|
||||
granules,
|
||||
cloud: clouds.length
|
||||
? { min: Math.min(...clouds), max: Math.max(...clouds) }
|
||||
: null,
|
||||
timeRange:
|
||||
starts.length && ends.length
|
||||
? {
|
||||
start: new Date(Math.min(...starts)).toISOString(),
|
||||
end: new Date(Math.max(...ends)).toISOString(),
|
||||
}
|
||||
: null,
|
||||
availability: granules.length ? 'present' : 'empty',
|
||||
coverage: 'unknown',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Group granules into one candidate per product and UTC day of `timeStart`,
|
||||
* newest first. Granules without a known product or start time are dropped.
|
||||
* @param {Array<object>} granules
|
||||
* @returns {Array<object>}
|
||||
*/
|
||||
export function groupGranulesByDay(granules) {
|
||||
const buckets = new Map();
|
||||
for (const granule of Array.isArray(granules) ? granules : []) {
|
||||
const day = utcDay(granule?.timeStart);
|
||||
if (!PRODUCTS[granule?.product] || !day) continue;
|
||||
const key = candidateKey(granule.product, day);
|
||||
if (!buckets.has(key))
|
||||
buckets.set(key, { product: granule.product, day, granules: [] });
|
||||
buckets.get(key).granules.push(granule);
|
||||
}
|
||||
return mergeCandidates([
|
||||
[...buckets.values()].map(({ product, day, granules: list }) =>
|
||||
buildCandidate(product, day, list),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* One VIIRS overview candidate per UTC day for the last `days` days, today
|
||||
* first. Availability stays unknown until the thumbnail probe answers;
|
||||
* coverage is full because the overview is a global mosaic.
|
||||
* @param {string | Date} todayIso
|
||||
* @param {number} [days]
|
||||
* @returns {Array<object>}
|
||||
*/
|
||||
export function viirsCandidates(todayIso, days = CATALOG_DAYS) {
|
||||
const today = utcDay(todayIso);
|
||||
if (!today) return [];
|
||||
const start = Date.parse(`${today}T00:00:00Z`);
|
||||
const out = [];
|
||||
for (let i = 0; i < Math.max(0, Math.trunc(days)); i += 1) {
|
||||
const day = new Date(start - i * DAY_MS).toISOString().slice(0, 10);
|
||||
out.push({
|
||||
key: candidateKey('VIIRS', day),
|
||||
product: 'VIIRS',
|
||||
day,
|
||||
granules: [],
|
||||
cloud: null,
|
||||
timeRange: null,
|
||||
availability: 'unknown',
|
||||
coverage: 'full',
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge candidate lists newest day first, then S30, L30, VIIRS; the first
|
||||
* occurrence of a key wins.
|
||||
* @param {Array<Array<object>>} lists
|
||||
* @returns {Array<object>}
|
||||
*/
|
||||
export function mergeCandidates(lists) {
|
||||
const byKey = new Map();
|
||||
for (const list of lists) {
|
||||
for (const candidate of list || []) {
|
||||
if (candidate?.key && !byKey.has(candidate.key))
|
||||
byKey.set(candidate.key, candidate);
|
||||
}
|
||||
}
|
||||
return [...byKey.values()].sort((a, b) =>
|
||||
a.day !== b.day
|
||||
? a.day < b.day
|
||||
? 1
|
||||
: -1
|
||||
: PRODUCT_ORDER.indexOf(a.product) - PRODUCT_ORDER.indexOf(b.product),
|
||||
);
|
||||
}
|
||||
|
||||
function pointInPolygon(lon, lat, ring) {
|
||||
let inside = false;
|
||||
for (let i = 0, j = ring.length - 1; i < ring.length; j = i, i += 1) {
|
||||
const [xi, yi] = ring[i];
|
||||
const [xj, yj] = ring[j];
|
||||
const crosses =
|
||||
yi > lat !== yj > lat &&
|
||||
lon < ((xj - xi) * (lat - yi)) / (yj - yi || Number.EPSILON) + xi;
|
||||
if (crosses) inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
/**
|
||||
* How much of the box a candidate's granule footprints cover, sampled at the
|
||||
* four corners and the centre: `full`, `partial`, or `unknown` without
|
||||
* footprints.
|
||||
* @param {object} candidate
|
||||
* @param {object} box Degrees box.
|
||||
* @returns {'full' | 'partial' | 'unknown'}
|
||||
*/
|
||||
export function coverageFor(candidate, box) {
|
||||
const normalized = normalizeBox(box);
|
||||
const footprints = (candidate?.granules || [])
|
||||
.map((granule) => granule?.footprint)
|
||||
.filter((ring) => Array.isArray(ring) && ring.length >= 3);
|
||||
if (!normalized || !footprints.length) return 'unknown';
|
||||
const { west, south, east, north } = normalized;
|
||||
const samples = [
|
||||
[west, south],
|
||||
[east, south],
|
||||
[east, north],
|
||||
[west, north],
|
||||
[(west + east) / 2, (south + north) / 2],
|
||||
];
|
||||
return samples.every(([lon, lat]) =>
|
||||
footprints.some((ring) => pointInPolygon(lon, lat, ring)),
|
||||
)
|
||||
? 'full'
|
||||
: 'partial';
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the START HERE day. Coverage first: a present HLS day that covers the
|
||||
* whole box (`full`, or `unknown` without footprints) beats a newer sliver at
|
||||
* the box edge, which changes none of the frame when draped. Within a tier
|
||||
* the newest day whose granules ALL have known cloud ≤ `maxCloud` wins
|
||||
* (`clear`), else the newest (`cloudy`); only slivers left → `partial`; else
|
||||
* the newest non-empty VIIRS day (`overview`). `certain` is false when the
|
||||
* catalog was truncated.
|
||||
* @param {Array<object>} candidates
|
||||
* @param {{ maxCloud?: number, truncated?: boolean }} [options]
|
||||
* @returns {{ candidate: object | null, reason: 'clear' | 'cloudy' | 'partial' | 'overview' | null, certain: boolean }}
|
||||
*/
|
||||
export function rankLatest(
|
||||
candidates,
|
||||
{ maxCloud = MAX_CLOUD_FOR_CLEAR, truncated = false } = {},
|
||||
) {
|
||||
const sorted = mergeCandidates([candidates]);
|
||||
const certain = !truncated;
|
||||
const present = (c) => c.availability !== 'empty';
|
||||
const hlsDay = (c) =>
|
||||
PRODUCTS[c.product] &&
|
||||
!PRODUCTS[c.product].overview &&
|
||||
present(c) &&
|
||||
c.granules.length > 0;
|
||||
const coversBox = (c) => c.coverage !== 'partial';
|
||||
const clearSky = (c) =>
|
||||
c.granules.every((granule) => {
|
||||
const cloud = cloudOf(granule);
|
||||
return cloud !== null && cloud <= maxCloud;
|
||||
});
|
||||
const tiers = [
|
||||
[(c) => hlsDay(c) && coversBox(c) && clearSky(c), 'clear'],
|
||||
[(c) => hlsDay(c) && coversBox(c), 'cloudy'],
|
||||
[(c) => hlsDay(c) && clearSky(c), 'partial'],
|
||||
[hlsDay, 'partial'],
|
||||
[(c) => c.product === 'VIIRS' && present(c), 'overview'],
|
||||
];
|
||||
for (const [matches, reason] of tiers) {
|
||||
const candidate = sorted.find(matches);
|
||||
if (candidate) return { candidate, reason, certain };
|
||||
}
|
||||
return { candidate: null, reason: null, certain };
|
||||
}
|
||||
|
||||
function daysAgoLabel(day, now) {
|
||||
const today = utcDay(now);
|
||||
if (!today) return null;
|
||||
const delta = Math.round(
|
||||
(Date.parse(`${today}T00:00:00Z`) - Date.parse(`${day}T00:00:00Z`)) /
|
||||
DAY_MS,
|
||||
);
|
||||
if (delta <= 0) return 'today';
|
||||
return delta === 1 ? 'yesterday' : `${delta} days ago`;
|
||||
}
|
||||
|
||||
function cloudLabel(cloud) {
|
||||
if (!cloud) return 'cloud unknown';
|
||||
const min = Math.round(cloud.min);
|
||||
const max = Math.round(cloud.max);
|
||||
return min === max ? `${min}% scene cloud` : `${min}–${max}% scene cloud`;
|
||||
}
|
||||
|
||||
/**
|
||||
* One-line UTC readout, e.g.
|
||||
* `Sep 18, 2026 17:12Z · 3 days ago · Sentinel-2 via HLS · 30 m · 12% scene cloud`.
|
||||
* @param {object} candidate
|
||||
* @param {string | number | Date} [now]
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatCandidateReadout(candidate, now = new Date()) {
|
||||
const product = PRODUCTS[candidate?.product];
|
||||
if (!product || !candidate?.day) return '';
|
||||
const [y, m, d] = candidate.day.split('-').map(Number);
|
||||
let when = `${MONTHS[m - 1]} ${d}, ${y}`;
|
||||
if (candidate.timeRange?.start) {
|
||||
const start = hhmm(candidate.timeRange.start);
|
||||
const end = candidate.timeRange.end ? hhmm(candidate.timeRange.end) : start;
|
||||
when +=
|
||||
(candidate.granules?.length || 0) > 1 && end !== start
|
||||
? ` ${start}–${end}Z`
|
||||
: ` ${start}Z`;
|
||||
}
|
||||
const parts = [when];
|
||||
const ago = daysAgoLabel(candidate.day, now);
|
||||
if (ago) parts.push(ago);
|
||||
parts.push(product.sensor, `${product.resolutionM} m`);
|
||||
if (product.overview) parts.push('overview');
|
||||
parts.push(cloudLabel(candidate.cloud));
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
/**
|
||||
* GIBS WMTS REST template with `{s}`, `{z}`, `{y}`, `{x}` placeholders (y
|
||||
* precedes x in the GIBS path).
|
||||
* @param {string} product
|
||||
* @param {string} day `YYYY-MM-DD`
|
||||
* @returns {string}
|
||||
*/
|
||||
export function gibsTemplate(product, day) {
|
||||
const spec = PRODUCTS[product];
|
||||
if (!spec) throw new TypeError(`Unknown imagery product: ${product}`);
|
||||
return `https://gibs-{s}.earthdata.nasa.gov/wmts/epsg3857/best/${spec.gibsLayer}/default/${day}/GoogleMapsCompatible_Level${spec.maxLevel}/{z}/{y}/{x}.${spec.format}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Worldview Snapshots URL for a thumbnail or export. EPSG:4326 BBOX order is
|
||||
* lat,lon: `south,west,north,east`.
|
||||
* @param {{ product: string, day: string, box: object, width: number, height: number, format?: string }} request
|
||||
* @returns {string}
|
||||
*/
|
||||
export function wvsSnapshotUrl({
|
||||
product,
|
||||
day,
|
||||
box,
|
||||
width,
|
||||
height,
|
||||
format = 'image/png',
|
||||
}) {
|
||||
const spec = PRODUCTS[product];
|
||||
const normalized = normalizeBox(box);
|
||||
if (!spec) throw new TypeError(`Unknown imagery product: ${product}`);
|
||||
if (!normalized) throw new TypeError('A finite box is required');
|
||||
const bbox = [
|
||||
normalized.south,
|
||||
normalized.west,
|
||||
normalized.north,
|
||||
normalized.east,
|
||||
]
|
||||
.map((value) => String(Number(value.toFixed(6))))
|
||||
.join(',');
|
||||
return (
|
||||
`${WVS_ENDPOINT}?REQUEST=GetSnapshot&LAYERS=${spec.gibsLayer}` +
|
||||
`&CRS=EPSG:4326&TIME=${day}&BBOX=${bbox}` +
|
||||
`&WIDTH=${Math.round(width)}&HEIGHT=${Math.round(height)}&FORMAT=${format}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Thumbnail load order: the focused card (clamped into the visible range),
|
||||
* the visible cards outward from it, then `extra` cards beyond each edge.
|
||||
* @param {number} focusIndex
|
||||
* @param {number} firstVisible
|
||||
* @param {number} lastVisible
|
||||
* @param {number} total
|
||||
* @param {number} [extra]
|
||||
* @returns {number[]}
|
||||
*/
|
||||
export function thumbnailOrder(
|
||||
focusIndex,
|
||||
firstVisible,
|
||||
lastVisible,
|
||||
total,
|
||||
extra = 2,
|
||||
) {
|
||||
const count = Math.max(0, Math.trunc(total));
|
||||
if (!count) return [];
|
||||
const clamp = (value) => Math.max(0, Math.min(count - 1, Math.trunc(value)));
|
||||
const first = clamp(Math.min(firstVisible, lastVisible));
|
||||
const last = clamp(Math.max(firstVisible, lastVisible));
|
||||
const focus = Math.max(first, Math.min(last, clamp(focusIndex)));
|
||||
const order = [];
|
||||
const push = (index) => {
|
||||
if (index >= 0 && index < count && !order.includes(index))
|
||||
order.push(index);
|
||||
};
|
||||
push(focus);
|
||||
for (let step = 1; step <= last - first; step += 1) {
|
||||
if (focus + step <= last) push(focus + step);
|
||||
if (focus - step >= first) push(focus - step);
|
||||
}
|
||||
for (let step = 1; step <= Math.max(0, Math.trunc(extra)); step += 1) {
|
||||
push(last + step);
|
||||
push(first - step);
|
||||
}
|
||||
return order;
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
boxFromPin,
|
||||
boxFromRectangle,
|
||||
boxSideKm,
|
||||
coverageFor,
|
||||
boxCentre,
|
||||
dequantizeBox,
|
||||
fitViewHeightM,
|
||||
formatCandidateReadout,
|
||||
gibsTemplate,
|
||||
groupGranulesByDay,
|
||||
mergeCandidates,
|
||||
parseCandidateKey,
|
||||
quantizeBox,
|
||||
rankLatest,
|
||||
shortDay,
|
||||
thumbnailOrder,
|
||||
utcDay,
|
||||
validateBox,
|
||||
viewTooLargeMessage,
|
||||
viirsCandidates,
|
||||
wvsSnapshotUrl,
|
||||
} from './model.js';
|
||||
import { BOX } from './testDoubles.mjs';
|
||||
|
||||
function granule(overrides = {}) {
|
||||
return {
|
||||
id: 'g',
|
||||
product: 'S30',
|
||||
timeStart: '2026-09-18T17:12:00.000Z',
|
||||
timeEnd: '2026-09-18T17:12:30.000Z',
|
||||
cloud: 12,
|
||||
footprint: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function hls(product, day, clouds, extra = {}) {
|
||||
const granules = clouds.map((cloud, index) =>
|
||||
granule({
|
||||
id: `${product}-${day}-${index}`,
|
||||
product,
|
||||
timeStart: `${day}T17:1${index}:00.000Z`,
|
||||
timeEnd: `${day}T17:1${index}:30.000Z`,
|
||||
cloud,
|
||||
}),
|
||||
);
|
||||
return { ...groupGranulesByDay(granules)[0], ...extra };
|
||||
}
|
||||
|
||||
test('validateBox refuses dateline, polar, degenerate, invalid and oversized boxes', () => {
|
||||
const reason = (box) => validateBox(box).reason;
|
||||
assert.equal(
|
||||
reason({ west: 179, south: 0, east: -179, north: 1 }),
|
||||
'dateline',
|
||||
);
|
||||
assert.equal(
|
||||
reason({ west: 0, south: 84, east: 0.5, north: 85.05111 }),
|
||||
'polar',
|
||||
);
|
||||
assert.equal(
|
||||
reason({ west: 0, south: -85.0512, east: 0.5, north: -84 }),
|
||||
'polar',
|
||||
);
|
||||
// Refusals fit the one-line notice.
|
||||
assert.equal(
|
||||
validateBox({ west: 0, south: 84, east: 0.5, north: 85.05111 }).message,
|
||||
'Imagery stops at ±85.05° latitude',
|
||||
);
|
||||
assert.equal(
|
||||
validateBox({ west: 179, south: 0, east: -179, north: 1 }).message,
|
||||
'Select one side of the dateline',
|
||||
);
|
||||
assert.equal(
|
||||
validateBox({ west: 0, south: 84.9, east: 0.05, north: 85 }).ok,
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
reason({ west: 10, south: 10, east: 10, north: 11 }),
|
||||
'degenerate',
|
||||
);
|
||||
assert.equal(
|
||||
reason({ west: Infinity, south: 0, east: 1, north: 1 }),
|
||||
'invalid',
|
||||
);
|
||||
assert.equal(reason({ west: -181, south: 0, east: 1, north: 1 }), 'invalid');
|
||||
assert.equal(reason(undefined), 'invalid');
|
||||
// The cap is 1,000 km a side, and the refusal says how big the box is.
|
||||
const tall = validateBox({ west: 0, south: 0, east: 0.1, north: 10 });
|
||||
assert.equal(tall.reason, 'too-large');
|
||||
assert.equal(tall.message, 'Box is 1,113 km wide · limit 1,000 km');
|
||||
assert.equal(
|
||||
reason({ west: 0, south: 0, east: 10, north: 0.1 }),
|
||||
'too-large',
|
||||
);
|
||||
assert.equal(validateBox({ west: 0, south: 0, east: 8, north: 8 }).ok, true);
|
||||
assert.equal(
|
||||
viewTooLargeMessage(2400),
|
||||
'View is 2,400 km wide · limit 1,000 km',
|
||||
);
|
||||
// Latitudes are ordered; longitudes are never swapped.
|
||||
assert.deepEqual(
|
||||
validateBox({ west: -97.8, south: 30.3, east: -97.7, north: 30.2 }).box,
|
||||
BOX,
|
||||
);
|
||||
});
|
||||
|
||||
test('ZOOM IN: the height whose top-down view is 400 km wide, clamped to 5–400 km; a box centre', () => {
|
||||
const near = (actual, expected, label) =>
|
||||
assert.ok(Math.abs(actual - expected) < 1e-6, `${label}: ${actual}`);
|
||||
// A 1000 × 500 canvas with Cesium's 60° horizontal FOV: tan(fovy / 2) is
|
||||
// tan(30°) / 2, so the view is 2 · h · tan(30°) wide.
|
||||
const fovy = 2 * Math.atan(Math.tan(Math.PI / 6) / 2);
|
||||
const height = fitViewHeightM({ width: 1000, height: 500, fovy });
|
||||
near(height, 400_000 / (2 * Math.tan(Math.PI / 6)), 'wide canvas');
|
||||
near(
|
||||
2 * height * Math.tan(fovy / 2) * 2,
|
||||
400_000,
|
||||
'the view at that height is 400 km wide',
|
||||
);
|
||||
// A tall canvas would need more than 400 km; a very wide one less than 5.
|
||||
assert.equal(
|
||||
fitViewHeightM({ width: 200, height: 1000, fovy: Math.PI / 3 }),
|
||||
400_000,
|
||||
);
|
||||
assert.equal(fitViewHeightM({ width: 1000, height: 500, fovy: 3.1 }), 5_000);
|
||||
// No canvas or FOV: a square 60° view.
|
||||
near(
|
||||
fitViewHeightM({ width: 0, height: 0, fovy: undefined }),
|
||||
400_000 / (2 * Math.tan(Math.PI / 6)),
|
||||
'fallback',
|
||||
);
|
||||
assert.deepEqual(boxCentre({ west: 0, south: 0, east: 20, north: 10 }), {
|
||||
lon: 10,
|
||||
lat: 5,
|
||||
});
|
||||
assert.equal(boxCentre(null), null);
|
||||
});
|
||||
|
||||
test('box width is the longitude span at the mid-latitude, never the shortest arc', () => {
|
||||
const equator = boxSideKm({ west: 0, south: -0.5, east: 1, north: 0.5 });
|
||||
assert.ok(Math.abs(equator.width - 111.32) < 1e-6);
|
||||
assert.ok(Math.abs(equator.height - 111.32) < 1e-6);
|
||||
const north = boxSideKm({ west: 0, south: 59.5, east: 1, north: 60.5 });
|
||||
assert.ok(north.width > 55 && north.width < 56.5, String(north.width));
|
||||
const nearlyGlobal = { west: -179.9, south: 30, east: 179.9, north: 30.1 };
|
||||
assert.ok(boxSideKm(nearlyGlobal).width > 30000);
|
||||
assert.equal(validateBox(nearlyGlobal).reason, 'too-large');
|
||||
});
|
||||
|
||||
test('boxFromPin builds a 10 km square within 2% at any latitude and refuses what cannot validate', () => {
|
||||
for (const [lon, lat] of [
|
||||
[-97.75, 30.27],
|
||||
[18.07, 59.33],
|
||||
[0, 0],
|
||||
[-70, -45],
|
||||
]) {
|
||||
const box = boxFromPin(lon, lat);
|
||||
const { width, height } = boxSideKm(box);
|
||||
assert.ok(Math.abs(width - 10) < 0.2, `width ${width} at ${lat}`);
|
||||
assert.ok(Math.abs(height - 10) < 0.2, `height ${height} at ${lat}`);
|
||||
assert.ok(Math.abs((box.west + box.east) / 2 - lon) < 1e-9);
|
||||
}
|
||||
assert.ok(Math.abs(boxSideKm(boxFromPin(10, 10, 50)).width - 50) < 1);
|
||||
assert.equal(boxFromPin(NaN, 0), null);
|
||||
assert.equal(boxFromPin(0, 0, 5000), null, 'over the cap');
|
||||
assert.equal(boxFromPin(179.99, 0), null, 'would cross the dateline');
|
||||
assert.equal(boxFromPin(0, 89), null, 'clamped to a degenerate polar box');
|
||||
assert.equal(boxFromPin(0, 85.04).north, 85.0511, 'clamped, still valid');
|
||||
});
|
||||
|
||||
test('rectangles convert from radians and the share-link quantization round-trips', () => {
|
||||
const box = boxFromRectangle({
|
||||
west: -Math.PI / 2,
|
||||
south: 0,
|
||||
east: -Math.PI / 4,
|
||||
north: Math.PI / 6,
|
||||
});
|
||||
assert.ok(Math.abs(box.west + 90) < 1e-9 && Math.abs(box.north - 30) < 1e-9);
|
||||
assert.equal(boxFromRectangle(null), null);
|
||||
const ints = quantizeBox({
|
||||
west: -97.812345678,
|
||||
south: 30.2,
|
||||
east: -97.7,
|
||||
north: 30.3999999,
|
||||
});
|
||||
assert.deepEqual(ints, {
|
||||
west: -9781235,
|
||||
south: 3020000,
|
||||
east: -9770000,
|
||||
north: 3040000,
|
||||
});
|
||||
assert.deepEqual(quantizeBox(dequantizeBox(ints)), ints);
|
||||
assert.equal(quantizeBox(null), null);
|
||||
assert.equal(dequantizeBox({ west: 'x' }), null);
|
||||
});
|
||||
|
||||
test('days and candidate keys are real UTC calendar days', () => {
|
||||
assert.equal(utcDay('2026-09-18T20:00:00-05:00'), '2026-09-19');
|
||||
assert.equal(utcDay(new Date(Date.UTC(2026, 0, 1))), '2026-01-01');
|
||||
assert.equal(utcDay('2026-02-30'), null);
|
||||
assert.equal(utcDay('nope'), null);
|
||||
assert.deepEqual(parseCandidateKey('L30:2026-09-10'), {
|
||||
product: 'L30',
|
||||
day: '2026-09-10',
|
||||
});
|
||||
assert.equal(parseCandidateKey('X30:2026-09-10'), null);
|
||||
assert.equal(parseCandidateKey('S30:2026-02-30'), null);
|
||||
assert.equal(shortDay('2026-09-18'), 'Sep 18');
|
||||
assert.equal(shortDay('nope'), '');
|
||||
});
|
||||
|
||||
test('granules group into one candidate per product and UTC day, newest first', () => {
|
||||
const candidates = groupGranulesByDay([
|
||||
granule({ id: 'a', cloud: 12, timeEnd: '2026-09-18T17:12:40Z' }),
|
||||
granule({
|
||||
id: 'b',
|
||||
cloud: 18,
|
||||
timeStart: '2026-09-18T17:20:00Z',
|
||||
timeEnd: '2026-09-18T17:20:40Z',
|
||||
}),
|
||||
granule({
|
||||
id: 'c',
|
||||
cloud: null,
|
||||
product: 'L30',
|
||||
timeStart: '2026-09-18T16:00:00Z',
|
||||
timeEnd: null,
|
||||
}),
|
||||
granule({ id: 'd', cloud: 3, timeStart: '2026-09-10T17:00:00Z' }),
|
||||
granule({ id: 'e', timeStart: 'garbage' }),
|
||||
granule({ id: 'f', product: 'nope' }),
|
||||
]);
|
||||
assert.deepEqual(
|
||||
candidates.map((c) => c.key),
|
||||
['S30:2026-09-18', 'L30:2026-09-18', 'S30:2026-09-10'],
|
||||
);
|
||||
const [s18, l18] = candidates;
|
||||
assert.deepEqual(s18.cloud, { min: 12, max: 18 });
|
||||
assert.deepEqual(s18.timeRange, {
|
||||
start: '2026-09-18T17:12:00.000Z',
|
||||
end: '2026-09-18T17:20:40.000Z',
|
||||
});
|
||||
assert.equal(s18.availability, 'present');
|
||||
assert.equal(l18.cloud, null, 'missing cloud stays unknown');
|
||||
assert.equal(l18.timeRange.end, '2026-09-18T16:00:00.000Z');
|
||||
});
|
||||
|
||||
test('VIIRS lists the last 30 UTC days as unprobed full-coverage days', () => {
|
||||
const days = viirsCandidates('2026-03-01T13:00:00Z', 3);
|
||||
assert.deepEqual(
|
||||
days.map((c) => c.key),
|
||||
['VIIRS:2026-03-01', 'VIIRS:2026-02-28', 'VIIRS:2026-02-27'],
|
||||
);
|
||||
assert.equal(days[0].availability, 'unknown');
|
||||
assert.equal(days[0].coverage, 'full');
|
||||
assert.equal(viirsCandidates('2026-09-21').length, 30);
|
||||
assert.deepEqual(viirsCandidates('bad'), []);
|
||||
});
|
||||
|
||||
test('merging sorts newest day first, then S30, L30, VIIRS, and the first key wins', () => {
|
||||
const merged = mergeCandidates([
|
||||
viirsCandidates('2026-09-18', 2),
|
||||
[hls('L30', '2026-09-18', [5]), hls('S30', '2026-09-17', [5])],
|
||||
[hls('S30', '2026-09-18', [7]), hls('S30', '2026-09-18', [99])],
|
||||
]);
|
||||
assert.deepEqual(
|
||||
merged.map((c) => c.key),
|
||||
[
|
||||
'S30:2026-09-18',
|
||||
'L30:2026-09-18',
|
||||
'VIIRS:2026-09-18',
|
||||
'S30:2026-09-17',
|
||||
'VIIRS:2026-09-17',
|
||||
],
|
||||
);
|
||||
assert.deepEqual(merged[0].cloud, { min: 7, max: 7 });
|
||||
});
|
||||
|
||||
test('coverage samples the box corners and centre against the union of footprints', () => {
|
||||
const ring = (west, east) => [
|
||||
[west, 30],
|
||||
[east, 30],
|
||||
[east, 31],
|
||||
[west, 31],
|
||||
];
|
||||
const withRings = (...rings) => ({
|
||||
granules: rings.map((footprint) => granule({ footprint })),
|
||||
});
|
||||
assert.equal(coverageFor(withRings(ring(-98, -97)), BOX), 'full');
|
||||
assert.equal(coverageFor(withRings(ring(-98, -97.75)), BOX), 'partial');
|
||||
assert.equal(
|
||||
coverageFor(withRings(ring(-98, -97.75), ring(-97.75, -97)), BOX),
|
||||
'full',
|
||||
);
|
||||
assert.equal(coverageFor(withRings(null), BOX), 'unknown');
|
||||
});
|
||||
|
||||
test('START HERE is the newest all-clear HLS day; unknown cloud is never clear', () => {
|
||||
const candidates = [
|
||||
hls('S30', '2026-09-20', [10, null]),
|
||||
hls('S30', '2026-09-19', [5, 21]),
|
||||
hls('S30', '2026-09-18', [0, 20]),
|
||||
];
|
||||
assert.deepEqual(rankLatest([...candidates].reverse()), {
|
||||
candidate: candidates[2],
|
||||
reason: 'clear',
|
||||
certain: true,
|
||||
});
|
||||
assert.equal(rankLatest(candidates, { truncated: true }).certain, false);
|
||||
// Then the newest cloudy HLS day, then the newest non-empty VIIRS day.
|
||||
const cloudy = rankLatest([
|
||||
hls('L30', '2026-09-20', [45]),
|
||||
hls('S30', '2026-09-19', [null]),
|
||||
]);
|
||||
assert.equal(cloudy.candidate.key, 'L30:2026-09-20');
|
||||
assert.equal(cloudy.reason, 'cloudy');
|
||||
const viirs = viirsCandidates('2026-09-21', 3);
|
||||
viirs[0] = { ...viirs[0], availability: 'empty' };
|
||||
const empty = { ...hls('S30', '2026-09-21', [0]), availability: 'empty' };
|
||||
const overview = rankLatest([empty, ...viirs]);
|
||||
assert.equal(overview.candidate.key, 'VIIRS:2026-09-20');
|
||||
assert.equal(overview.reason, 'overview');
|
||||
assert.equal(rankLatest([]).candidate, null);
|
||||
});
|
||||
|
||||
test('START HERE prefers a day that covers the whole box over a newer sliver', () => {
|
||||
const full = (day, clouds) => hls('S30', day, clouds, { coverage: 'full' });
|
||||
const sliver = (day, clouds) =>
|
||||
hls('S30', day, clouds, { coverage: 'partial' });
|
||||
const pick = (list) => {
|
||||
const { candidate, reason } = rankLatest(list);
|
||||
return `${candidate?.day} ${reason}`;
|
||||
};
|
||||
assert.equal(
|
||||
pick([sliver('2026-09-20', [0]), full('2026-09-18', [10])]),
|
||||
'2026-09-18 clear',
|
||||
);
|
||||
assert.equal(
|
||||
pick([full('2026-09-20', [null]), sliver('2026-09-19', [0])]),
|
||||
'2026-09-20 cloudy',
|
||||
);
|
||||
// Unknown coverage (no footprints) counts as whole-box.
|
||||
assert.equal(
|
||||
pick([
|
||||
hls('S30', '2026-09-20', [5], { coverage: 'unknown' }),
|
||||
full('2026-09-18', [5]),
|
||||
]),
|
||||
'2026-09-20 clear',
|
||||
);
|
||||
// Only slivers: the newest clear one, then the newest at all.
|
||||
assert.equal(
|
||||
pick([sliver('2026-09-20', [50]), sliver('2026-09-19', [0])]),
|
||||
'2026-09-19 partial',
|
||||
);
|
||||
assert.equal(
|
||||
pick([sliver('2026-09-20', [50]), sliver('2026-09-19', [null])]),
|
||||
'2026-09-20 partial',
|
||||
);
|
||||
assert.equal(
|
||||
pick([...viirsCandidates('2026-09-21', 2), sliver('2026-09-19', [70])]),
|
||||
'2026-09-19 partial',
|
||||
'a sliver still beats the overview',
|
||||
);
|
||||
});
|
||||
|
||||
test('the readout is one UTC line with age, sensor, resolution and scene cloud', () => {
|
||||
const now = '2026-09-21T12:00:00Z';
|
||||
const readout = (granules) =>
|
||||
formatCandidateReadout(groupGranulesByDay(granules)[0], now);
|
||||
assert.equal(
|
||||
readout([granule({ timeStart: '2026-09-18T17:12:10Z' })]),
|
||||
'Sep 18, 2026 17:12Z · 3 days ago · Sentinel-2 via HLS · 30 m · 12% scene cloud',
|
||||
);
|
||||
assert.equal(
|
||||
readout([
|
||||
granule({ id: 'a' }),
|
||||
granule({
|
||||
id: 'b',
|
||||
cloud: 18.4,
|
||||
timeStart: '2026-09-18T17:20:00Z',
|
||||
timeEnd: '2026-09-18T17:20:30Z',
|
||||
}),
|
||||
]),
|
||||
'Sep 18, 2026 17:12–17:20Z · 3 days ago · Sentinel-2 via HLS · 30 m · 12–18% scene cloud',
|
||||
);
|
||||
assert.equal(
|
||||
readout([
|
||||
granule({
|
||||
product: 'L30',
|
||||
cloud: null,
|
||||
timeStart: '2026-09-20T16:05:00Z',
|
||||
timeEnd: null,
|
||||
}),
|
||||
]),
|
||||
'Sep 20, 2026 16:05Z · yesterday · Landsat 8/9 via HLS · 30 m · cloud unknown',
|
||||
);
|
||||
assert.equal(
|
||||
formatCandidateReadout(viirsCandidates('2026-09-21', 1)[0], now),
|
||||
'Sep 21, 2026 · today · VIIRS NOAA-21 · 250 m · overview · cloud unknown',
|
||||
);
|
||||
assert.equal(formatCandidateReadout(null), '');
|
||||
});
|
||||
|
||||
test('GIBS templates put y before x; Worldview snapshots take south,west,north,east', () => {
|
||||
assert.equal(
|
||||
gibsTemplate('S30', '2026-09-18'),
|
||||
'https://gibs-{s}.earthdata.nasa.gov/wmts/epsg3857/best/HLS_S30_Nadir_BRDF_Adjusted_Reflectance/default/2026-09-18/GoogleMapsCompatible_Level12/{z}/{y}/{x}.png',
|
||||
);
|
||||
assert.equal(
|
||||
gibsTemplate('VIIRS', '2026-09-21'),
|
||||
'https://gibs-{s}.earthdata.nasa.gov/wmts/epsg3857/best/VIIRS_NOAA21_CorrectedReflectance_TrueColor/default/2026-09-21/GoogleMapsCompatible_Level9/{z}/{y}/{x}.jpg',
|
||||
);
|
||||
assert.match(gibsTemplate('L30', '2026-09-10'), /HLS_L30_.*Level12.*\.png$/);
|
||||
assert.throws(() => gibsTemplate('MODIS', '2026-01-01'), /Unknown/);
|
||||
assert.equal(
|
||||
wvsSnapshotUrl({
|
||||
product: 'S30',
|
||||
day: '2026-09-18',
|
||||
box: BOX,
|
||||
width: 256,
|
||||
height: 256,
|
||||
}),
|
||||
'https://wvs.earthdata.nasa.gov/api/v1/snapshot?REQUEST=GetSnapshot&LAYERS=HLS_S30_Nadir_BRDF_Adjusted_Reflectance&CRS=EPSG:4326&TIME=2026-09-18&BBOX=30.2,-97.8,30.3,-97.7&WIDTH=256&HEIGHT=256&FORMAT=image/png',
|
||||
);
|
||||
assert.throws(
|
||||
() => wvsSnapshotUrl({ product: 'S30', day: 'x', box: null }),
|
||||
TypeError,
|
||||
);
|
||||
});
|
||||
|
||||
test('thumbnails load the focused card, then the visible cards outward, then the margins', () => {
|
||||
assert.deepEqual(thumbnailOrder(5, 3, 7, 20), [5, 6, 4, 7, 3, 8, 2, 9, 1]);
|
||||
assert.deepEqual(thumbnailOrder(19, 17, 19, 20), [19, 18, 17, 16, 15]);
|
||||
assert.deepEqual(thumbnailOrder(50, 7, 3, 10), [7, 6, 5, 4, 3, 8, 2, 9, 1]);
|
||||
assert.deepEqual(thumbnailOrder(0, 0, 0, 0), []);
|
||||
});
|
||||
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* Draped-imagery renderer for the Recent Imagery layer: at most two GIBS
|
||||
* imagery layers (slot `a`, left of a swipe; slot `b`, right) on whichever
|
||||
* host the map stack offers. Against the basemap slot `a` splits left and
|
||||
* slot `b` stays empty. Every layer is removed from the collection that owns it, so a
|
||||
* host switch or a replaced image never strands one. Tile requests pass a
|
||||
* small semaphore of our own; Cesium's global request limits stay untouched.
|
||||
*/
|
||||
import * as Cesium from 'cesium';
|
||||
import { PRODUCTS, gibsTemplate } from './model.js';
|
||||
|
||||
const SLOT_IDS = ['a', 'b'];
|
||||
const SPLIT_ENUM = { left: 'LEFT', right: 'RIGHT', none: 'NONE' };
|
||||
const NO_HOST = Object.freeze({ collection: null, kind: 'none' });
|
||||
|
||||
/** Render reason for a deferred or settled tile request. */
|
||||
export const TILE_RENDER_REASON = 'recent-imagery-tiles';
|
||||
/** Delay before the one frame that retries tiles Cesium itself deferred. */
|
||||
export const TILE_RETRY_DELAY_MS = 250;
|
||||
|
||||
function clampAlpha(value) {
|
||||
const alpha = Number(value);
|
||||
return Number.isFinite(alpha) ? Math.max(0, Math.min(1, alpha)) : 1;
|
||||
}
|
||||
|
||||
const boxKey = (box) =>
|
||||
[box.west, box.south, box.east, box.north].map(Number).join(',');
|
||||
|
||||
/**
|
||||
* Gate a provider's `requestImage` through the semaphore. Returning
|
||||
* `undefined` tells Cesium to retry on a later frame, but under
|
||||
* `requestRenderMode` there is no later frame unless something asks — and a
|
||||
* deferral must never ask for one itself: the retried tile would defer again
|
||||
* and ask again, keeping the scene rendering every frame. So a request that
|
||||
* finds our semaphore full asks for nothing (the request holding the slot
|
||||
* asks for one frame when it settles), and a request the provider itself
|
||||
* deferred goes through the one coalesced delayed retry.
|
||||
*/
|
||||
function admitTileRequests(provider, admission, requestRender, retry) {
|
||||
const original = provider.requestImage.bind(provider);
|
||||
provider.requestImage = (x, y, level, request) => {
|
||||
if (admission.inFlight >= admission.limit) return undefined;
|
||||
admission.inFlight += 1;
|
||||
const release = () => {
|
||||
admission.inFlight -= 1;
|
||||
};
|
||||
let promise;
|
||||
try {
|
||||
promise = original(x, y, level, request);
|
||||
} catch (error) {
|
||||
release();
|
||||
throw error;
|
||||
}
|
||||
if (!promise) {
|
||||
release();
|
||||
retry.schedule();
|
||||
return undefined;
|
||||
}
|
||||
return Promise.resolve(promise).finally(() => {
|
||||
release();
|
||||
requestRender(TILE_RENDER_REASON);
|
||||
});
|
||||
};
|
||||
return provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the renderer.
|
||||
* @param {{ cesium?: object, maxTileRequests?: number, requestRender?: (reason: string) => void, setTimeoutImpl?: Function, clearTimeoutImpl?: Function }} [options]
|
||||
*/
|
||||
export function createRecentImageryRenderer({
|
||||
cesium = Cesium,
|
||||
maxTileRequests = 6,
|
||||
requestRender = () => {},
|
||||
setTimeoutImpl = (fn, ms) => globalThis.setTimeout(fn, ms),
|
||||
clearTimeoutImpl = (id) => globalThis.clearTimeout(id),
|
||||
} = {}) {
|
||||
let host = NO_HOST;
|
||||
let destroyed = false;
|
||||
const admission = { limit: maxTileRequests, inFlight: 0 };
|
||||
/** @type {Record<'a'|'b', null | { layer: object, collection: object, candidate: object, box: object, alpha: number, split: string }>} */
|
||||
const owned = { a: null, b: null };
|
||||
|
||||
// One timer for both slots: any number of upstream deferrals while it is
|
||||
// armed share the same single retry frame.
|
||||
let retryTimer = null;
|
||||
const retry = {
|
||||
schedule() {
|
||||
if (retryTimer !== null) return;
|
||||
retryTimer = setTimeoutImpl(() => {
|
||||
retryTimer = null;
|
||||
if (!destroyed) requestRender(TILE_RENDER_REASON);
|
||||
}, TILE_RETRY_DELAY_MS);
|
||||
},
|
||||
cancel() {
|
||||
if (retryTimer !== null) clearTimeoutImpl(retryTimer);
|
||||
retryTimer = null;
|
||||
},
|
||||
};
|
||||
|
||||
function createProvider(candidate, box) {
|
||||
const spec = PRODUCTS[candidate.product];
|
||||
const provider = new cesium.UrlTemplateImageryProvider({
|
||||
url: gibsTemplate(candidate.product, candidate.day),
|
||||
subdomains: ['a', 'b', 'c'],
|
||||
minimumLevel: 0,
|
||||
maximumLevel: spec.maxLevel,
|
||||
tilingScheme: new cesium.WebMercatorTilingScheme(),
|
||||
rectangle: cesium.Rectangle.fromDegrees(
|
||||
box.west,
|
||||
box.south,
|
||||
box.east,
|
||||
box.north,
|
||||
),
|
||||
credit: 'NASA GIBS',
|
||||
hasAlphaChannel: spec.format === 'png',
|
||||
});
|
||||
return admitTileRequests(provider, admission, requestRender, retry);
|
||||
}
|
||||
|
||||
function removeOwned(slotId) {
|
||||
const record = owned[slotId];
|
||||
if (!record) return false;
|
||||
owned[slotId] = null;
|
||||
try {
|
||||
record.collection.remove(record.layer, true);
|
||||
} catch {
|
||||
/* the collection is already gone */
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function applyLook(record) {
|
||||
record.layer.alpha = record.alpha;
|
||||
record.layer.splitDirection =
|
||||
cesium.SplitDirection?.[SPLIT_ENUM[record.split] || 'NONE'] ?? 0;
|
||||
}
|
||||
|
||||
function mount(slotId, { candidate, box, alpha, split }) {
|
||||
const layer = host.collection.addImageryProvider(
|
||||
createProvider(candidate, box),
|
||||
);
|
||||
owned[slotId] = {
|
||||
layer,
|
||||
collection: host.collection,
|
||||
candidate,
|
||||
box,
|
||||
alpha,
|
||||
split,
|
||||
};
|
||||
applyLook(owned[slotId]);
|
||||
}
|
||||
|
||||
return {
|
||||
/**
|
||||
* Drape a candidate into a slot. The same candidate on the same host only
|
||||
* updates its look; anything else replaces the slot's layer.
|
||||
* @param {'a'|'b'} slotId
|
||||
* @param {object} candidate
|
||||
* @param {object} box Degrees box.
|
||||
* @param {{ alpha?: number, splitDirection?: 'left'|'right'|'none' }} [look]
|
||||
* @returns {boolean} Whether a layer is now draped for the slot.
|
||||
*/
|
||||
showSlot(
|
||||
slotId,
|
||||
candidate,
|
||||
box,
|
||||
{ alpha = 1, splitDirection = 'none' } = {},
|
||||
) {
|
||||
if (destroyed || !candidate?.key || !box) return false;
|
||||
if (!host.collection) {
|
||||
removeOwned(slotId);
|
||||
return false;
|
||||
}
|
||||
const current = owned[slotId];
|
||||
const look = { alpha: clampAlpha(alpha), split: splitDirection };
|
||||
if (
|
||||
current &&
|
||||
current.collection === host.collection &&
|
||||
current.candidate.key === candidate.key &&
|
||||
boxKey(current.box) === boxKey(box)
|
||||
) {
|
||||
if (current.alpha !== look.alpha || current.split !== look.split) {
|
||||
Object.assign(current, look);
|
||||
applyLook(current);
|
||||
requestRender('recent-imagery-look');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
removeOwned(slotId);
|
||||
mount(slotId, { candidate, box, ...look });
|
||||
requestRender('recent-imagery-show');
|
||||
return true;
|
||||
},
|
||||
|
||||
/** @param {'a'|'b'} slotId */
|
||||
hideSlot(slotId) {
|
||||
if (removeOwned(slotId)) requestRender('recent-imagery-hide');
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {'a'|'b'} slotId
|
||||
* @param {number} alpha 0–1
|
||||
*/
|
||||
setAlpha(slotId, alpha) {
|
||||
const record = owned[slotId];
|
||||
const next = clampAlpha(alpha);
|
||||
if (!record || record.alpha === next) return;
|
||||
record.alpha = next;
|
||||
applyLook(record);
|
||||
requestRender('recent-imagery-alpha');
|
||||
},
|
||||
|
||||
/**
|
||||
* Move every owned layer to a new host. Layers are rebuilt there, never
|
||||
* re-parented, so the old collection is left as it was found.
|
||||
* @param {{ collection: object | null, kind: string }} nextHost
|
||||
*/
|
||||
rebind(nextHost) {
|
||||
host = nextHost || NO_HOST;
|
||||
let changed = false;
|
||||
for (const slotId of SLOT_IDS) {
|
||||
const record = owned[slotId];
|
||||
if (!record || record.collection === host.collection) continue;
|
||||
removeOwned(slotId);
|
||||
changed = true;
|
||||
if (host.collection && !destroyed) mount(slotId, record);
|
||||
}
|
||||
if (changed) requestRender('recent-imagery-rebind');
|
||||
},
|
||||
|
||||
/** What is draped right now, per slot. */
|
||||
getOwned() {
|
||||
const out = {};
|
||||
for (const slotId of SLOT_IDS) {
|
||||
const record = owned[slotId];
|
||||
out[slotId] = record
|
||||
? {
|
||||
key: record.candidate.key,
|
||||
kind: host.kind,
|
||||
alpha: record.alpha,
|
||||
split: record.split,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
return out;
|
||||
},
|
||||
|
||||
/** Number of layers this renderer owns (never above two). */
|
||||
ownedCount() {
|
||||
return SLOT_IDS.filter((slotId) => owned[slotId]).length;
|
||||
},
|
||||
|
||||
/** Remove everything and refuse further work. */
|
||||
destroy() {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
retry.cancel();
|
||||
let changed = false;
|
||||
for (const slotId of SLOT_IDS) changed = removeOwned(slotId) || changed;
|
||||
host = NO_HOST;
|
||||
if (changed) requestRender('recent-imagery-destroy');
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
TILE_RENDER_REASON,
|
||||
TILE_RETRY_DELAY_MS,
|
||||
createRecentImageryRenderer,
|
||||
} from './rendering.js';
|
||||
import { BOX, manualTimers } from './testDoubles.mjs';
|
||||
|
||||
/** A provider whose tile requests stay pending until the test settles them. */
|
||||
class UrlTemplateImageryProvider {
|
||||
constructor(options) {
|
||||
this.options = options;
|
||||
this.calls = [];
|
||||
this.pending = [];
|
||||
}
|
||||
requestImage(x, y, level) {
|
||||
this.calls.push([x, y, level]);
|
||||
return new Promise((resolve) => this.pending.push(resolve));
|
||||
}
|
||||
}
|
||||
|
||||
function fakeCesium(requestImage) {
|
||||
class Provider extends UrlTemplateImageryProvider {}
|
||||
if (requestImage) Provider.prototype.requestImage = requestImage;
|
||||
return {
|
||||
UrlTemplateImageryProvider: Provider,
|
||||
WebMercatorTilingScheme: class {},
|
||||
Rectangle: {
|
||||
fromDegrees: (west, south, east, north) => ({ west, south, east, north }),
|
||||
},
|
||||
SplitDirection: { LEFT: -1, NONE: 0, RIGHT: 1 },
|
||||
};
|
||||
}
|
||||
|
||||
function fakeCollection() {
|
||||
const layers = [];
|
||||
return {
|
||||
layers,
|
||||
addImageryProvider(provider) {
|
||||
const layer = { provider, alpha: 1, splitDirection: 0 };
|
||||
layers.push(layer);
|
||||
return layer;
|
||||
},
|
||||
remove(layer, destroy) {
|
||||
layers.splice(layers.indexOf(layer), 1);
|
||||
layer.destroyed = destroy;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const S30 = { key: 'S30:2026-09-18', product: 'S30', day: '2026-09-18' };
|
||||
const L30 = { key: 'L30:2026-09-16', product: 'L30', day: '2026-09-16' };
|
||||
const VIIRS = { key: 'VIIRS:2026-09-20', product: 'VIIRS', day: '2026-09-20' };
|
||||
|
||||
function fixture({ requestImage, maxTileRequests } = {}) {
|
||||
const renders = [];
|
||||
const timers = manualTimers();
|
||||
const renderer = createRecentImageryRenderer({
|
||||
cesium: fakeCesium(requestImage),
|
||||
maxTileRequests,
|
||||
requestRender: (reason) => renders.push(reason),
|
||||
setTimeoutImpl: timers.setTimeoutImpl,
|
||||
clearTimeoutImpl: timers.clearTimeoutImpl,
|
||||
});
|
||||
const globe = fakeCollection();
|
||||
renderer.rebind({ collection: globe, kind: 'globe' });
|
||||
const tileFrames = () =>
|
||||
renders.filter((reason) => reason === TILE_RENDER_REASON).length;
|
||||
return { renderer, globe, renders, timers, tileFrames };
|
||||
}
|
||||
|
||||
test('a slot drapes one GIBS provider bounded to the box; the same day only restyles it', () => {
|
||||
const { renderer, globe, renders } = fixture();
|
||||
assert.equal(renderer.showSlot('a', S30, BOX, { alpha: 0.8 }), true);
|
||||
const [layer] = globe.layers;
|
||||
assert.match(
|
||||
layer.provider.options.url,
|
||||
/^https:\/\/gibs-\{s\}\.earthdata\.nasa\.gov\/.*HLS_S30.*\/2026-09-18\/GoogleMapsCompatible_Level12\/\{z\}\/\{y\}\/\{x\}\.png$/,
|
||||
);
|
||||
assert.deepEqual(layer.provider.options.subdomains, ['a', 'b', 'c']);
|
||||
assert.equal(layer.provider.options.credit, 'NASA GIBS');
|
||||
assert.deepEqual(layer.provider.options.rectangle, BOX);
|
||||
assert.equal(layer.alpha, 0.8);
|
||||
assert.deepEqual(renders, ['recent-imagery-show']);
|
||||
assert.deepEqual(renderer.getOwned().a, {
|
||||
key: 'S30:2026-09-18',
|
||||
kind: 'globe',
|
||||
alpha: 0.8,
|
||||
split: 'none',
|
||||
});
|
||||
renderer.showSlot('a', S30, BOX, { alpha: 0.5, splitDirection: 'left' });
|
||||
assert.deepEqual(globe.layers, [layer], 'the layer is reused');
|
||||
assert.equal(layer.splitDirection, -1);
|
||||
// A different day replaces the layer in its own collection.
|
||||
renderer.showSlot('a', L30, BOX);
|
||||
assert.equal(layer.destroyed, true);
|
||||
assert.equal(globe.layers.length, 1);
|
||||
// VIIRS is a level-9 JPEG overview with no alpha channel.
|
||||
renderer.showSlot('b', VIIRS, BOX, { splitDirection: 'right' });
|
||||
assert.equal(globe.layers[1].provider.options.maximumLevel, 9);
|
||||
assert.equal(globe.layers[1].provider.options.hasAlphaChannel, false);
|
||||
assert.equal(globe.layers[1].splitDirection, 1);
|
||||
renderer.setAlpha('b', 7);
|
||||
assert.equal(globe.layers[1].alpha, 1, 'alpha clamps to 0–1');
|
||||
});
|
||||
|
||||
test('rapid replacement never owns more than two layers', () => {
|
||||
const { renderer, globe } = fixture();
|
||||
const days = [S30, L30, VIIRS];
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
renderer.showSlot('a', days[i % 3], BOX);
|
||||
renderer.showSlot('b', days[(i + 1) % 3], BOX);
|
||||
if (i % 4 === 0) renderer.hideSlot('a');
|
||||
assert.ok(globe.layers.length <= 2 && renderer.ownedCount() <= 2);
|
||||
}
|
||||
renderer.hideSlot('a');
|
||||
renderer.hideSlot('b');
|
||||
assert.equal(globe.layers.length, 0);
|
||||
});
|
||||
|
||||
test('rebind rebuilds the owned layers on the new host and leaves the old one empty', () => {
|
||||
const { renderer, globe } = fixture();
|
||||
renderer.showSlot('a', S30, BOX, { alpha: 0.6, splitDirection: 'left' });
|
||||
renderer.showSlot('b', L30, BOX, { splitDirection: 'right' });
|
||||
const tileset = fakeCollection();
|
||||
renderer.rebind({ collection: tileset, kind: 'tileset' });
|
||||
assert.equal(globe.layers.length, 0);
|
||||
assert.deepEqual(
|
||||
tileset.layers.map((layer) => [layer.alpha, layer.splitDirection]),
|
||||
[
|
||||
[0.6, -1],
|
||||
[1, 1],
|
||||
],
|
||||
);
|
||||
assert.equal(renderer.getOwned().a.kind, 'tileset');
|
||||
renderer.rebind({ collection: null, kind: 'none' });
|
||||
assert.equal(tileset.layers.length, 0);
|
||||
assert.equal(renderer.showSlot('a', S30, BOX), false);
|
||||
renderer.rebind({ collection: globe, kind: 'globe' });
|
||||
renderer.showSlot('a', S30, BOX);
|
||||
renderer.destroy();
|
||||
assert.equal(globe.layers.length, 0);
|
||||
assert.equal(renderer.showSlot('a', S30, BOX), false, 'destroyed');
|
||||
});
|
||||
|
||||
test('above the tile limit a request defers silently; its settlement asks for exactly one frame', async () => {
|
||||
const { renderer, globe, timers, tileFrames } = fixture({
|
||||
maxTileRequests: 1,
|
||||
});
|
||||
renderer.showSlot('a', S30, BOX);
|
||||
const { provider } = globe.layers[0];
|
||||
const first = provider.requestImage(0, 0, 1);
|
||||
assert.ok(first instanceof Promise);
|
||||
for (let i = 1; i <= 120; i += 1)
|
||||
assert.equal(provider.requestImage(i, 0, 1), undefined);
|
||||
assert.equal(provider.calls.length, 1, 'deferred requests never go out');
|
||||
assert.equal(tileFrames(), 0, 'a deferral asks for no frame');
|
||||
assert.equal(timers.armed(), 0);
|
||||
provider.pending[0]('tile');
|
||||
await first;
|
||||
assert.equal(tileFrames(), 1, 'the settlement asks for the retry frame');
|
||||
assert.ok(provider.requestImage(200, 0, 1) instanceof Promise);
|
||||
});
|
||||
|
||||
test('upstream deferrals from both slots share one delayed retry frame; destroy disarms it', () => {
|
||||
let throwNext = false;
|
||||
const { renderer, globe, timers, tileFrames } = fixture({
|
||||
maxTileRequests: 1,
|
||||
requestImage() {
|
||||
if (throwNext) throw new Error('boom');
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
renderer.showSlot('a', S30, BOX);
|
||||
renderer.showSlot('b', L30, BOX);
|
||||
for (const { provider } of globe.layers)
|
||||
for (let i = 0; i < 40; i += 1)
|
||||
assert.equal(provider.requestImage(i, 0, 0), undefined);
|
||||
// Every deferral released its slot (the limit is one), and none asked
|
||||
// for a frame of its own: one timer holds the single retry.
|
||||
assert.equal(tileFrames(), 0);
|
||||
assert.equal(timers.armed(), 1);
|
||||
assert.equal([...timers.pending.values()][0].ms, TILE_RETRY_DELAY_MS);
|
||||
timers.flush();
|
||||
assert.equal(tileFrames(), 1);
|
||||
// A throwing provider releases its slot too.
|
||||
throwNext = true;
|
||||
assert.throws(() => globe.layers[0].provider.requestImage(0, 0, 0), /boom/);
|
||||
throwNext = false;
|
||||
globe.layers[0].provider.requestImage(0, 0, 0);
|
||||
assert.equal(timers.armed(), 1, 'the next deferral arms a fresh timer');
|
||||
renderer.destroy();
|
||||
assert.equal(timers.armed(), 0, 'destroy disarms the retry');
|
||||
assert.equal(tileFrames(), 1);
|
||||
});
|
||||
|
||||
test('against the basemap slot a splits left with no second layer, and leaving the swipe only restyles it', () => {
|
||||
const { renderer, globe, renders } = fixture();
|
||||
renderer.showSlot('a', S30, BOX, { splitDirection: 'left' });
|
||||
renderer.hideSlot('b');
|
||||
assert.equal(globe.layers.length, 1, 'the basemap is the right side');
|
||||
assert.equal(globe.layers[0].splitDirection, -1);
|
||||
assert.equal(renderer.getOwned().b, null);
|
||||
const [layer] = globe.layers;
|
||||
renderer.showSlot('a', S30, BOX, { splitDirection: 'none' });
|
||||
assert.deepEqual(globe.layers, [layer], 'no rebuild for a new look');
|
||||
assert.equal(layer.splitDirection, 0);
|
||||
assert.deepEqual(renders, ['recent-imagery-show', 'recent-imagery-look']);
|
||||
});
|
||||
@@ -0,0 +1,368 @@
|
||||
/**
|
||||
* Test doubles shared by the Recent Imagery tests: manual timers, fetch
|
||||
* responses, and the Cesium/viewer/document fakes the box tool needs.
|
||||
*/
|
||||
import { thumbnailOrder } from './model.js';
|
||||
|
||||
export const BOX = Object.freeze({
|
||||
west: -97.8,
|
||||
south: 30.2,
|
||||
east: -97.7,
|
||||
north: 30.3,
|
||||
});
|
||||
|
||||
export const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
/** Timers that fire only when the test says so. */
|
||||
export function manualTimers() {
|
||||
const pending = new Map();
|
||||
const cleared = [];
|
||||
let nextId = 1;
|
||||
return {
|
||||
pending,
|
||||
cleared,
|
||||
setTimeoutImpl(fn, ms) {
|
||||
const id = nextId++;
|
||||
pending.set(id, { fn, ms });
|
||||
return id;
|
||||
},
|
||||
clearTimeoutImpl(id) {
|
||||
cleared.push(id);
|
||||
pending.delete(id);
|
||||
},
|
||||
/** Fire every armed timer, in order. */
|
||||
flush() {
|
||||
const entries = [...pending.values()];
|
||||
pending.clear();
|
||||
for (const entry of entries) entry.fn();
|
||||
},
|
||||
armed: () => pending.size,
|
||||
};
|
||||
}
|
||||
|
||||
/** A `fetch` response with case-insensitive headers. */
|
||||
export function response({
|
||||
ok = true,
|
||||
status = ok ? 200 : 500,
|
||||
json = {},
|
||||
headers = {},
|
||||
blob = { size: 1 },
|
||||
} = {}) {
|
||||
const lower = new Map(
|
||||
Object.entries(headers).map(([name, value]) => [name.toLowerCase(), value]),
|
||||
);
|
||||
return {
|
||||
ok,
|
||||
status,
|
||||
headers: { get: (name) => lower.get(name.toLowerCase()) ?? null },
|
||||
json: async () => json,
|
||||
blob: async () => blob,
|
||||
};
|
||||
}
|
||||
|
||||
const EVENT_TYPES = {
|
||||
LEFT_DOWN: 'down',
|
||||
MOUSE_MOVE: 'move',
|
||||
LEFT_UP: 'up',
|
||||
LEFT_CLICK: 'click',
|
||||
LEFT_DOUBLE_CLICK: 'dblclick',
|
||||
};
|
||||
|
||||
/**
|
||||
* Cesium, viewer and document fakes for `initImageryBoxTool`. The canvas is
|
||||
* 1000 × 500 px mapped linearly onto 0.2° near Austin; y < 40 px is sky.
|
||||
*/
|
||||
export function boxToolFakes() {
|
||||
const handlers = [];
|
||||
class ScreenSpaceEventHandler {
|
||||
constructor(canvas) {
|
||||
this.canvas = canvas;
|
||||
this.actions = new Map();
|
||||
this.destroyed = false;
|
||||
handlers.push(this);
|
||||
}
|
||||
setInputAction(fn, type) {
|
||||
this.actions.set(type, fn);
|
||||
}
|
||||
getInputAction(type) {
|
||||
return this.actions.get(type);
|
||||
}
|
||||
removeInputAction(type) {
|
||||
this.actions.delete(type);
|
||||
}
|
||||
destroy() {
|
||||
this.destroyed = true;
|
||||
}
|
||||
}
|
||||
class CustomDataSource {
|
||||
constructor(name) {
|
||||
this.name = name;
|
||||
const values = [];
|
||||
this.entities = {
|
||||
values,
|
||||
add: (entity) => (values.push(entity), entity),
|
||||
removeAll: () => values.splice(0),
|
||||
};
|
||||
}
|
||||
}
|
||||
const color = { withAlpha: (alpha) => ({ alpha }) };
|
||||
const cesium = {
|
||||
ScreenSpaceEventHandler,
|
||||
ScreenSpaceEventType: EVENT_TYPES,
|
||||
CustomDataSource,
|
||||
CallbackProperty: class {},
|
||||
PolylineDashMaterialProperty: class {},
|
||||
ClassificationType: { BOTH: 'both' },
|
||||
Color: { fromCssColorString: () => color },
|
||||
Rectangle: { fromDegrees: () => ({}) },
|
||||
Cartesian3: { fromDegreesArray: (values) => values },
|
||||
};
|
||||
|
||||
const stock = new ScreenSpaceEventHandler('stock');
|
||||
const originalClick = () => 'select';
|
||||
const originalDouble = () => 'track';
|
||||
stock.setInputAction(originalClick, EVENT_TYPES.LEFT_CLICK);
|
||||
stock.setInputAction(originalDouble, EVENT_TYPES.LEFT_DOUBLE_CLICK);
|
||||
const sources = [];
|
||||
const viewer = {
|
||||
renders: 0,
|
||||
scene: {
|
||||
canvas: { clientWidth: 1000, clientHeight: 500 },
|
||||
screenSpaceCameraController: { enableInputs: true },
|
||||
requestRender: () => {
|
||||
viewer.renders += 1;
|
||||
},
|
||||
},
|
||||
screenSpaceEventHandler: stock,
|
||||
dataSources: {
|
||||
sources,
|
||||
add: (source) => (sources.push(source), Promise.resolve(source)),
|
||||
remove: (source) => sources.splice(sources.indexOf(source), 1),
|
||||
},
|
||||
};
|
||||
|
||||
const listeners = [];
|
||||
const classes = new Set();
|
||||
const documentRef = {
|
||||
listeners,
|
||||
classes,
|
||||
addEventListener: (type, fn) => listeners.push([type, fn]),
|
||||
removeEventListener: (type, fn) => {
|
||||
const index = listeners.findIndex((entry) => entry[1] === fn);
|
||||
if (index >= 0) listeners.splice(index, 1);
|
||||
},
|
||||
body: {
|
||||
classList: {
|
||||
add: (name) => classes.add(name),
|
||||
remove: (name) => classes.delete(name),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const pickWorld = (_viewer, nx, ny) =>
|
||||
ny * 500 < 40 ? null : { lon: -97.9 + nx * 0.2, lat: 30.4 - ny * 0.2 };
|
||||
/** The live (non-stock) canvas handler, if the tool has one bound. */
|
||||
const handler = () =>
|
||||
handlers.find((h) => h.canvas !== 'stock' && !h.destroyed) || null;
|
||||
const fire = (type, event) =>
|
||||
handler()?.actions.get(EVENT_TYPES[type])?.(event);
|
||||
/** Dispatch a keydown to the tool's document listener. */
|
||||
const key = (name) => {
|
||||
const event = {
|
||||
key: name,
|
||||
prevented: false,
|
||||
stopped: false,
|
||||
preventDefault() {
|
||||
this.prevented = true;
|
||||
},
|
||||
stopImmediatePropagation() {
|
||||
this.stopped = true;
|
||||
},
|
||||
};
|
||||
listeners.find(([type]) => type === 'keydown')?.[1](event);
|
||||
return event;
|
||||
};
|
||||
return {
|
||||
cesium,
|
||||
viewer,
|
||||
documentRef,
|
||||
pickWorld,
|
||||
handler,
|
||||
fire,
|
||||
key,
|
||||
types: EVENT_TYPES,
|
||||
originalClick,
|
||||
originalDouble,
|
||||
};
|
||||
}
|
||||
|
||||
// ---- the layer's injected parts --------------------------------------
|
||||
|
||||
export function candidate(product, day, cloud = 5) {
|
||||
const granules =
|
||||
product === 'VIIRS'
|
||||
? []
|
||||
: [
|
||||
{
|
||||
id: `${product}-${day}`,
|
||||
product,
|
||||
timeStart: `${day}T17:12:00Z`,
|
||||
timeEnd: `${day}T17:14:00Z`,
|
||||
cloud,
|
||||
},
|
||||
];
|
||||
return {
|
||||
key: `${product}:${day}`,
|
||||
product,
|
||||
day,
|
||||
granules,
|
||||
cloud: granules.length ? { min: cloud, max: cloud } : null,
|
||||
timeRange: granules.length
|
||||
? { start: granules[0].timeStart, end: granules[0].timeEnd }
|
||||
: null,
|
||||
availability: granules.length ? 'present' : 'unknown',
|
||||
coverage: 'full',
|
||||
};
|
||||
}
|
||||
|
||||
// Strip order, newest first: V21 (unprobed), S18 (clear), L16 (cloudy), V15.
|
||||
export const CANDIDATES = Object.freeze([
|
||||
candidate('VIIRS', '2026-09-21'),
|
||||
candidate('S30', '2026-09-18', 12),
|
||||
candidate('L30', '2026-09-16', 60),
|
||||
candidate('VIIRS', '2026-09-15'),
|
||||
]);
|
||||
|
||||
export function fakeRenderer() {
|
||||
const calls = [];
|
||||
const owned = { a: null, b: null };
|
||||
let host = { kind: 'none' };
|
||||
let peak = 0;
|
||||
return {
|
||||
calls,
|
||||
rebind(next) {
|
||||
calls.push(['rebind', next.kind]);
|
||||
host = next;
|
||||
for (const slot of ['a', 'b'])
|
||||
if (owned[slot]) owned[slot] = { ...owned[slot], kind: next.kind };
|
||||
},
|
||||
showSlot(slot, cand, box, look) {
|
||||
if (owned[slot]?.key !== cand.key) calls.push(['show', slot, cand.key]);
|
||||
owned[slot] = { key: cand.key, kind: host.kind, ...look };
|
||||
peak = Math.max(peak, this.ownedCount());
|
||||
return true;
|
||||
},
|
||||
hideSlot(slot) {
|
||||
owned[slot] = null;
|
||||
},
|
||||
setAlpha(slot, alpha) {
|
||||
calls.push(['alpha', slot, alpha]);
|
||||
},
|
||||
getOwned: () => ({ ...owned }),
|
||||
ownedCount: () => ['a', 'b'].filter((slot) => owned[slot]).length,
|
||||
peak: () => peak,
|
||||
destroy() {
|
||||
calls.push(['destroy']);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function fakeThumbnails() {
|
||||
const calls = [];
|
||||
const listeners = new Set();
|
||||
const statuses = new Map();
|
||||
/** Every key the layer asked to probe, in the order it asked. */
|
||||
const requested = [];
|
||||
/** Direct `request` calls: `[key, priority]`. */
|
||||
const requests = [];
|
||||
const ask = (key) => {
|
||||
if (!requested.includes(key)) requested.push(key);
|
||||
};
|
||||
return {
|
||||
calls,
|
||||
requested,
|
||||
requests,
|
||||
requestOrdered(candidates, box, window) {
|
||||
calls.push(['ordered', candidates.map((c) => c.key), window]);
|
||||
const { focusIndex, firstVisible, lastVisible, extra } = window;
|
||||
thumbnailOrder(
|
||||
focusIndex,
|
||||
firstVisible,
|
||||
lastVisible,
|
||||
candidates.length,
|
||||
extra,
|
||||
).forEach((index) => ask(candidates[index].key));
|
||||
},
|
||||
request(candidate, box, priority) {
|
||||
ask(candidate.key);
|
||||
requests.push([candidate.key, priority]);
|
||||
},
|
||||
/** Settle every requested key still unknown, as the loader would. */
|
||||
probeRequested(status = 'present') {
|
||||
for (const key of requested)
|
||||
if (!statuses.has(key)) this.probe(key, status);
|
||||
},
|
||||
clear() {
|
||||
calls.push(['clear']);
|
||||
},
|
||||
get: (key) => ({ status: statuses.get(key) || 'unknown' }),
|
||||
/** Record a probe result and tell the layer, as the loader does. */
|
||||
probe(key, status) {
|
||||
statuses.set(key, status);
|
||||
for (const listener of listeners) listener(key);
|
||||
},
|
||||
/** Record a probe result without telling anyone yet. */
|
||||
setStatus: (key, status) => statuses.set(key, status),
|
||||
subscribe(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
destroy() {
|
||||
calls.push(['destroy']);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function fakeCatalog() {
|
||||
const searches = [];
|
||||
return {
|
||||
searches,
|
||||
searchHls(request) {
|
||||
return new Promise((resolve, reject) => {
|
||||
request.signal?.addEventListener('abort', () =>
|
||||
reject(new Error('aborted')),
|
||||
);
|
||||
searches.push({ request, resolve, reject });
|
||||
});
|
||||
},
|
||||
resolveLast(candidates = CANDIDATES) {
|
||||
searches.at(-1).resolve({ candidates, truncated: false, errors: [] });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A map controller whose lease settles when the test says so. */
|
||||
export function fakeController({ refuse = false } = {}) {
|
||||
const calls = [];
|
||||
const controller = {
|
||||
calls,
|
||||
acquireImageryComparison(options) {
|
||||
calls.push(['acquire', options.owner, options.switchPolicy]);
|
||||
if (refuse) throw new Error('held by bhote');
|
||||
let settle;
|
||||
const ready = new Promise((resolve) => {
|
||||
settle = resolve;
|
||||
});
|
||||
controller.lease = {
|
||||
ready,
|
||||
release: () => {
|
||||
calls.push(['release']);
|
||||
return Promise.resolve();
|
||||
},
|
||||
settle: (result = { status: 'ready' }) => settle(result),
|
||||
};
|
||||
return controller.lease;
|
||||
},
|
||||
};
|
||||
return controller;
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* Thumbnail loader for the Recent Imagery strip: one Worldview snapshot per
|
||||
* candidate day. The response's `Data-Present` header is the only proof that
|
||||
* a day has pixels in the box (GIBS answers an empty tile with HTTP 200), and
|
||||
* `Acquisition-Time` carries the sensing time. Fetches are bounded, and so
|
||||
* are decoded images: residency is least-recently-USED, where a request or a
|
||||
* completed decode is a use and a `get` read never is (the panel reads every
|
||||
* card on every render, so a read that counted would evict the visible cards
|
||||
* first). Eviction revokes the image only; what the probe proved is kept.
|
||||
*/
|
||||
import { thumbnailOrder, wvsSnapshotUrl } from './model.js';
|
||||
|
||||
const EMPTY = Object.freeze({
|
||||
status: 'unknown',
|
||||
objectUrl: null,
|
||||
acquisitionTime: null,
|
||||
loading: false,
|
||||
});
|
||||
|
||||
/**
|
||||
* @param {{ fetchImpl?: typeof fetch, maxInFlight?: number, maxDecoded?: number, createObjectUrl?: (blob: Blob) => string, revokeObjectUrl?: (url: string) => void, size?: number }} [options]
|
||||
*/
|
||||
export function createThumbnailLoader({
|
||||
fetchImpl = globalThis.fetch?.bind(globalThis),
|
||||
maxInFlight = 3,
|
||||
maxDecoded = 16,
|
||||
createObjectUrl = (blob) => URL.createObjectURL(blob),
|
||||
revokeObjectUrl = (url) => URL.revokeObjectURL(url),
|
||||
size = 256,
|
||||
} = {}) {
|
||||
const limit = Math.max(1, Math.trunc(maxInFlight) || 1);
|
||||
const resident = Math.max(1, Math.trunc(maxDecoded) || 1);
|
||||
const entries = new Map();
|
||||
const listeners = new Set();
|
||||
const queue = [];
|
||||
let inFlight = 0;
|
||||
let touchCounter = 0;
|
||||
let destroyed = false;
|
||||
|
||||
function notify(key) {
|
||||
for (const listener of [...listeners]) {
|
||||
try {
|
||||
listener(key);
|
||||
} catch (error) {
|
||||
console.warn('[Data:RecentImagery] thumbnail listener failed:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function touch(entry) {
|
||||
touchCounter += 1;
|
||||
entry.touched = touchCounter;
|
||||
}
|
||||
|
||||
function revokeImage(entry) {
|
||||
if (!entry.objectUrl) return;
|
||||
try {
|
||||
revokeObjectUrl(entry.objectUrl);
|
||||
} catch {
|
||||
/* a revoked URL is already gone */
|
||||
}
|
||||
entry.objectUrl = null;
|
||||
}
|
||||
|
||||
function evictDecoded(keep) {
|
||||
const decoded = [...entries.values()]
|
||||
.filter((entry) => entry.objectUrl && entry !== keep)
|
||||
.sort((a, b) => a.touched - b.touched);
|
||||
let count = decoded.length + (keep?.objectUrl ? 1 : 0);
|
||||
for (const entry of decoded) {
|
||||
if (count <= resident) break;
|
||||
revokeImage(entry);
|
||||
notify(entry.key);
|
||||
count -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
function enqueue(entry) {
|
||||
if (!queue.includes(entry)) queue.push(entry);
|
||||
pump();
|
||||
}
|
||||
|
||||
async function run(entry) {
|
||||
const controller = new AbortController();
|
||||
entry.controller = controller;
|
||||
entry.loading = true;
|
||||
inFlight += 1;
|
||||
let status = 'error';
|
||||
let objectUrl = null;
|
||||
let acquisitionTime = null;
|
||||
let cancelled = false;
|
||||
try {
|
||||
const url = wvsSnapshotUrl({
|
||||
product: entry.candidate.product,
|
||||
day: entry.candidate.day,
|
||||
box: entry.box,
|
||||
width: size,
|
||||
height: size,
|
||||
});
|
||||
const response = await fetchImpl(url, { signal: controller.signal });
|
||||
if (controller.signal.aborted) {
|
||||
cancelled = true;
|
||||
} else if (response?.ok) {
|
||||
const present = String(
|
||||
response.headers?.get?.('Data-Present') ?? 'true',
|
||||
).toLowerCase();
|
||||
acquisitionTime = response.headers?.get?.('Acquisition-Time') || null;
|
||||
if (present === 'false') {
|
||||
status = 'empty';
|
||||
} else {
|
||||
const blob = await response.blob();
|
||||
if (controller.signal.aborted) cancelled = true;
|
||||
else {
|
||||
objectUrl = createObjectUrl(blob);
|
||||
status = 'present';
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted || error?.name === 'AbortError')
|
||||
cancelled = true;
|
||||
else status = 'error';
|
||||
} finally {
|
||||
inFlight -= 1;
|
||||
entry.loading = false;
|
||||
entry.controller = null;
|
||||
}
|
||||
// Every settlement frees a slot, so a cancelled fetch pumps too.
|
||||
if (cancelled || destroyed || entries.get(entry.key) !== entry) {
|
||||
if (objectUrl) revokeObjectUrl(objectUrl);
|
||||
pump();
|
||||
return;
|
||||
}
|
||||
// A failed re-fetch of an evicted image keeps what the day proved.
|
||||
if (status !== 'error' || entry.status !== 'present') {
|
||||
entry.status = status;
|
||||
entry.acquisitionTime = acquisitionTime;
|
||||
}
|
||||
entry.objectUrl = objectUrl;
|
||||
touch(entry);
|
||||
if (objectUrl) evictDecoded(entry);
|
||||
notify(entry.key);
|
||||
pump();
|
||||
}
|
||||
|
||||
function pump() {
|
||||
if (destroyed) return;
|
||||
queue.sort((a, b) => a.priority - b.priority);
|
||||
while (inFlight < limit && queue.length) {
|
||||
const entry = queue.shift();
|
||||
if (entries.get(entry.key) !== entry) continue;
|
||||
void run(entry);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
/**
|
||||
* Ask for one candidate's thumbnail. Lower priority loads sooner. A day
|
||||
* already known is only touched (and re-prioritised while it waits); a
|
||||
* known-present day whose image was evicted is fetched again.
|
||||
* @param {object} candidate
|
||||
* @param {object} box Degrees box.
|
||||
* @param {number} [priority]
|
||||
*/
|
||||
request(candidate, box, priority = 0) {
|
||||
if (destroyed || !candidate?.key || !box) return;
|
||||
const key = candidate.key;
|
||||
const existing = entries.get(key);
|
||||
if (existing) {
|
||||
touch(existing);
|
||||
if (existing.loading) return;
|
||||
if (existing.status === 'unknown') {
|
||||
existing.priority = priority;
|
||||
} else if (existing.status === 'present' && !existing.objectUrl) {
|
||||
existing.priority = priority;
|
||||
enqueue(existing);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const entry = {
|
||||
key,
|
||||
candidate,
|
||||
box,
|
||||
priority,
|
||||
status: 'unknown',
|
||||
objectUrl: null,
|
||||
acquisitionTime: null,
|
||||
loading: false,
|
||||
controller: null,
|
||||
touched: 0,
|
||||
};
|
||||
touch(entry);
|
||||
entries.set(key, entry);
|
||||
enqueue(entry);
|
||||
},
|
||||
|
||||
/**
|
||||
* Request the visible window of a strip in `thumbnailOrder` priority.
|
||||
* @param {Array<object>} candidates
|
||||
* @param {object} box
|
||||
* @param {{ focusIndex: number, firstVisible: number, lastVisible: number, extra?: number }} window
|
||||
*/
|
||||
requestOrdered(
|
||||
candidates,
|
||||
box,
|
||||
{ focusIndex, firstVisible, lastVisible, extra },
|
||||
) {
|
||||
const order = thumbnailOrder(
|
||||
focusIndex,
|
||||
firstVisible,
|
||||
lastVisible,
|
||||
candidates.length,
|
||||
extra,
|
||||
);
|
||||
order.forEach((index, priority) => {
|
||||
this.request(candidates[index], box, priority);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Abort every fetch in flight and forget every unsettled request. A
|
||||
* known day whose image was being fetched again keeps what it knows.
|
||||
*/
|
||||
cancelAll() {
|
||||
queue.length = 0;
|
||||
for (const entry of [...entries.values()]) {
|
||||
if (entry.loading) entry.controller?.abort();
|
||||
if (entry.status === 'unknown') entries.delete(entry.key);
|
||||
}
|
||||
},
|
||||
|
||||
/** Cancel everything and revoke every decoded thumbnail. */
|
||||
clear() {
|
||||
this.cancelAll();
|
||||
for (const entry of entries.values()) revokeImage(entry);
|
||||
entries.clear();
|
||||
},
|
||||
|
||||
/**
|
||||
* Read a day's state. A read is not a use: it never changes residency.
|
||||
* @param {string} key Candidate key.
|
||||
* @returns {{ status: 'unknown'|'present'|'empty'|'error', objectUrl: string | null, acquisitionTime: string | null, loading: boolean }}
|
||||
*/
|
||||
get(key) {
|
||||
const entry = entries.get(key);
|
||||
if (!entry) return EMPTY;
|
||||
return {
|
||||
status: entry.status,
|
||||
objectUrl: entry.objectUrl,
|
||||
acquisitionTime: entry.acquisitionTime,
|
||||
loading: entry.loading,
|
||||
};
|
||||
},
|
||||
|
||||
/** @param {(key: string) => void} listener */
|
||||
subscribe(listener) {
|
||||
if (typeof listener !== 'function') return () => {};
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
|
||||
stats() {
|
||||
return {
|
||||
inFlight,
|
||||
queued: queue.length,
|
||||
decoded: [...entries.values()].filter((entry) => entry.objectUrl)
|
||||
.length,
|
||||
tracked: entries.size,
|
||||
};
|
||||
},
|
||||
|
||||
destroy() {
|
||||
if (destroyed) return;
|
||||
this.clear();
|
||||
destroyed = true;
|
||||
listeners.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createThumbnailLoader } from './thumbnails.js';
|
||||
import { BOX, response, settle } from './testDoubles.mjs';
|
||||
|
||||
const candidate = (product, day) => ({
|
||||
key: `${product}:${day}`,
|
||||
product,
|
||||
day,
|
||||
});
|
||||
|
||||
/** A fetch whose responses the test releases, in any order. */
|
||||
function fakeFetch() {
|
||||
const calls = [];
|
||||
const impl = (url, { signal } = {}) =>
|
||||
new Promise((resolve, reject) => {
|
||||
signal?.addEventListener('abort', () =>
|
||||
reject(Object.assign(new Error('aborted'), { name: 'AbortError' })),
|
||||
);
|
||||
calls.push({ url, signal, resolve });
|
||||
});
|
||||
const respond = (index, { ok = true, present = 'true' } = {}) =>
|
||||
calls[index].resolve(
|
||||
response({
|
||||
ok,
|
||||
headers: {
|
||||
'Data-Present': present,
|
||||
'Acquisition-Time': '2026-09-18T17:12:00Z',
|
||||
},
|
||||
}),
|
||||
);
|
||||
return { impl, calls, respond };
|
||||
}
|
||||
|
||||
function fixture(options = {}) {
|
||||
const fetch = fakeFetch();
|
||||
const created = [];
|
||||
const revoked = [];
|
||||
const loader = createThumbnailLoader({
|
||||
fetchImpl: fetch.impl,
|
||||
createObjectUrl: () => {
|
||||
created.push(`blob:${created.length + 1}`);
|
||||
return created.at(-1);
|
||||
},
|
||||
revokeObjectUrl: (url) => revoked.push(url),
|
||||
...options,
|
||||
});
|
||||
return { loader, fetch, created, revoked };
|
||||
}
|
||||
|
||||
test('Data-Present decides the day: present with an object URL and sensing time, empty, or error', async () => {
|
||||
const { loader, fetch, created } = fixture();
|
||||
const changes = [];
|
||||
loader.subscribe((key) => changes.push(key));
|
||||
loader.request(candidate('S30', '2026-09-18'), BOX, 0);
|
||||
loader.request(candidate('L30', '2026-09-16'), BOX, 1);
|
||||
loader.request(candidate('VIIRS', '2026-09-15'), BOX, 2);
|
||||
assert.equal(loader.get('S30:2026-09-18').loading, true);
|
||||
assert.match(
|
||||
fetch.calls[0].url,
|
||||
/wvs\.earthdata\.nasa\.gov.*HLS_S30.*TIME=2026-09-18.*WIDTH=256&HEIGHT=256/,
|
||||
);
|
||||
fetch.respond(0);
|
||||
fetch.respond(1, { present: 'false' });
|
||||
fetch.respond(2, { ok: false });
|
||||
await settle();
|
||||
assert.deepEqual(loader.get('S30:2026-09-18'), {
|
||||
status: 'present',
|
||||
objectUrl: 'blob:1',
|
||||
acquisitionTime: '2026-09-18T17:12:00Z',
|
||||
loading: false,
|
||||
});
|
||||
assert.equal(loader.get('L30:2026-09-16').status, 'empty');
|
||||
assert.equal(loader.get('VIIRS:2026-09-15').status, 'error');
|
||||
assert.deepEqual(created, ['blob:1']);
|
||||
assert.deepEqual(changes.sort(), [
|
||||
'L30:2026-09-16',
|
||||
'S30:2026-09-18',
|
||||
'VIIRS:2026-09-15',
|
||||
]);
|
||||
assert.equal(loader.get('nope').status, 'unknown');
|
||||
});
|
||||
|
||||
test('at most maxInFlight fetches run and the queue drains by priority', async () => {
|
||||
const { loader, fetch } = fixture({ maxInFlight: 2 });
|
||||
loader.request(candidate('S30', '2026-09-10'), BOX, 5);
|
||||
loader.request(candidate('S30', '2026-09-11'), BOX, 1);
|
||||
loader.request(candidate('S30', '2026-09-12'), BOX, 0);
|
||||
loader.request(candidate('S30', '2026-09-13'), BOX, 3);
|
||||
// The pump is eager: the first two arrivals start at once, the rest queue.
|
||||
assert.equal(fetch.calls.length, 2);
|
||||
assert.match(fetch.calls[0].url, /TIME=2026-09-10/);
|
||||
assert.match(fetch.calls[1].url, /TIME=2026-09-11/);
|
||||
assert.deepEqual(loader.stats(), {
|
||||
inFlight: 2,
|
||||
queued: 2,
|
||||
decoded: 0,
|
||||
tracked: 4,
|
||||
});
|
||||
fetch.respond(0);
|
||||
await settle();
|
||||
assert.equal(fetch.calls.length, 3);
|
||||
assert.match(fetch.calls[2].url, /TIME=2026-09-12/, 'priority 0 before 3');
|
||||
// A repeat request for a queued key just re-prioritises it.
|
||||
loader.request(candidate('S30', '2026-09-13'), BOX, 0);
|
||||
assert.equal(loader.stats().tracked, 4);
|
||||
fetch.respond(1);
|
||||
await settle();
|
||||
assert.equal(fetch.calls.length, 4);
|
||||
assert.match(fetch.calls[3].url, /TIME=2026-09-13/);
|
||||
});
|
||||
|
||||
test('requestOrdered loads the focused card first, then outward, then the margins', () => {
|
||||
const { loader, fetch } = fixture({ maxInFlight: 10 });
|
||||
const candidates = Array.from({ length: 8 }, (_, i) =>
|
||||
candidate('S30', `2026-09-${String(10 + i).padStart(2, '0')}`),
|
||||
);
|
||||
loader.requestOrdered(candidates, BOX, {
|
||||
focusIndex: 3,
|
||||
firstVisible: 2,
|
||||
lastVisible: 5,
|
||||
extra: 1,
|
||||
});
|
||||
const days = fetch.calls.map(
|
||||
(call) => /TIME=2026-09-(\d\d)/.exec(call.url)[1],
|
||||
);
|
||||
assert.deepEqual(days, ['13', '14', '12', '15', '16', '11']);
|
||||
});
|
||||
|
||||
test('decoded thumbnails are evicted least-recently-used and revoked; a read is not a use and an evicted day keeps what it learned', async () => {
|
||||
const { loader, fetch, revoked } = fixture({
|
||||
maxInFlight: 10,
|
||||
maxDecoded: 2,
|
||||
});
|
||||
loader.request(candidate('S30', '2026-09-10'), BOX, 0);
|
||||
loader.request(candidate('S30', '2026-09-11'), BOX, 1);
|
||||
loader.request(candidate('S30', '2026-09-12'), BOX, 2);
|
||||
fetch.respond(0);
|
||||
fetch.respond(1);
|
||||
await settle();
|
||||
assert.equal(loader.stats().decoded, 2);
|
||||
loader.get('S30:2026-09-10'); // reading the oldest does not make it recent
|
||||
fetch.respond(2);
|
||||
await settle();
|
||||
assert.equal(loader.stats().decoded, 2);
|
||||
assert.deepEqual(revoked, ['blob:1']);
|
||||
const evicted = loader.get('S30:2026-09-10');
|
||||
assert.equal(evicted.status, 'present', 'availability survives eviction');
|
||||
assert.equal(evicted.objectUrl, null);
|
||||
assert.equal(evicted.acquisitionTime, '2026-09-18T17:12:00Z');
|
||||
assert.equal(loader.stats().tracked, 3, 'the entry is kept');
|
||||
assert.equal(loader.get('S30:2026-09-11').objectUrl, 'blob:2');
|
||||
assert.equal(loader.get('S30:2026-09-12').objectUrl, 'blob:3');
|
||||
// A repeat request IS a use, and re-fetches an evicted day without ever
|
||||
// reporting it unknown; 11 is now the LRU and goes when 10 comes back.
|
||||
loader.request(candidate('S30', '2026-09-12'), BOX, 0);
|
||||
loader.request(candidate('S30', '2026-09-10'), BOX, 0);
|
||||
assert.equal(fetch.calls.length, 4, 'an evicted day is fetched again');
|
||||
assert.equal(loader.get('S30:2026-09-10').status, 'present');
|
||||
assert.equal(loader.get('S30:2026-09-10').loading, true);
|
||||
fetch.respond(3);
|
||||
await settle();
|
||||
assert.equal(loader.get('S30:2026-09-10').objectUrl, 'blob:4');
|
||||
assert.deepEqual(revoked, ['blob:1', 'blob:2']);
|
||||
assert.equal(loader.get('S30:2026-09-11').status, 'present');
|
||||
assert.equal(loader.get('S30:2026-09-12').objectUrl, 'blob:3');
|
||||
// A day that already holds an image is not fetched twice.
|
||||
loader.request(candidate('S30', '2026-09-10'), BOX, 0);
|
||||
assert.equal(fetch.calls.length, 4);
|
||||
});
|
||||
|
||||
test('reading every card in strip order (a snapshot) never decides who is evicted; evicted days stay known and come back on request', async () => {
|
||||
const { loader, fetch, revoked } = fixture({
|
||||
maxInFlight: 20,
|
||||
maxDecoded: 9,
|
||||
});
|
||||
const candidates = Array.from({ length: 20 }, (_, i) =>
|
||||
candidate('S30', `2026-09-${String(i + 1).padStart(2, '0')}`),
|
||||
);
|
||||
candidates.forEach((c, i) => loader.request(c, BOX, i));
|
||||
assert.equal(fetch.calls.length, 20);
|
||||
// Decode the strip back to front, leaving the newest card in flight.
|
||||
for (let i = 19; i >= 1; i -= 1) fetch.respond(i);
|
||||
await settle();
|
||||
const holders = () =>
|
||||
candidates.filter((c) => loader.get(c.key).objectUrl).map((c) => c.key);
|
||||
assert.equal(loader.stats().decoded, 9);
|
||||
assert.equal(loader.stats().tracked, 20);
|
||||
assert.deepEqual(
|
||||
holders(),
|
||||
candidates.slice(1, 10).map((c) => c.key),
|
||||
'the nine most recently decoded',
|
||||
);
|
||||
// Sweep every card in strip order, several times, as getSnapshot does.
|
||||
for (let pass = 0; pass < 3; pass += 1)
|
||||
for (const c of candidates) loader.get(c.key);
|
||||
assert.deepEqual(
|
||||
holders(),
|
||||
candidates.slice(1, 10).map((c) => c.key),
|
||||
);
|
||||
// The last decode evicts the least recently DECODED card, not the first
|
||||
// card the sweep happened to read.
|
||||
fetch.respond(0);
|
||||
await settle();
|
||||
assert.deepEqual(
|
||||
holders(),
|
||||
candidates.slice(0, 9).map((c) => c.key),
|
||||
);
|
||||
assert.equal(revoked.length, 11);
|
||||
for (const c of candidates) {
|
||||
const entry = loader.get(c.key);
|
||||
assert.equal(entry.status, 'present', `${c.key} stays present`);
|
||||
assert.equal(entry.acquisitionTime, '2026-09-18T17:12:00Z');
|
||||
assert.equal(entry.loading, false);
|
||||
}
|
||||
// Re-requesting an evicted day fetches it again and it holds an image.
|
||||
const evictedKey = candidates[15].key;
|
||||
assert.equal(loader.get(evictedKey).objectUrl, null);
|
||||
loader.request(candidates[15], BOX, 0);
|
||||
assert.equal(fetch.calls.length, 21);
|
||||
assert.equal(loader.get(evictedKey).status, 'present');
|
||||
fetch.respond(20);
|
||||
await settle();
|
||||
assert.ok(loader.get(evictedKey).objectUrl);
|
||||
assert.equal(loader.stats().decoded, 9);
|
||||
assert.equal(loader.stats().tracked, 20);
|
||||
});
|
||||
|
||||
test('cancelAll aborts in-flight fetches and a late settle never leaks an object URL', async () => {
|
||||
const { loader, fetch, created, revoked } = fixture({ maxInFlight: 1 });
|
||||
loader.request(candidate('S30', '2026-09-10'), BOX, 0);
|
||||
loader.request(candidate('S30', '2026-09-11'), BOX, 1);
|
||||
const signal = fetch.calls[0].signal;
|
||||
loader.cancelAll();
|
||||
assert.equal(signal.aborted, true);
|
||||
assert.deepEqual(loader.stats(), {
|
||||
inFlight: 1,
|
||||
queued: 0,
|
||||
decoded: 0,
|
||||
tracked: 0,
|
||||
});
|
||||
await settle();
|
||||
assert.equal(loader.stats().inFlight, 0);
|
||||
assert.deepEqual(created, []);
|
||||
assert.deepEqual(revoked, []);
|
||||
assert.equal(loader.get('S30:2026-09-10').status, 'unknown');
|
||||
});
|
||||
|
||||
test('clear and destroy revoke every resident thumbnail', async () => {
|
||||
const { loader, fetch, revoked } = fixture({ maxInFlight: 10 });
|
||||
loader.request(candidate('S30', '2026-09-10'), BOX, 0);
|
||||
loader.request(candidate('S30', '2026-09-11'), BOX, 1);
|
||||
fetch.respond(0);
|
||||
fetch.respond(1);
|
||||
await settle();
|
||||
loader.clear();
|
||||
assert.deepEqual(revoked.sort(), ['blob:1', 'blob:2']);
|
||||
assert.equal(loader.stats().tracked, 0);
|
||||
loader.request(candidate('S30', '2026-09-12'), BOX, 0);
|
||||
fetch.respond(2);
|
||||
await settle();
|
||||
loader.destroy();
|
||||
assert.equal(revoked.length, 3);
|
||||
loader.request(candidate('S30', '2026-09-13'), BOX, 0);
|
||||
assert.equal(fetch.calls.length, 3, 'a destroyed loader fetches nothing');
|
||||
});
|
||||
|
||||
test('a request queued behind an aborted fetch still starts once the abort settles', async () => {
|
||||
// A response that arrives after the abort (the fetch ignored the signal).
|
||||
const calls = [];
|
||||
const fetchImpl = (url, { signal } = {}) =>
|
||||
new Promise((resolve) => calls.push({ url, signal, resolve }));
|
||||
const loader = createThumbnailLoader({
|
||||
fetchImpl,
|
||||
maxInFlight: 1,
|
||||
createObjectUrl: () => 'blob:x',
|
||||
revokeObjectUrl: () => {},
|
||||
});
|
||||
loader.request(candidate('S30', '2026-09-10'), BOX, 0);
|
||||
loader.clear();
|
||||
loader.request(candidate('S30', '2026-09-11'), BOX, 0);
|
||||
assert.equal(calls[0].signal.aborted, true);
|
||||
assert.deepEqual(loader.stats(), {
|
||||
inFlight: 1,
|
||||
queued: 1,
|
||||
decoded: 0,
|
||||
tracked: 1,
|
||||
});
|
||||
calls[0].resolve({
|
||||
ok: true,
|
||||
headers: { get: () => null },
|
||||
blob: async () => ({}),
|
||||
});
|
||||
await settle();
|
||||
assert.equal(calls.length, 2, 'the replacement request was pumped');
|
||||
assert.match(calls[1].url, /TIME=2026-09-11/);
|
||||
assert.deepEqual(loader.stats(), {
|
||||
inFlight: 1,
|
||||
queued: 0,
|
||||
decoded: 0,
|
||||
tracked: 1,
|
||||
});
|
||||
|
||||
// The same sequence when the fetch rejects with AbortError instead.
|
||||
const rejecting = fixture({ maxInFlight: 1 });
|
||||
rejecting.loader.request(candidate('S30', '2026-09-10'), BOX, 0);
|
||||
rejecting.loader.clear();
|
||||
rejecting.loader.request(candidate('S30', '2026-09-11'), BOX, 0);
|
||||
await settle();
|
||||
assert.equal(rejecting.fetch.calls.length, 2);
|
||||
assert.deepEqual(rejecting.loader.stats(), {
|
||||
inFlight: 1,
|
||||
queued: 0,
|
||||
decoded: 0,
|
||||
tracked: 1,
|
||||
});
|
||||
});
|
||||
+75
-3
@@ -1,6 +1,13 @@
|
||||
import { indexMapSources } from './registry.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createMapCredits } from './credits.js';
|
||||
import { acquireImageryComparison } from './imageryComparison.js';
|
||||
|
||||
/**
|
||||
* Private `setStack` option marking a switch the controller issues itself
|
||||
* (a tile-failure fallback); outside callers cannot forge it.
|
||||
*/
|
||||
const AUTOMATIC = Symbol('automatic switch');
|
||||
|
||||
/** Coordinate source lifetimes and scene changes; the registry owns provider choices. */
|
||||
export class MapSourceController {
|
||||
@@ -33,12 +40,15 @@ export class MapSourceController {
|
||||
this._ownedTilesets = new Set();
|
||||
this._disposed = new WeakSet();
|
||||
this._switchGen = 0;
|
||||
/** Who chose the current generation's stack: 'manual' | 'automatic'. */
|
||||
this._switchOrigin = 'manual';
|
||||
this._isSwitching = false;
|
||||
this._lastError = null;
|
||||
this._imageryLayer = null;
|
||||
this._activeImageryProvider = null;
|
||||
this._removeImageryErrorListener = null;
|
||||
this._terrainMode = null;
|
||||
this._subscribers = new Set();
|
||||
this._destroyed = false;
|
||||
}
|
||||
|
||||
@@ -84,6 +94,16 @@ export class MapSourceController {
|
||||
getSwitchGeneration() {
|
||||
return this._switchGen;
|
||||
}
|
||||
/**
|
||||
* Who chose the stack of the current switch generation: 'manual' for a
|
||||
* `setStack` issued from outside (the operator, a scene, a lease),
|
||||
* 'automatic' when the controller replaced it itself (a construction or
|
||||
* tile-failure fallback, or a recovery after a failed activation).
|
||||
* @returns {'manual' | 'automatic'}
|
||||
*/
|
||||
getSwitchOrigin() {
|
||||
return this._switchOrigin;
|
||||
}
|
||||
getState(status = this._isSwitching ? 'switching' : 'ready') {
|
||||
return {
|
||||
activeId: this._activeId,
|
||||
@@ -91,6 +111,7 @@ export class MapSourceController {
|
||||
stacks: this.getStacks(),
|
||||
status,
|
||||
lastError: this._lastError,
|
||||
switchOrigin: this._switchOrigin,
|
||||
...this._registry.state,
|
||||
};
|
||||
}
|
||||
@@ -98,7 +119,48 @@ export class MapSourceController {
|
||||
this._onChange?.(this.getState(status));
|
||||
}
|
||||
|
||||
async setStack(id, { silent = false } = {}) {
|
||||
/**
|
||||
* Hear every settled activation: a stack switch, a silent switch, a
|
||||
* fallback after a failure, a recovery. Unlike `onChange` (the UI's
|
||||
* status feed, muted by `silent`), listeners fire once per `setStack`
|
||||
* after `_activeId` has settled, so a layer that drapes on the active
|
||||
* host can rebind. The state's `switchOrigin` says whether the caller
|
||||
* ('manual') or the controller itself ('automatic') chose the settled
|
||||
* stack. A listener that throws is logged, never fatal.
|
||||
* @param {(state: object) => void} listener
|
||||
* @returns {() => void} Unsubscribe.
|
||||
*/
|
||||
subscribe(listener) {
|
||||
if (typeof listener !== 'function') return () => {};
|
||||
this._subscribers.add(listener);
|
||||
return () => {
|
||||
this._subscribers.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
_notifySubscribers() {
|
||||
if (this._destroyed || !this._subscribers.size) return;
|
||||
const state = this.getState();
|
||||
for (const listener of [...this._subscribers]) {
|
||||
try {
|
||||
listener(state);
|
||||
} catch (error) {
|
||||
console.warn('[MapSourceController] subscriber failed:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lease the map for an imagery comparison. switchPolicy: 'preserve' (never
|
||||
* switch) | 'esri' (switch to Esri imagery from any other stack) |
|
||||
* 'esri-if-photoreal' (switch only when Google 3D is active). Throws while
|
||||
* another owner holds the lease; see acquireImageryComparison().
|
||||
*/
|
||||
acquireImageryComparison(options) {
|
||||
return acquireImageryComparison(this, options);
|
||||
}
|
||||
|
||||
async setStack(id, { silent = false, [AUTOMATIC]: automatic = false } = {}) {
|
||||
if (this._destroyed) return this.getState();
|
||||
const stack = this.getStack(id) || this.getStack(this._registry.unknownId);
|
||||
if (!stack) return null;
|
||||
@@ -109,6 +171,7 @@ export class MapSourceController {
|
||||
return this.getState();
|
||||
}
|
||||
const gen = ++this._switchGen;
|
||||
this._switchOrigin = automatic ? 'automatic' : 'manual';
|
||||
this._isSwitching = true;
|
||||
this._lastError = null;
|
||||
if (!silent) this._emitChange('switching');
|
||||
@@ -116,6 +179,7 @@ export class MapSourceController {
|
||||
const activation = await this._activate(stack, gen);
|
||||
if (gen !== this._switchGen) return this.getState();
|
||||
this._activeId = activation?.effectiveStackId || stack.id;
|
||||
if (this._activeId !== stack.id) this._switchOrigin = 'automatic';
|
||||
if (activation?.fallbackMessage) {
|
||||
this._lastError = activation.fallbackMessage;
|
||||
this._onError?.(activation.fallbackMessage, stack);
|
||||
@@ -133,6 +197,8 @@ export class MapSourceController {
|
||||
recovery.id !== stack.id &&
|
||||
this.isStackAvailable(recovery.id)
|
||||
) {
|
||||
// Whatever settles now is the controller's choice, not the caller's.
|
||||
this._switchOrigin = 'automatic';
|
||||
try {
|
||||
const activation = await this._activate(recovery, gen);
|
||||
if (gen !== this._switchGen) return this.getState();
|
||||
@@ -145,7 +211,12 @@ export class MapSourceController {
|
||||
}
|
||||
if (!silent) this._emitChange('error');
|
||||
} finally {
|
||||
if (gen === this._switchGen) this._isSwitching = false;
|
||||
if (gen === this._switchGen) {
|
||||
this._isSwitching = false;
|
||||
// The active id has settled for this generation (activated, fell
|
||||
// back, or recovered): every subscriber hears it, silent or not.
|
||||
this._notifySubscribers();
|
||||
}
|
||||
}
|
||||
return this.getState();
|
||||
}
|
||||
@@ -290,7 +361,7 @@ export class MapSourceController {
|
||||
this.getStack(resolution.effectiveStackId),
|
||||
);
|
||||
const expectedGen = this._switchGen + 1;
|
||||
void this.setStack(fallback.id, { silent: true })
|
||||
void this.setStack(fallback.id, { silent: true, [AUTOMATIC]: true })
|
||||
.then((state) => {
|
||||
if (
|
||||
!this._destroyed &&
|
||||
@@ -324,6 +395,7 @@ export class MapSourceController {
|
||||
destroy() {
|
||||
if (this._destroyed) return;
|
||||
this._destroyed = true;
|
||||
this._subscribers.clear();
|
||||
this._switchGen++;
|
||||
this._abort.abort();
|
||||
this._isSwitching = false;
|
||||
|
||||
@@ -458,3 +458,384 @@ test('imagery host tileset follows supplied, owned and globe map sources', async
|
||||
controller.destroy();
|
||||
assert.equal(controller.getImageryHostTileset(), null);
|
||||
});
|
||||
|
||||
function leaseFixture() {
|
||||
const env = publicFixture();
|
||||
const switches = [];
|
||||
const setStack = env.controller.setStack.bind(env.controller);
|
||||
env.controller.setStack = (id, options) => {
|
||||
switches.push(id);
|
||||
return setStack(id, options);
|
||||
};
|
||||
return { ...env, switches };
|
||||
}
|
||||
|
||||
test('an imagery comparison lease rejects a second owner synchronously until released', async () => {
|
||||
const env = leaseFixture();
|
||||
const lease = env.controller.acquireImageryComparison({
|
||||
owner: 'nepal',
|
||||
switchPolicy: 'preserve',
|
||||
});
|
||||
assert.throws(
|
||||
() => env.controller.acquireImageryComparison({ owner: 'recent' }),
|
||||
/held by nepal/,
|
||||
);
|
||||
assert.deepEqual(await lease.ready, {
|
||||
status: 'ready',
|
||||
activeId: 'photoreal',
|
||||
});
|
||||
await lease.release();
|
||||
const next = env.controller.acquireImageryComparison({ owner: 'recent' });
|
||||
assert.deepEqual(await next.ready, {
|
||||
status: 'ready',
|
||||
activeId: 'photoreal',
|
||||
});
|
||||
await next.release();
|
||||
assert.deepEqual(env.switches, []);
|
||||
env.controller.destroy();
|
||||
});
|
||||
|
||||
test("the 'esri' policy switches from any other stack and release restores it", async () => {
|
||||
for (const initial of ['photoreal', 'osm']) {
|
||||
const env = leaseFixture();
|
||||
if (initial !== env.controller.getActiveId())
|
||||
await env.controller.setStack(initial);
|
||||
env.switches.length = 0;
|
||||
const lease = env.controller.acquireImageryComparison({
|
||||
owner: 'nepal',
|
||||
switchPolicy: 'esri',
|
||||
});
|
||||
assert.deepEqual(await lease.ready, {
|
||||
status: 'ready',
|
||||
activeId: 'esri-imagery',
|
||||
});
|
||||
assert.equal(env.controller.getActiveId(), 'esri-imagery');
|
||||
await lease.release();
|
||||
assert.equal(env.controller.getActiveId(), initial);
|
||||
assert.deepEqual(env.switches, ['esri-imagery', initial]);
|
||||
env.controller.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test("the 'esri-if-photoreal' policy leaves any 2D basemap alone", async () => {
|
||||
const photoreal = leaseFixture();
|
||||
const fromPhotoreal = photoreal.controller.acquireImageryComparison({
|
||||
owner: 'recent',
|
||||
switchPolicy: 'esri-if-photoreal',
|
||||
});
|
||||
assert.equal((await fromPhotoreal.ready).activeId, 'esri-imagery');
|
||||
await fromPhotoreal.release();
|
||||
assert.deepEqual(photoreal.switches, ['esri-imagery', 'photoreal']);
|
||||
photoreal.controller.destroy();
|
||||
|
||||
const osm = leaseFixture();
|
||||
await osm.controller.setStack('osm');
|
||||
osm.switches.length = 0;
|
||||
const fromOsm = osm.controller.acquireImageryComparison({
|
||||
owner: 'recent',
|
||||
switchPolicy: 'esri-if-photoreal',
|
||||
});
|
||||
assert.deepEqual(await fromOsm.ready, { status: 'ready', activeId: 'osm' });
|
||||
await fromOsm.release();
|
||||
assert.equal(osm.controller.getActiveId(), 'osm');
|
||||
assert.deepEqual(osm.switches, []);
|
||||
osm.controller.destroy();
|
||||
});
|
||||
|
||||
test("the 'preserve' policy never switches, whatever is active", async () => {
|
||||
for (const initial of ['photoreal', 'esri-imagery', 'osm']) {
|
||||
const env = leaseFixture();
|
||||
if (initial !== env.controller.getActiveId())
|
||||
await env.controller.setStack(initial);
|
||||
env.switches.length = 0;
|
||||
const lease = env.controller.acquireImageryComparison({
|
||||
owner: 'nepal',
|
||||
});
|
||||
assert.deepEqual(await lease.ready, { status: 'ready', activeId: initial });
|
||||
await lease.release();
|
||||
assert.equal(env.controller.getActiveId(), initial);
|
||||
assert.deepEqual(env.switches, []);
|
||||
env.controller.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('release keeps an operator choice made while the comparison was open', async () => {
|
||||
const env = leaseFixture();
|
||||
const lease = env.controller.acquireImageryComparison({
|
||||
owner: 'nepal',
|
||||
switchPolicy: 'esri',
|
||||
});
|
||||
await lease.ready;
|
||||
await env.controller.setStack('osm');
|
||||
await lease.release();
|
||||
assert.equal(env.controller.getActiveId(), 'osm');
|
||||
assert.deepEqual(env.switches, ['esri-imagery', 'osm']);
|
||||
env.controller.destroy();
|
||||
});
|
||||
|
||||
test('release keeps a fallback that replaced the comparison stack', async () => {
|
||||
const env = leaseFixture();
|
||||
const lease = env.controller.acquireImageryComparison({
|
||||
owner: 'nepal',
|
||||
switchPolicy: 'esri',
|
||||
});
|
||||
await lease.ready;
|
||||
const errors = env.providers.get('esri-imagery').errorEvent;
|
||||
errors.raise();
|
||||
errors.raise();
|
||||
await settle();
|
||||
assert.equal(env.controller.getActiveId(), 'osm');
|
||||
await lease.release();
|
||||
assert.equal(env.controller.getActiveId(), 'osm');
|
||||
assert.deepEqual(env.switches, ['esri-imagery', 'osm']);
|
||||
env.controller.destroy();
|
||||
});
|
||||
|
||||
test('release during acquisition lets no late activation survive', async () => {
|
||||
const env = leaseFixture();
|
||||
let resolve;
|
||||
env.registry.sources.find(
|
||||
(source) => source.descriptor.id === 'esri-imagery',
|
||||
).imagery = () =>
|
||||
new Promise((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
const lease = env.controller.acquireImageryComparison({
|
||||
owner: 'nepal',
|
||||
switchPolicy: 'esri',
|
||||
});
|
||||
await settle();
|
||||
const releasing = lease.release();
|
||||
resolve(env.providers.get('esri-imagery'));
|
||||
assert.deepEqual(await lease.ready, {
|
||||
status: 'superseded',
|
||||
activeId: 'photoreal',
|
||||
});
|
||||
await releasing;
|
||||
await settle();
|
||||
assert.equal(env.controller.getActiveId(), 'photoreal');
|
||||
assert.equal(env.viewer.scene.globe.show, false);
|
||||
assert.equal(env.imagery.length, 0);
|
||||
assert.equal(env.tileset.show, true);
|
||||
assert.deepEqual(env.switches, ['esri-imagery', 'photoreal']);
|
||||
env.controller.destroy();
|
||||
});
|
||||
|
||||
test('release is idempotent and frees the lease for the next owner', async () => {
|
||||
const env = leaseFixture();
|
||||
const lease = env.controller.acquireImageryComparison({
|
||||
owner: 'nepal',
|
||||
switchPolicy: 'esri',
|
||||
});
|
||||
await lease.ready;
|
||||
const first = lease.release();
|
||||
assert.equal(lease.release(), first);
|
||||
await first;
|
||||
await lease.release();
|
||||
assert.deepEqual(env.switches, ['esri-imagery', 'photoreal']);
|
||||
const next = env.controller.acquireImageryComparison({
|
||||
owner: 'recent',
|
||||
switchPolicy: 'esri',
|
||||
});
|
||||
assert.equal((await next.ready).status, 'ready');
|
||||
await next.release();
|
||||
assert.deepEqual(env.switches, [
|
||||
'esri-imagery',
|
||||
'photoreal',
|
||||
'esri-imagery',
|
||||
'photoreal',
|
||||
]);
|
||||
env.controller.destroy();
|
||||
});
|
||||
|
||||
test('an unavailable comparison stack reports failure and release leaves the map alone', async () => {
|
||||
const env = leaseFixture();
|
||||
const errors = [];
|
||||
env.controller._onError = (message) => errors.push(message);
|
||||
const esri = env.controller._sources.get('esri-imagery');
|
||||
esri.available = false;
|
||||
esri.unavailableReason = 'Esri offline';
|
||||
const lease = env.controller.acquireImageryComparison({
|
||||
owner: 'nepal',
|
||||
switchPolicy: 'esri',
|
||||
});
|
||||
assert.deepEqual(await lease.ready, {
|
||||
status: 'failed',
|
||||
activeId: 'photoreal',
|
||||
});
|
||||
await lease.release();
|
||||
assert.deepEqual(env.switches, ['esri-imagery']);
|
||||
assert.deepEqual(errors, ['Esri offline']);
|
||||
assert.equal(env.controller.getActiveId(), 'photoreal');
|
||||
env.controller.destroy();
|
||||
});
|
||||
|
||||
test('a lease stays owned until its restoration settles, so a rival cannot read the comparison stack as its own', async () => {
|
||||
const env = leaseFixture();
|
||||
const lease = env.controller.acquireImageryComparison({
|
||||
owner: 'recent',
|
||||
switchPolicy: 'esri-if-photoreal',
|
||||
});
|
||||
assert.equal((await lease.ready).activeId, 'esri-imagery');
|
||||
// Hold the restore switch open: the map is still on Esri while it runs.
|
||||
const setStack = env.controller.setStack;
|
||||
let finishRestore;
|
||||
env.controller.setStack = (id, options) =>
|
||||
new Promise((resolve) => {
|
||||
finishRestore = () => resolve(setStack(id, options));
|
||||
});
|
||||
const releasing = lease.release();
|
||||
assert.equal(typeof finishRestore, 'function', 'the restore was issued');
|
||||
assert.throws(
|
||||
() =>
|
||||
env.controller.acquireImageryComparison({
|
||||
owner: 'nepal',
|
||||
switchPolicy: 'esri',
|
||||
}),
|
||||
/held by recent/,
|
||||
'the pending restoration still owns the lease',
|
||||
);
|
||||
finishRestore();
|
||||
await releasing;
|
||||
assert.equal(env.controller.getActiveId(), 'photoreal');
|
||||
env.controller.setStack = setStack;
|
||||
const next = env.controller.acquireImageryComparison({
|
||||
owner: 'nepal',
|
||||
switchPolicy: 'esri',
|
||||
});
|
||||
assert.deepEqual(await next.ready, {
|
||||
status: 'ready',
|
||||
activeId: 'esri-imagery',
|
||||
});
|
||||
await next.release();
|
||||
assert.equal(env.controller.getActiveId(), 'photoreal');
|
||||
assert.deepEqual(env.switches, [
|
||||
'esri-imagery',
|
||||
'photoreal',
|
||||
'esri-imagery',
|
||||
'photoreal',
|
||||
]);
|
||||
env.controller.destroy();
|
||||
});
|
||||
|
||||
test('subscribe hears every settled activation — silent switches, fallbacks and recoveries — until unsubscribed or destroyed', async () => {
|
||||
const env = publicFixture();
|
||||
const heard = [];
|
||||
const off = env.controller.subscribe((state) =>
|
||||
heard.push([state.activeId, state.status]),
|
||||
);
|
||||
assert.equal(typeof off, 'function');
|
||||
assert.equal(typeof env.controller.subscribe(null), 'function');
|
||||
await env.controller.setStack('esri-imagery');
|
||||
assert.deepEqual(heard, [['esri-imagery', 'ready']]);
|
||||
// A silent switch mutes onChange but not the subscription.
|
||||
const changesBefore = env.changes.length;
|
||||
await env.controller.setStack('photoreal', { silent: true });
|
||||
assert.equal(env.changes.length, changesBefore, 'onChange stays silent');
|
||||
assert.deepEqual(heard.at(-1), ['photoreal', 'ready']);
|
||||
// A subscriber that throws does not stop the others.
|
||||
const noisy = env.controller.subscribe(() => {
|
||||
throw new Error('listener failed');
|
||||
});
|
||||
const warn = console.warn;
|
||||
const warnings = [];
|
||||
console.warn = (...args) => warnings.push(args);
|
||||
try {
|
||||
await env.controller.setStack('esri-imagery');
|
||||
} finally {
|
||||
console.warn = warn;
|
||||
}
|
||||
assert.equal(warnings.length, 1);
|
||||
assert.deepEqual(heard.at(-1), ['esri-imagery', 'ready']);
|
||||
noisy();
|
||||
off();
|
||||
await env.controller.setStack('photoreal');
|
||||
assert.equal(heard.length, 3, 'unsubscribed listeners hear nothing');
|
||||
env.controller.destroy();
|
||||
|
||||
// A failed activation that recovers still settles once, on the recovery.
|
||||
const recovering = fixture(
|
||||
{
|
||||
defaultId: 'first',
|
||||
recoveryId: 'recovery',
|
||||
sources: [
|
||||
{
|
||||
descriptor: descriptor('first'),
|
||||
imagery: async () => {
|
||||
throw new Error('first offline');
|
||||
},
|
||||
},
|
||||
{ descriptor: descriptor('recovery'), imagery: async () => ({}) },
|
||||
],
|
||||
},
|
||||
{ onError: () => {} },
|
||||
);
|
||||
const recovered = [];
|
||||
recovering.controller.subscribe((state) => recovered.push(state.activeId));
|
||||
await recovering.controller.setStack('first');
|
||||
assert.deepEqual(recovered, ['recovery']);
|
||||
recovering.controller.destroy();
|
||||
await recovering.controller.setStack('first');
|
||||
assert.deepEqual(recovered, ['recovery'], 'a destroyed controller is mute');
|
||||
});
|
||||
|
||||
test('each switch generation reports its origin: an outside setStack is manual, a fallback or recovery automatic', async () => {
|
||||
const env = publicFixture();
|
||||
const heard = [];
|
||||
env.controller.subscribe((state) =>
|
||||
heard.push([state.activeId, state.switchOrigin]),
|
||||
);
|
||||
await env.controller.setStack('esri-imagery');
|
||||
assert.equal(env.controller.getSwitchOrigin(), 'manual');
|
||||
const generation = env.controller.getSwitchGeneration();
|
||||
// Two tile failures: the controller falls back to OSM on its own.
|
||||
const errors = env.providers.get('esri-imagery').errorEvent;
|
||||
errors.raise();
|
||||
errors.raise();
|
||||
await settle();
|
||||
assert.equal(env.controller.getActiveId(), 'osm');
|
||||
assert.equal(env.controller.getSwitchGeneration(), generation + 1);
|
||||
assert.equal(env.controller.getSwitchOrigin(), 'automatic');
|
||||
assert.equal(env.controller.getState().switchOrigin, 'automatic');
|
||||
// A silent switch from outside is still the caller's choice.
|
||||
await env.controller.setStack('photoreal', { silent: true });
|
||||
assert.equal(env.controller.getSwitchOrigin(), 'manual');
|
||||
assert.deepEqual(heard, [
|
||||
['esri-imagery', 'manual'],
|
||||
['osm', 'automatic'],
|
||||
['photoreal', 'manual'],
|
||||
]);
|
||||
env.controller.destroy();
|
||||
|
||||
// A failed activation that recovers settles as automatic.
|
||||
const recovering = fixture(
|
||||
{
|
||||
defaultId: 'first',
|
||||
recoveryId: 'recovery',
|
||||
sources: [
|
||||
{
|
||||
descriptor: descriptor('first'),
|
||||
imagery: async () => {
|
||||
throw new Error('first offline');
|
||||
},
|
||||
},
|
||||
{ descriptor: descriptor('recovery'), imagery: async () => ({}) },
|
||||
],
|
||||
},
|
||||
{ onError: () => {} },
|
||||
);
|
||||
const recovered = [];
|
||||
recovering.controller.subscribe((state) =>
|
||||
recovered.push([state.activeId, state.switchOrigin]),
|
||||
);
|
||||
await recovering.controller.setStack('first');
|
||||
assert.equal(recovering.controller.getActiveId(), 'recovery');
|
||||
assert.equal(recovering.controller.getSwitchOrigin(), 'automatic');
|
||||
await recovering.controller.setStack('recovery');
|
||||
assert.equal(recovering.controller.getSwitchOrigin(), 'manual');
|
||||
assert.deepEqual(recovered, [
|
||||
['recovery', 'automatic'],
|
||||
['recovery', 'manual'],
|
||||
]);
|
||||
recovering.controller.destroy();
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
/** Basemap that before/after imagery comparisons are read against. */
|
||||
export const IMAGERY_COMPARISON_STACK_ID = 'esri-imagery';
|
||||
|
||||
const SWITCH_POLICIES = new Set(['preserve', 'esri', 'esri-if-photoreal']);
|
||||
const leases = new WeakMap();
|
||||
|
||||
function comparisonTargetId(activeId, switchPolicy) {
|
||||
if (!SWITCH_POLICIES.has(switchPolicy))
|
||||
throw new Error(
|
||||
`Unknown imagery comparison switch policy: ${switchPolicy}`,
|
||||
);
|
||||
if (switchPolicy === 'esri' && activeId !== IMAGERY_COMPARISON_STACK_ID)
|
||||
return IMAGERY_COMPARISON_STACK_ID;
|
||||
if (switchPolicy === 'esri-if-photoreal' && activeId === 'photoreal')
|
||||
return IMAGERY_COMPARISON_STACK_ID;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lease the map for an imagery comparison.
|
||||
*
|
||||
* Works on any controller exposing getActiveId(), getSwitchGeneration() and
|
||||
* setStack(id); MapSourceController#acquireImageryComparison delegates here.
|
||||
* One lease per controller: acquiring while another owner holds it throws
|
||||
* synchronously. The previous stack id and the switch generation are recorded
|
||||
* the moment the switch is issued (not after awaiting it), so release() can
|
||||
* tell its own switch from a later operator choice. `ready` resolves to
|
||||
* `{ status: 'ready' | 'superseded' | 'failed', activeId }`; a resolved
|
||||
* setStack() does not mean success because the controller falls back
|
||||
* internally. release() is idempotent and restores the previous stack only
|
||||
* while the lease's own generation still owns the active stack; releasing
|
||||
* during acquisition re-issues the previous stack so the late activation
|
||||
* cannot survive. The lease stays owned until that restoration settles (a
|
||||
* rival acquiring meanwhile still throws), so nobody can read the comparison
|
||||
* stack as their own while it is on its way out; a release with nothing to
|
||||
* restore frees the lease synchronously.
|
||||
*/
|
||||
export function acquireImageryComparison(
|
||||
controller,
|
||||
{ owner = 'imagery-comparison', switchPolicy = 'preserve' } = {},
|
||||
) {
|
||||
if (!controller)
|
||||
throw new Error('Imagery comparison needs a map stack controller');
|
||||
const held = leases.get(controller);
|
||||
if (held)
|
||||
throw new Error(
|
||||
held.releasing
|
||||
? `Imagery comparison is still held by ${held.owner} until its release settles`
|
||||
: `Imagery comparison is already held by ${held.owner}`,
|
||||
);
|
||||
const previousId = controller.getActiveId?.() ?? null;
|
||||
const targetId = comparisonTargetId(previousId, switchPolicy);
|
||||
const lease = {
|
||||
owner,
|
||||
generation: null,
|
||||
acquiring: false,
|
||||
releasing: null,
|
||||
};
|
||||
leases.set(controller, lease);
|
||||
const activeId = () => controller.getActiveId?.() ?? null;
|
||||
const generation = () => controller.getSwitchGeneration?.() ?? null;
|
||||
|
||||
let ready;
|
||||
if (targetId) {
|
||||
lease.acquiring = true;
|
||||
let switching;
|
||||
try {
|
||||
switching = Promise.resolve(controller.setStack(targetId));
|
||||
} catch (error) {
|
||||
switching = Promise.reject(error);
|
||||
}
|
||||
lease.generation = generation();
|
||||
const settle = () => {
|
||||
lease.acquiring = false;
|
||||
};
|
||||
ready = switching.then(
|
||||
() => {
|
||||
settle();
|
||||
const active = activeId();
|
||||
const status =
|
||||
generation() !== lease.generation
|
||||
? 'superseded'
|
||||
: active === targetId
|
||||
? 'ready'
|
||||
: 'failed';
|
||||
return { status, activeId: active };
|
||||
},
|
||||
(error) => {
|
||||
settle();
|
||||
throw error;
|
||||
},
|
||||
);
|
||||
} else {
|
||||
ready = Promise.resolve({ status: 'ready', activeId: previousId });
|
||||
}
|
||||
|
||||
const disown = () => {
|
||||
if (leases.get(controller) === lease) leases.delete(controller);
|
||||
};
|
||||
const release = () => {
|
||||
if (lease.releasing) return lease.releasing;
|
||||
const restore =
|
||||
Boolean(targetId) &&
|
||||
previousId != null &&
|
||||
previousId !== targetId &&
|
||||
generation() === lease.generation &&
|
||||
(lease.acquiring || activeId() === targetId);
|
||||
if (!restore) {
|
||||
disown();
|
||||
lease.releasing = Promise.resolve();
|
||||
return lease.releasing;
|
||||
}
|
||||
lease.releasing = (async () => {
|
||||
try {
|
||||
await controller.setStack(previousId);
|
||||
} finally {
|
||||
disown();
|
||||
}
|
||||
})();
|
||||
return lease.releasing;
|
||||
};
|
||||
|
||||
return { ready, release };
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Where draped imagery can go on the current map stack. A globe stack drapes
|
||||
* on `viewer.imageryLayers`; a 3D-tiles stack hides the globe, and Cesium
|
||||
* 1.138 drapes imagery onto the tileset's own `imageryLayers` instead. When
|
||||
* neither exists there is nowhere to drape and the caller says so.
|
||||
*/
|
||||
|
||||
/** Guidance shown when the active map stack cannot host draped imagery. */
|
||||
export const NO_IMAGERY_HOST = 'Hidden by this map source · choose a globe map';
|
||||
|
||||
/**
|
||||
* Resolve the imagery layer collection for the active map stack.
|
||||
* @param {{ viewer?: object, tileset?: object }} scene
|
||||
* @returns {{ collection: object | null, kind: 'globe' | 'tileset' | 'none' }}
|
||||
*/
|
||||
export function resolveImageryHost({ viewer, tileset } = {}) {
|
||||
if (viewer?.scene?.globe?.show === true && viewer.imageryLayers) {
|
||||
return { collection: viewer.imageryLayers, kind: 'globe' };
|
||||
}
|
||||
if (tileset?.imageryLayers && !tileset.isDestroyed?.()) {
|
||||
return { collection: tileset.imageryLayers, kind: 'tileset' };
|
||||
}
|
||||
return { collection: null, kind: 'none' };
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { NO_IMAGERY_HOST, resolveImageryHost } from './imageryHost.js';
|
||||
|
||||
test('a shown globe hosts imagery on the viewer collection', () => {
|
||||
const imageryLayers = { id: 'globe' };
|
||||
const host = resolveImageryHost({
|
||||
viewer: { scene: { globe: { show: true } }, imageryLayers },
|
||||
tileset: { imageryLayers: { id: 'tiles' } },
|
||||
});
|
||||
assert.deepEqual(host, { collection: imageryLayers, kind: 'globe' });
|
||||
});
|
||||
|
||||
test('a hidden globe hands imagery to the tileset collection', () => {
|
||||
const imageryLayers = { id: 'tiles' };
|
||||
const host = resolveImageryHost({
|
||||
viewer: { scene: { globe: { show: false } }, imageryLayers: {} },
|
||||
tileset: { imageryLayers, isDestroyed: () => false },
|
||||
});
|
||||
assert.deepEqual(host, { collection: imageryLayers, kind: 'tileset' });
|
||||
});
|
||||
|
||||
test('no globe and no tileset means nowhere to drape', () => {
|
||||
assert.deepEqual(
|
||||
resolveImageryHost({
|
||||
viewer: { scene: { globe: { show: false } }, imageryLayers: {} },
|
||||
tileset: null,
|
||||
}),
|
||||
{ collection: null, kind: 'none' },
|
||||
);
|
||||
assert.deepEqual(resolveImageryHost({}), { collection: null, kind: 'none' });
|
||||
assert.deepEqual(
|
||||
resolveImageryHost({
|
||||
viewer: { scene: { globe: { show: false } } },
|
||||
tileset: { imageryLayers: {}, isDestroyed: () => true },
|
||||
}),
|
||||
{ collection: null, kind: 'none' },
|
||||
);
|
||||
assert.match(NO_IMAGERY_HOST, /globe map/);
|
||||
});
|
||||
@@ -138,6 +138,35 @@ test('visual parameters, explicit empty layers, and panel state are v2-only', ()
|
||||
assert.equal(legacy.panelState, null);
|
||||
});
|
||||
|
||||
// The panel chrome shares every rail panel, so the link registry must know
|
||||
// each one: a collapsed Recent Imagery panel used to restore expanded because
|
||||
// the encoder had no token for it.
|
||||
test('a collapsed Recent Imagery panel survives the share-link round trip beside the older panels', () => {
|
||||
const manager = makeManager();
|
||||
manager.setPanelStateProvider(() => ({ specs: [
|
||||
{ id: 'cctv-panel', collapsed: false },
|
||||
{ id: 'recent-imagery-panel', collapsed: true },
|
||||
{ id: 'global-context-panel', collapsed: true },
|
||||
] }));
|
||||
clearTimeout(manager._debounceTimer);
|
||||
manager._updateHash();
|
||||
const params = new URLSearchParams(window.location.hash.slice(1));
|
||||
assert.equal(params.get('ui'), 'v.c.0_i.c.1_g.c.1');
|
||||
const restored = makeManager(`#v=2&lat=10&lon=20&ui=${params.get('ui')}`)
|
||||
.parseInitialHash();
|
||||
assert.deepEqual(restored.panelState, { specs: [
|
||||
{ id: 'cctv-panel', collapsed: false, pinned: null },
|
||||
{ id: 'recent-imagery-panel', collapsed: true, pinned: null },
|
||||
{ id: 'global-context-panel', collapsed: true, pinned: null },
|
||||
] });
|
||||
// A link written before the token existed decodes exactly as it did.
|
||||
const older = makeManager('#v=2&lat=10&lon=20&ui=v.c.1_g.c.0').parseInitialHash();
|
||||
assert.deepEqual(older.panelState, { specs: [
|
||||
{ id: 'cctv-panel', collapsed: true, pinned: null },
|
||||
{ id: 'global-context-panel', collapsed: false, pinned: null },
|
||||
] });
|
||||
});
|
||||
|
||||
test('camera-only, partial, and malformed panel shares remain valid incoming state', () => {
|
||||
const cameraOnly = makeManager('#lat=10&lon=20').parseInitialHash();
|
||||
assert.ok(cameraOnly);
|
||||
|
||||
@@ -40,6 +40,7 @@ const SHARE_PANEL_STATE_REGISTRY = Object.freeze([
|
||||
{ id: 'location-bar', token: 'l', pinnable: true },
|
||||
{ id: 'data-panel', token: 'd', pinnable: false },
|
||||
{ id: 'cctv-panel', token: 'v', pinnable: false },
|
||||
{ id: 'recent-imagery-panel', token: 'i', pinnable: false },
|
||||
{ id: 'radio-panel', token: 'r', pinnable: false },
|
||||
{ id: 'scene-panel', token: 's', pinnable: false },
|
||||
{ id: 'global-context-panel', token: 'g', pinnable: false },
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
/**
|
||||
* Box selection for the Recent Imagery layer: press on the world, drag, and
|
||||
* release to hand the layer a degrees box. Follows the draw tool's ownership
|
||||
* rules — the pointer is claimed for the session, the viewer's own click
|
||||
* actions are borrowed and given back, and `destroy()` returns everything —
|
||||
* and freezes the camera only while a drag is in progress.
|
||||
*/
|
||||
import * as Cesium from 'cesium';
|
||||
import { pickWorldFromScreen } from '../annotations/annotationResolver.js';
|
||||
import {
|
||||
claimPointer,
|
||||
pointerOwner,
|
||||
releasePointer,
|
||||
} from '../data/inputOwnership.js';
|
||||
import { validateBox } from '../layers/recentImagery/model.js';
|
||||
|
||||
/** The id this tool claims the pointer under. */
|
||||
export const IMAGERY_BOX_POINTER_OWNER = 'recent-imagery-box';
|
||||
|
||||
/**
|
||||
* Wire the box tool.
|
||||
* @param {{ viewer: object, onBox: (box: object) => void, onCancel?: (reason: string, message?: string, box?: object) => void, onActive?: (active: boolean) => void, onEscape?: () => boolean, cesium?: object, pickWorld?: Function, documentRef?: object }} deps
|
||||
* `onEscape` runs before the tool cancels on Escape; returning `true` claims
|
||||
* the key (it is still consumed) and leaves the tool armed.
|
||||
* @returns {{ start: () => boolean, cancel: (reason?: string) => void, isActive: () => boolean, destroy: () => Promise<void> } | null}
|
||||
*/
|
||||
export function initImageryBoxTool({
|
||||
viewer,
|
||||
onBox,
|
||||
onCancel,
|
||||
onActive,
|
||||
onEscape,
|
||||
cesium = Cesium,
|
||||
pickWorld = pickWorldFromScreen,
|
||||
documentRef = globalThis.document,
|
||||
} = {}) {
|
||||
if (!viewer?.scene || typeof onBox !== 'function') return null;
|
||||
|
||||
let active = false;
|
||||
let destroyed = false;
|
||||
/** Drag corners in degrees; `anchor` is null while no drag is live. */
|
||||
let anchor = null;
|
||||
let corner = null;
|
||||
let handler = null;
|
||||
let lease = null;
|
||||
let savedSingleClick = null;
|
||||
let savedDoubleClick = null;
|
||||
let savedCameraInputs = null;
|
||||
|
||||
const dragBox = () =>
|
||||
anchor && {
|
||||
west: Math.min(anchor.lon, corner.lon),
|
||||
south: Math.min(anchor.lat, corner.lat),
|
||||
east: Math.max(anchor.lon, corner.lon),
|
||||
north: Math.max(anchor.lat, corner.lat),
|
||||
};
|
||||
|
||||
const dataSource = new cesium.CustomDataSource(
|
||||
'gev-recent-imagery-box-preview',
|
||||
);
|
||||
let attaching = Promise.resolve(viewer.dataSources.add(dataSource)).catch(
|
||||
() => null,
|
||||
);
|
||||
const stroke = cesium.Color.fromCssColorString('#8be9ff');
|
||||
const previewRectangle = () => {
|
||||
const box = dragBox();
|
||||
return box
|
||||
? cesium.Rectangle.fromDegrees(box.west, box.south, box.east, box.north)
|
||||
: undefined;
|
||||
};
|
||||
const previewOutline = () => {
|
||||
const box = dragBox();
|
||||
if (!box) return [];
|
||||
return cesium.Cartesian3.fromDegreesArray([
|
||||
box.west,
|
||||
box.south,
|
||||
box.east,
|
||||
box.south,
|
||||
box.east,
|
||||
box.north,
|
||||
box.west,
|
||||
box.north,
|
||||
box.west,
|
||||
box.south,
|
||||
]);
|
||||
};
|
||||
const fill = dataSource.entities.add({
|
||||
show: false,
|
||||
rectangle: {
|
||||
coordinates: new cesium.CallbackProperty(previewRectangle, false),
|
||||
material: stroke.withAlpha(0.12),
|
||||
classificationType: cesium.ClassificationType.BOTH,
|
||||
},
|
||||
});
|
||||
// A rectangle graphic cannot draw a dashed edge on the ground, so the
|
||||
// outline is its own draped polyline traced round the same corners.
|
||||
const outline = dataSource.entities.add({
|
||||
show: false,
|
||||
polyline: {
|
||||
positions: new cesium.CallbackProperty(previewOutline, false),
|
||||
width: 2,
|
||||
clampToGround: true,
|
||||
classificationType: cesium.ClassificationType.BOTH,
|
||||
material: new cesium.PolylineDashMaterialProperty({
|
||||
color: stroke.withAlpha(0.95),
|
||||
dashLength: 12,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const requestRender = () => viewer.scene.requestRender?.();
|
||||
const setPreview = (visible) => {
|
||||
fill.show = visible;
|
||||
outline.show = visible;
|
||||
requestRender();
|
||||
};
|
||||
const worldAt = (position) => {
|
||||
if (!position) return null;
|
||||
const canvas = viewer.scene.canvas;
|
||||
const w = canvas?.clientWidth || canvas?.width || 1;
|
||||
const h = canvas?.clientHeight || canvas?.height || 1;
|
||||
const point = pickWorld(viewer, position.x / w, position.y / h);
|
||||
const lon = Number(point?.lon);
|
||||
const lat = Number(point?.lat);
|
||||
return Number.isFinite(lon) && Number.isFinite(lat) ? { lon, lat } : null;
|
||||
};
|
||||
const cameraController = () => viewer.scene.screenSpaceCameraController;
|
||||
|
||||
function freezeCamera() {
|
||||
const controller = cameraController();
|
||||
if (!controller || savedCameraInputs !== null) return;
|
||||
savedCameraInputs = controller.enableInputs;
|
||||
controller.enableInputs = false;
|
||||
}
|
||||
function thawCamera() {
|
||||
const controller = cameraController();
|
||||
if (controller && savedCameraInputs !== null)
|
||||
controller.enableInputs = savedCameraInputs;
|
||||
savedCameraInputs = null;
|
||||
}
|
||||
|
||||
function endDrag() {
|
||||
anchor = null;
|
||||
corner = null;
|
||||
thawCamera();
|
||||
setPreview(false);
|
||||
}
|
||||
|
||||
const onDown = (event) => {
|
||||
if (!active || destroyed || anchor) return;
|
||||
const point = worldAt(event.position);
|
||||
if (!point) {
|
||||
onCancel?.('sky', 'Press on the ground, not the sky');
|
||||
return;
|
||||
}
|
||||
anchor = point;
|
||||
corner = point;
|
||||
freezeCamera();
|
||||
setPreview(true);
|
||||
};
|
||||
const onMove = (event) => {
|
||||
const point = anchor && !destroyed && worldAt(event.endPosition);
|
||||
if (!point) return;
|
||||
corner = point;
|
||||
requestRender();
|
||||
};
|
||||
const onUp = (event) => {
|
||||
if (!anchor || destroyed) return;
|
||||
corner = worldAt(event.position) || corner;
|
||||
const box = dragBox();
|
||||
const result = validateBox(box);
|
||||
if (result.reason === 'degenerate') result.message = 'Drag to size the box';
|
||||
endDrag();
|
||||
setActive(false);
|
||||
if (result.ok) onBox(result.box);
|
||||
else onCancel?.(result.reason, result.message, box);
|
||||
};
|
||||
// Escape is taken from a document capture listener, ahead of every other
|
||||
// handler, so the interceptor runs first: when it claims the key (the
|
||||
// panel exits a live comparison), the tool stays armed and the key still
|
||||
// goes no further; otherwise Escape cancels the session.
|
||||
const onKey = (event) => {
|
||||
if (!active || destroyed || event.key !== 'Escape') return;
|
||||
event.preventDefault?.();
|
||||
event.stopImmediatePropagation?.();
|
||||
if (onEscape?.() === true) return;
|
||||
cancel('escape');
|
||||
};
|
||||
|
||||
function bindScene() {
|
||||
if (handler) return;
|
||||
handler = new cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
|
||||
handler.setInputAction(onDown, cesium.ScreenSpaceEventType.LEFT_DOWN);
|
||||
handler.setInputAction(onMove, cesium.ScreenSpaceEventType.MOUSE_MOVE);
|
||||
handler.setInputAction(onUp, cesium.ScreenSpaceEventType.LEFT_UP);
|
||||
const stock = viewer.screenSpaceEventHandler;
|
||||
if (stock) {
|
||||
savedSingleClick =
|
||||
stock.getInputAction(cesium.ScreenSpaceEventType.LEFT_CLICK) || null;
|
||||
savedDoubleClick =
|
||||
stock.getInputAction(cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK) ||
|
||||
null;
|
||||
stock.removeInputAction(cesium.ScreenSpaceEventType.LEFT_CLICK);
|
||||
stock.removeInputAction(cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK);
|
||||
}
|
||||
documentRef?.addEventListener?.('keydown', onKey, true);
|
||||
documentRef?.body?.classList?.add?.('gev-imagery-box');
|
||||
}
|
||||
|
||||
function releaseScene() {
|
||||
if (handler) {
|
||||
handler.destroy();
|
||||
handler = null;
|
||||
}
|
||||
const stock = viewer.screenSpaceEventHandler;
|
||||
if (stock && savedSingleClick) {
|
||||
stock.setInputAction(
|
||||
savedSingleClick,
|
||||
cesium.ScreenSpaceEventType.LEFT_CLICK,
|
||||
);
|
||||
}
|
||||
if (stock && savedDoubleClick) {
|
||||
stock.setInputAction(
|
||||
savedDoubleClick,
|
||||
cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK,
|
||||
);
|
||||
}
|
||||
savedSingleClick = null;
|
||||
savedDoubleClick = null;
|
||||
documentRef?.removeEventListener?.('keydown', onKey, true);
|
||||
documentRef?.body?.classList?.remove?.('gev-imagery-box');
|
||||
}
|
||||
|
||||
function setActive(next) {
|
||||
if (next === active) return active;
|
||||
if (next) {
|
||||
lease = claimPointer(IMAGERY_BOX_POINTER_OWNER);
|
||||
if (!lease) {
|
||||
onCancel?.(
|
||||
'pointer-busy',
|
||||
`${pointerOwner()} is using the pointer — close it first`,
|
||||
);
|
||||
return active;
|
||||
}
|
||||
active = true;
|
||||
bindScene();
|
||||
} else {
|
||||
if (anchor) endDrag();
|
||||
releaseScene();
|
||||
releasePointer(lease);
|
||||
lease = null;
|
||||
active = false;
|
||||
}
|
||||
onActive?.(active);
|
||||
return active;
|
||||
}
|
||||
|
||||
function cancel(reason = 'cancel') {
|
||||
if (!active || destroyed) return;
|
||||
setActive(false);
|
||||
onCancel?.(reason);
|
||||
}
|
||||
|
||||
return {
|
||||
/** Begin a box session. False when the pointer is held elsewhere. */
|
||||
start() {
|
||||
return !destroyed && setActive(true);
|
||||
},
|
||||
cancel,
|
||||
isActive() {
|
||||
return active;
|
||||
},
|
||||
/** Returns everything; resolves once the preview is detached. */
|
||||
destroy() {
|
||||
if (destroyed) return attaching;
|
||||
if (active) setActive(false);
|
||||
destroyed = true;
|
||||
releaseScene();
|
||||
releasePointer(lease);
|
||||
lease = null;
|
||||
thawCamera();
|
||||
dataSource.entities.removeAll();
|
||||
attaching = attaching.then(() => {
|
||||
try {
|
||||
viewer.dataSources.remove(dataSource, true);
|
||||
} catch {
|
||||
/* viewer already disposed */
|
||||
}
|
||||
});
|
||||
return attaching;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
claimPointer,
|
||||
pointerOwner,
|
||||
releasePointer,
|
||||
resetPointerOwnership,
|
||||
} from '../data/inputOwnership.js';
|
||||
import {
|
||||
IMAGERY_BOX_POINTER_OWNER,
|
||||
initImageryBoxTool,
|
||||
} from './imageryBoxTool.js';
|
||||
import { boxToolFakes } from '../layers/recentImagery/testDoubles.mjs';
|
||||
|
||||
function fixture(options = {}) {
|
||||
resetPointerOwnership();
|
||||
const fakes = boxToolFakes();
|
||||
const boxes = [];
|
||||
const cancels = [];
|
||||
const actives = [];
|
||||
const tool = initImageryBoxTool({
|
||||
viewer: fakes.viewer,
|
||||
cesium: fakes.cesium,
|
||||
pickWorld: fakes.pickWorld,
|
||||
documentRef: fakes.documentRef,
|
||||
onBox: (box) => boxes.push(box),
|
||||
onCancel: (reason, message) => cancels.push([reason, message]),
|
||||
onActive: (active) => actives.push(active),
|
||||
...options,
|
||||
});
|
||||
const camera = fakes.viewer.scene.screenSpaceCameraController;
|
||||
const stockAction = (type) =>
|
||||
fakes.viewer.screenSpaceEventHandler.getInputAction(fakes.types[type]);
|
||||
const preview = () =>
|
||||
fakes.viewer.dataSources.sources[0]?.entities.values.every((e) => e.show);
|
||||
return {
|
||||
...fakes,
|
||||
tool,
|
||||
boxes,
|
||||
cancels,
|
||||
actives,
|
||||
camera,
|
||||
stockAction,
|
||||
preview,
|
||||
};
|
||||
}
|
||||
|
||||
test('a session claims the pointer, borrows the stock clicks, freezes the camera only mid-drag and returns everything with the box', async () => {
|
||||
const f = fixture();
|
||||
assert.equal(f.tool.start(), true);
|
||||
assert.equal(pointerOwner(), IMAGERY_BOX_POINTER_OWNER);
|
||||
assert.equal(f.stockAction('LEFT_CLICK'), undefined);
|
||||
assert.equal(f.stockAction('LEFT_DOUBLE_CLICK'), undefined);
|
||||
assert.ok(f.documentRef.classes.has('gev-imagery-box'));
|
||||
assert.equal(f.camera.enableInputs, true, 'not frozen before the press');
|
||||
f.fire('LEFT_DOWN', { position: { x: 250, y: 125 } });
|
||||
assert.equal(f.camera.enableInputs, false);
|
||||
assert.equal(f.preview(), true);
|
||||
f.fire('MOUSE_MOVE', { endPosition: { x: 500, y: 250 } });
|
||||
f.fire('MOUSE_MOVE', { endPosition: { x: 500, y: 10 } }); // sky: ignored
|
||||
f.fire('LEFT_UP', { position: { x: 500, y: 250 } });
|
||||
assert.equal(f.boxes.length, 1);
|
||||
const { west, south, east, north } = f.boxes[0];
|
||||
assert.deepEqual(
|
||||
[west, south, east, north].map((v) => Math.round(v * 1e6) / 1e6),
|
||||
[-97.85, 30.3, -97.8, 30.35],
|
||||
);
|
||||
assert.deepEqual(f.cancels, []);
|
||||
assert.equal(f.tool.isActive(), false);
|
||||
assert.equal(pointerOwner(), null);
|
||||
assert.equal(f.stockAction('LEFT_CLICK'), f.originalClick);
|
||||
assert.equal(f.stockAction('LEFT_DOUBLE_CLICK'), f.originalDouble);
|
||||
assert.equal(f.camera.enableInputs, true);
|
||||
assert.equal(f.preview(), false);
|
||||
assert.equal(f.documentRef.classes.has('gev-imagery-box'), false);
|
||||
assert.equal(f.documentRef.listeners.length, 0);
|
||||
assert.equal(f.handler(), null);
|
||||
assert.deepEqual(f.actives, [true, false]);
|
||||
await f.tool.destroy();
|
||||
});
|
||||
|
||||
test('a sky press, a click without a drag and Escape each say why', async () => {
|
||||
const f = fixture();
|
||||
f.tool.start();
|
||||
f.fire('LEFT_DOWN', { position: { x: 250, y: 10 } });
|
||||
assert.deepEqual(f.cancels.at(-1), [
|
||||
'sky',
|
||||
'Press on the ground, not the sky',
|
||||
]);
|
||||
assert.equal(f.tool.isActive(), true, 'a sky press keeps the session');
|
||||
f.fire('LEFT_DOWN', { position: { x: 250, y: 125 } });
|
||||
f.fire('LEFT_UP', { position: { x: 250, y: 125 } });
|
||||
assert.deepEqual(f.cancels.at(-1), ['degenerate', 'Drag to size the box']);
|
||||
f.tool.start();
|
||||
assert.equal(f.key('Escape').prevented, true);
|
||||
assert.deepEqual(f.cancels.at(-1), ['escape', undefined]);
|
||||
assert.equal(pointerOwner(), null);
|
||||
await f.tool.destroy();
|
||||
});
|
||||
|
||||
test('an Escape interceptor that claims the key leaves the tool armed; the next Escape cancels', async () => {
|
||||
let claim = true;
|
||||
let asked = 0;
|
||||
const f = fixture({
|
||||
onEscape: () => {
|
||||
asked += 1;
|
||||
return claim;
|
||||
},
|
||||
});
|
||||
f.tool.start();
|
||||
const first = f.key('Escape');
|
||||
assert.equal(asked, 1);
|
||||
assert.equal(
|
||||
first.prevented && first.stopped,
|
||||
true,
|
||||
'the key goes no further',
|
||||
);
|
||||
assert.equal(f.tool.isActive(), true);
|
||||
claim = false;
|
||||
f.key('Escape');
|
||||
assert.deepEqual(f.cancels, [['escape', undefined]]);
|
||||
f.tool.start();
|
||||
f.key('Enter');
|
||||
assert.equal(asked, 2, 'other keys never reach the interceptor');
|
||||
await f.tool.destroy();
|
||||
});
|
||||
|
||||
test('a held pointer refuses the session and nothing is borrowed', async () => {
|
||||
const f = fixture();
|
||||
const lease = claimPointer('draw');
|
||||
assert.equal(f.tool.start(), false);
|
||||
assert.deepEqual(f.cancels, [
|
||||
['pointer-busy', 'draw is using the pointer — close it first'],
|
||||
]);
|
||||
assert.equal(f.stockAction('LEFT_CLICK'), f.originalClick);
|
||||
assert.equal(f.handler(), null);
|
||||
releasePointer(lease);
|
||||
assert.equal(f.tool.start(), true);
|
||||
await f.tool.destroy();
|
||||
});
|
||||
|
||||
test('destroy mid-drag restores the camera, releases the claim and detaches the preview', async () => {
|
||||
const f = fixture();
|
||||
f.tool.start();
|
||||
f.fire('LEFT_DOWN', { position: { x: 250, y: 125 } });
|
||||
assert.equal(f.viewer.dataSources.sources.length, 1);
|
||||
await f.tool.destroy();
|
||||
assert.equal(f.camera.enableInputs, true);
|
||||
assert.equal(pointerOwner(), null);
|
||||
assert.equal(f.viewer.dataSources.sources.length, 0);
|
||||
assert.equal(f.stockAction('LEFT_CLICK'), f.originalClick);
|
||||
assert.equal(f.tool.start(), false);
|
||||
assert.equal(initImageryBoxTool({ viewer: null, onBox() {} }), null);
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
const HANDLE_KEYS = new Set(['ArrowLeft', 'ArrowRight', 'Home', 'End']);
|
||||
|
||||
function clampUnit(value) {
|
||||
return Math.max(0, Math.min(1, Number(value) || 0));
|
||||
}
|
||||
|
||||
function defaultViewportWidth() {
|
||||
return (
|
||||
Number(document.documentElement?.clientWidth) ||
|
||||
Number(globalThis.window?.innerWidth) ||
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
function defaultValueText(beforePercent, afterPercent) {
|
||||
return `A ${beforePercent} percent, B ${afterPercent} percent`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split divider for before/after imagery comparison.
|
||||
*
|
||||
* Owns the DOM line + handle, pointer capture, keyboard, ARIA, a CSS custom
|
||||
* property and scene.splitPosition. `setValue` is the owner's own write and
|
||||
* never echoes through `onChange`; `onChange` reports pointer and keyboard
|
||||
* changes so the owner can mirror them, and `requestRender` fires whenever
|
||||
* the clamped value actually changes. `destroy` is idempotent: it removes the
|
||||
* listeners and line, clears the CSS property and restores the scene split
|
||||
* captured at creation once.
|
||||
*/
|
||||
export function createImagerySplit({
|
||||
scene,
|
||||
parent = document.body,
|
||||
initialValue = 0.5,
|
||||
id,
|
||||
handleClass,
|
||||
cssTarget = document.documentElement,
|
||||
cssProperty,
|
||||
beforeLabel = 'A',
|
||||
afterLabel = 'B',
|
||||
beforeTitle,
|
||||
afterTitle,
|
||||
ariaLabel,
|
||||
formatValueText = defaultValueText,
|
||||
getViewportWidth = defaultViewportWidth,
|
||||
onChange,
|
||||
requestRender,
|
||||
} = {}) {
|
||||
const restoreValue = scene ? (scene.splitPosition ?? null) : null;
|
||||
const line = document.createElement('div');
|
||||
if (id) line.id = id;
|
||||
const before = document.createElement('span');
|
||||
const after = document.createElement('span');
|
||||
const handle = document.createElement('button');
|
||||
before.className = 'before';
|
||||
after.className = 'after';
|
||||
before.textContent = beforeLabel;
|
||||
if (beforeTitle != null) before.title = beforeTitle;
|
||||
after.textContent = afterLabel;
|
||||
if (afterTitle != null) after.title = afterTitle;
|
||||
handle.type = 'button';
|
||||
if (handleClass) handle.className = handleClass;
|
||||
handle.setAttribute('role', 'slider');
|
||||
if (ariaLabel != null) handle.setAttribute('aria-label', ariaLabel);
|
||||
handle.setAttribute('aria-valuemin', '0');
|
||||
handle.setAttribute('aria-valuemax', '100');
|
||||
handle.setAttribute('aria-orientation', 'horizontal');
|
||||
handle.textContent = '↔';
|
||||
|
||||
let value = null;
|
||||
let cssValue = null;
|
||||
let dragging = false;
|
||||
let pointerId = null;
|
||||
let destroyed = false;
|
||||
|
||||
function apply(next, { notify = false, render = true } = {}) {
|
||||
const clamped = clampUnit(next);
|
||||
const changed = clamped !== value;
|
||||
value = clamped;
|
||||
if (scene && scene.splitPosition !== value) scene.splitPosition = value;
|
||||
const css = `${value * 100}%`;
|
||||
if (cssProperty && cssValue !== css) {
|
||||
cssTarget.style.setProperty(cssProperty, css);
|
||||
cssValue = css;
|
||||
}
|
||||
const beforePercent = Math.round(value * 100);
|
||||
handle.setAttribute('aria-valuenow', String(beforePercent));
|
||||
handle.setAttribute(
|
||||
'aria-valuetext',
|
||||
formatValueText(beforePercent, 100 - beforePercent),
|
||||
);
|
||||
if (changed && render) requestRender?.();
|
||||
if (changed && notify) onChange?.(value);
|
||||
return changed;
|
||||
}
|
||||
|
||||
function setFromClientX(clientX) {
|
||||
const viewportWidth = Number(getViewportWidth()) || 0;
|
||||
if (viewportWidth <= 0 || !Number.isFinite(Number(clientX))) return;
|
||||
apply(Number(clientX) / viewportWidth, { notify: true });
|
||||
}
|
||||
|
||||
function handlePointerDown(event) {
|
||||
if (event.button != null && event.button !== 0) return;
|
||||
event.preventDefault?.();
|
||||
dragging = true;
|
||||
pointerId = event.pointerId ?? null;
|
||||
handle.setPointerCapture?.(event.pointerId);
|
||||
setFromClientX(event.clientX);
|
||||
}
|
||||
|
||||
function handlePointerMove(event) {
|
||||
if (!dragging) return;
|
||||
if (pointerId != null && event.pointerId !== pointerId) return;
|
||||
event.preventDefault?.();
|
||||
setFromClientX(event.clientX);
|
||||
}
|
||||
|
||||
function finishPointerDrag(event) {
|
||||
if (!dragging) return;
|
||||
if (pointerId != null && event.pointerId !== pointerId) return;
|
||||
handle.releasePointerCapture?.(event.pointerId);
|
||||
dragging = false;
|
||||
pointerId = null;
|
||||
}
|
||||
|
||||
function handleKeyDown(event) {
|
||||
if (!HANDLE_KEYS.has(event.key)) return;
|
||||
event.preventDefault?.();
|
||||
const step = event.shiftKey ? 0.05 : 0.01;
|
||||
if (event.key === 'Home') apply(0, { notify: true });
|
||||
else if (event.key === 'End') apply(1, { notify: true });
|
||||
else
|
||||
apply(value + (event.key === 'ArrowRight' ? step : -step), {
|
||||
notify: true,
|
||||
});
|
||||
}
|
||||
|
||||
const listeners = [
|
||||
['pointerdown', handlePointerDown],
|
||||
['pointermove', handlePointerMove],
|
||||
['pointerup', finishPointerDrag],
|
||||
['pointercancel', finishPointerDrag],
|
||||
['keydown', handleKeyDown],
|
||||
];
|
||||
for (const [type, listener] of listeners)
|
||||
handle.addEventListener(type, listener);
|
||||
line.append(before, handle, after);
|
||||
parent.appendChild(line);
|
||||
apply(initialValue, { render: false });
|
||||
|
||||
return {
|
||||
getValue() {
|
||||
return value;
|
||||
},
|
||||
setValue(next) {
|
||||
if (destroyed) return false;
|
||||
return apply(next);
|
||||
},
|
||||
setVisible(visible) {
|
||||
line.hidden = !visible;
|
||||
},
|
||||
destroy() {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
if (dragging && pointerId != null)
|
||||
handle.releasePointerCapture?.(pointerId);
|
||||
dragging = false;
|
||||
pointerId = null;
|
||||
for (const [type, listener] of listeners)
|
||||
handle.removeEventListener?.(type, listener);
|
||||
line.remove();
|
||||
if (cssProperty) cssTarget.style.removeProperty(cssProperty);
|
||||
if (scene && restoreValue != null) scene.splitPosition = restoreValue;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createImagerySplit } from './imagerySplit.js';
|
||||
|
||||
class FakeElement {
|
||||
constructor(tagName) {
|
||||
this.tagName = tagName;
|
||||
this.children = [];
|
||||
this.removed = false;
|
||||
this.captured = [];
|
||||
this.released = [];
|
||||
this._attributes = new Map();
|
||||
this._listeners = new Map();
|
||||
}
|
||||
setAttribute(name, value) {
|
||||
this._attributes.set(name, value);
|
||||
}
|
||||
getAttribute(name) {
|
||||
return this._attributes.get(name) ?? null;
|
||||
}
|
||||
addEventListener(type, handler) {
|
||||
this._listeners.set(type, handler);
|
||||
}
|
||||
removeEventListener(type, handler) {
|
||||
if (this._listeners.get(type) === handler) this._listeners.delete(type);
|
||||
}
|
||||
dispatch(type, event = {}) {
|
||||
return this._listeners.get(type)?.(event);
|
||||
}
|
||||
setPointerCapture(id) {
|
||||
this.captured.push(id);
|
||||
}
|
||||
releasePointerCapture(id) {
|
||||
this.released.push(id);
|
||||
}
|
||||
append(...children) {
|
||||
this.children.push(...children);
|
||||
}
|
||||
appendChild(child) {
|
||||
this.children.push(child);
|
||||
return child;
|
||||
}
|
||||
remove() {
|
||||
this.removed = true;
|
||||
}
|
||||
}
|
||||
|
||||
function fixture({ initialValue = 0.5, splitPosition = 0.37 } = {}) {
|
||||
const body = new FakeElement('body');
|
||||
const cssCalls = [];
|
||||
const changes = [];
|
||||
let renders = 0;
|
||||
const scene = { splitPosition };
|
||||
const cssTarget = {
|
||||
style: {
|
||||
setProperty(name, value) {
|
||||
cssCalls.push(['set', name, value]);
|
||||
},
|
||||
removeProperty(name) {
|
||||
cssCalls.push(['remove', name]);
|
||||
},
|
||||
},
|
||||
};
|
||||
const previous = globalThis.document;
|
||||
globalThis.document = {
|
||||
body,
|
||||
createElement: (tagName) => new FakeElement(tagName),
|
||||
documentElement: cssTarget,
|
||||
};
|
||||
const split = createImagerySplit({
|
||||
scene,
|
||||
parent: body,
|
||||
initialValue,
|
||||
id: 'split-line',
|
||||
handleClass: 'split-handle',
|
||||
cssTarget,
|
||||
cssProperty: '--split',
|
||||
beforeTitle: 'Before',
|
||||
afterTitle: 'After',
|
||||
ariaLabel: 'Divider',
|
||||
getViewportWidth: () => 1000,
|
||||
onChange: (value) => changes.push(value),
|
||||
requestRender: () => {
|
||||
renders += 1;
|
||||
},
|
||||
});
|
||||
globalThis.document = previous;
|
||||
const line = body.children[0];
|
||||
const handle = line.children.find((child) => child.tagName === 'button');
|
||||
return {
|
||||
split,
|
||||
scene,
|
||||
body,
|
||||
line,
|
||||
handle,
|
||||
cssCalls,
|
||||
changes,
|
||||
renders: () => renders,
|
||||
};
|
||||
}
|
||||
|
||||
test('the split builds its line and applies the initial value without notifying', () => {
|
||||
const env = fixture({ initialValue: 0.62 });
|
||||
assert.equal(env.line.id, 'split-line');
|
||||
assert.equal(env.line.children[0].textContent, 'A');
|
||||
assert.equal(env.line.children[0].title, 'Before');
|
||||
assert.equal(env.line.children[2].textContent, 'B');
|
||||
assert.equal(env.line.children[2].title, 'After');
|
||||
assert.equal(env.handle.className, 'split-handle');
|
||||
assert.equal(env.handle.getAttribute('role'), 'slider');
|
||||
assert.equal(env.handle.getAttribute('aria-label'), 'Divider');
|
||||
assert.equal(env.handle.getAttribute('aria-valuenow'), '62');
|
||||
assert.equal(
|
||||
env.handle.getAttribute('aria-valuetext'),
|
||||
'A 62 percent, B 38 percent',
|
||||
);
|
||||
assert.equal(env.scene.splitPosition, 0.62);
|
||||
assert.deepEqual(env.cssCalls, [['set', '--split', '62%']]);
|
||||
assert.deepEqual(env.changes, []);
|
||||
assert.equal(env.renders(), 0);
|
||||
assert.equal(env.split.getValue(), 0.62);
|
||||
});
|
||||
|
||||
test('keyboard steps 1 percent, 5 percent with Shift, and jumps to Home and End', () => {
|
||||
const env = fixture();
|
||||
const key = (key, shiftKey = false) => {
|
||||
let prevented = false;
|
||||
env.handle.dispatch('keydown', {
|
||||
key,
|
||||
shiftKey,
|
||||
preventDefault() {
|
||||
prevented = true;
|
||||
},
|
||||
});
|
||||
return prevented;
|
||||
};
|
||||
assert.equal(key('ArrowRight'), true);
|
||||
assert.equal(env.scene.splitPosition, 0.51);
|
||||
assert.equal(key('ArrowLeft', true), true);
|
||||
assert.equal(env.scene.splitPosition, 0.46);
|
||||
assert.equal(key('Home'), true);
|
||||
assert.equal(env.scene.splitPosition, 0);
|
||||
assert.equal(env.handle.getAttribute('aria-valuenow'), '0');
|
||||
assert.equal(key('End'), true);
|
||||
assert.equal(env.scene.splitPosition, 1);
|
||||
assert.equal(env.handle.getAttribute('aria-valuenow'), '100');
|
||||
assert.equal(key('Enter'), false, 'other keys are ignored and not prevented');
|
||||
assert.equal(env.scene.splitPosition, 1);
|
||||
assert.deepEqual(env.changes, [0.51, 0.46, 0, 1]);
|
||||
});
|
||||
|
||||
test('values clamp to the unit range and only real changes notify or render', () => {
|
||||
const env = fixture();
|
||||
assert.equal(env.split.setValue(1.4), true);
|
||||
assert.equal(env.split.getValue(), 1);
|
||||
assert.equal(env.scene.splitPosition, 1);
|
||||
assert.equal(env.split.setValue(1), false);
|
||||
assert.equal(env.split.setValue(-3), true);
|
||||
assert.equal(env.split.getValue(), 0);
|
||||
assert.equal(
|
||||
env.split.setValue('nonsense'),
|
||||
false,
|
||||
'non-numeric input clamps to 0',
|
||||
);
|
||||
assert.equal(env.renders(), 2, 'render only when the clamped value moved');
|
||||
assert.deepEqual(
|
||||
env.changes,
|
||||
[],
|
||||
'setValue is the owner writing; it never echoes',
|
||||
);
|
||||
env.handle.dispatch('keydown', { key: 'Home', preventDefault() {} });
|
||||
env.handle.dispatch('keydown', { key: 'ArrowLeft', preventDefault() {} });
|
||||
assert.deepEqual(
|
||||
env.changes,
|
||||
[],
|
||||
'interaction that cannot move the value stays silent',
|
||||
);
|
||||
env.handle.dispatch('keydown', { key: 'End', preventDefault() {} });
|
||||
env.handle.dispatch('keydown', { key: 'End', preventDefault() {} });
|
||||
assert.deepEqual(env.changes, [1]);
|
||||
assert.equal(env.renders(), 3);
|
||||
assert.deepEqual(env.cssCalls, [
|
||||
['set', '--split', '50%'],
|
||||
['set', '--split', '100%'],
|
||||
['set', '--split', '0%'],
|
||||
['set', '--split', '100%'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('pointer drags follow the primary pointer only', () => {
|
||||
const env = fixture();
|
||||
env.handle.dispatch('pointerdown', {
|
||||
button: 2,
|
||||
clientX: 900,
|
||||
pointerId: 1,
|
||||
preventDefault() {},
|
||||
});
|
||||
assert.equal(
|
||||
env.scene.splitPosition,
|
||||
0.5,
|
||||
'non-primary buttons never start a drag',
|
||||
);
|
||||
env.handle.dispatch('pointermove', {
|
||||
clientX: 900,
|
||||
pointerId: 1,
|
||||
preventDefault() {},
|
||||
});
|
||||
assert.equal(
|
||||
env.scene.splitPosition,
|
||||
0.5,
|
||||
'moves without a drag are ignored',
|
||||
);
|
||||
env.handle.dispatch('pointerdown', {
|
||||
button: 0,
|
||||
clientX: 250,
|
||||
pointerId: 4,
|
||||
preventDefault() {},
|
||||
});
|
||||
assert.equal(env.scene.splitPosition, 0.25);
|
||||
assert.deepEqual(env.handle.captured, [4]);
|
||||
env.handle.dispatch('pointermove', {
|
||||
clientX: 900,
|
||||
pointerId: 9,
|
||||
preventDefault() {},
|
||||
});
|
||||
assert.equal(
|
||||
env.scene.splitPosition,
|
||||
0.25,
|
||||
'a different pointer id is ignored',
|
||||
);
|
||||
env.handle.dispatch('pointerup', { pointerId: 9 });
|
||||
env.handle.dispatch('pointermove', {
|
||||
clientX: 750,
|
||||
pointerId: 4,
|
||||
preventDefault() {},
|
||||
});
|
||||
assert.equal(
|
||||
env.scene.splitPosition,
|
||||
0.75,
|
||||
'the drag survives a foreign pointerup',
|
||||
);
|
||||
env.handle.dispatch('pointermove', {
|
||||
clientX: Number.NaN,
|
||||
pointerId: 4,
|
||||
preventDefault() {},
|
||||
});
|
||||
assert.equal(
|
||||
env.scene.splitPosition,
|
||||
0.75,
|
||||
'unusable coordinates are ignored',
|
||||
);
|
||||
env.handle.dispatch('pointerup', { pointerId: 4 });
|
||||
assert.deepEqual(env.handle.released, [4]);
|
||||
env.handle.dispatch('pointermove', {
|
||||
clientX: 100,
|
||||
pointerId: 4,
|
||||
preventDefault() {},
|
||||
});
|
||||
assert.equal(env.scene.splitPosition, 0.75, 'the drag ended');
|
||||
assert.deepEqual(env.changes, [0.25, 0.75]);
|
||||
});
|
||||
|
||||
test('visibility toggles the line without touching the value', () => {
|
||||
const env = fixture();
|
||||
env.split.setVisible(false);
|
||||
assert.equal(env.line.hidden, true);
|
||||
env.split.setVisible(true);
|
||||
assert.equal(env.line.hidden, false);
|
||||
assert.equal(env.split.getValue(), 0.5);
|
||||
});
|
||||
|
||||
test('destroy removes the line, clears the CSS property, restores the scene split once, and is idempotent', () => {
|
||||
const env = fixture({ initialValue: 0.8, splitPosition: 0.37 });
|
||||
env.handle.dispatch('pointerdown', {
|
||||
button: 0,
|
||||
clientX: 100,
|
||||
pointerId: 2,
|
||||
preventDefault() {},
|
||||
});
|
||||
assert.equal(env.scene.splitPosition, 0.1);
|
||||
env.split.destroy();
|
||||
assert.equal(env.line.removed, true);
|
||||
assert.deepEqual(
|
||||
env.handle.released,
|
||||
[2],
|
||||
'an in-flight drag releases its capture',
|
||||
);
|
||||
assert.equal(env.cssCalls.at(-1)[0], 'remove');
|
||||
assert.equal(env.scene.splitPosition, 0.37);
|
||||
assert.equal(env.handle._listeners.size, 0);
|
||||
env.scene.splitPosition = 0.9;
|
||||
env.split.destroy();
|
||||
assert.equal(
|
||||
env.scene.splitPosition,
|
||||
0.9,
|
||||
'a second destroy does not restore again',
|
||||
);
|
||||
assert.equal(env.cssCalls.filter(([kind]) => kind === 'remove').length, 1);
|
||||
assert.equal(
|
||||
env.split.setValue(0.2),
|
||||
false,
|
||||
'a destroyed split ignores writes',
|
||||
);
|
||||
assert.equal(env.scene.splitPosition, 0.9);
|
||||
env.handle.dispatch('keydown', { key: 'End', preventDefault() {} });
|
||||
assert.deepEqual(env.changes, [0.1], 'listeners are gone');
|
||||
});
|
||||
+28
-1
@@ -31,11 +31,12 @@ const PANEL_GROUPS = [
|
||||
},
|
||||
{
|
||||
label: 'Cameras',
|
||||
ids: ['cctv', 'alpr-cameras'],
|
||||
ids: ['cctv', 'recent-imagery'],
|
||||
},
|
||||
{
|
||||
label: 'Infrastructure',
|
||||
ids: [
|
||||
'alpr-cameras',
|
||||
'military-installations',
|
||||
'local-datacenters',
|
||||
'telegeography-submarine-cables',
|
||||
@@ -126,6 +127,8 @@ export class LayerPanel {
|
||||
this._removers = [];
|
||||
this._destroyed = false;
|
||||
this._cancelRowControlsRefresh = null;
|
||||
this._recentImageryFactory = null;
|
||||
this._recentImageryPanel = null;
|
||||
}
|
||||
mount(container) {
|
||||
if (this._destroyed) return;
|
||||
@@ -138,8 +141,29 @@ export class LayerPanel {
|
||||
container?.ownerDocument?.getElementById?.('weather-panel-body'),
|
||||
setLayerParams: this.setLayerParams,
|
||||
});
|
||||
this._mountRecentImagery();
|
||||
this._renderToggles();
|
||||
}
|
||||
/**
|
||||
* Host the Recent Imagery readout in its rail body, like the weather
|
||||
* readout. The application supplies the factory once the layer, viewer and
|
||||
* box tool exist; a remount rebuilds the readout in place.
|
||||
* @param {((container: HTMLElement) => { destroy: () => void } | null) | null} factory
|
||||
*/
|
||||
attachRecentImagery(factory) {
|
||||
if (this._destroyed) return;
|
||||
this._recentImageryFactory = typeof factory === 'function' ? factory : null;
|
||||
this._mountRecentImagery();
|
||||
}
|
||||
_mountRecentImagery() {
|
||||
this._recentImageryPanel?.destroy();
|
||||
this._recentImageryPanel = null;
|
||||
const container = this._toggleContainer?.ownerDocument?.getElementById?.(
|
||||
'recent-imagery-panel-body',
|
||||
);
|
||||
if (container && this._recentImageryFactory)
|
||||
this._recentImageryPanel = this._recentImageryFactory(container) || null;
|
||||
}
|
||||
_bind(element, type, listener) {
|
||||
element.addEventListener(type, listener);
|
||||
this._removers.push(() => element.removeEventListener(type, listener));
|
||||
@@ -156,6 +180,9 @@ export class LayerPanel {
|
||||
this._releaseBindings();
|
||||
this._weatherPanel?.destroy();
|
||||
this._weatherPanel = null;
|
||||
this._recentImageryPanel?.destroy();
|
||||
this._recentImageryPanel = null;
|
||||
this._recentImageryFactory = null;
|
||||
this._toggleContainer = null;
|
||||
}
|
||||
_renderToggles() {
|
||||
|
||||
@@ -174,3 +174,56 @@ test('readout rows contain only toggles and metadata; ordinary rows retain contr
|
||||
globalThis.document = previousDocument;
|
||||
}
|
||||
});
|
||||
|
||||
test('the Recent Imagery readout mounts in its rail body like the weather readout and is rebuilt or released with the panel', async () => {
|
||||
const { LayerPanel } = await import('./layerPanel.js');
|
||||
const { railFixture } = await import('./railTestFixture.mjs');
|
||||
const f = railFixture();
|
||||
const body = f.document.createElement('div');
|
||||
f.document.getElementById = (id) =>
|
||||
id === 'recent-imagery-panel-body' ? body : null;
|
||||
const previousDocument = globalThis.document;
|
||||
globalThis.document = f.document;
|
||||
const mounted = [];
|
||||
const factory = (container) => {
|
||||
const readout = {
|
||||
container,
|
||||
destroyed: false,
|
||||
destroy() {
|
||||
this.destroyed = true;
|
||||
},
|
||||
};
|
||||
mounted.push(readout);
|
||||
return readout;
|
||||
};
|
||||
const panel = new LayerPanel({
|
||||
getLayers: () => [],
|
||||
isEnabled: () => false,
|
||||
setEnabled() {},
|
||||
setLayerParams() {},
|
||||
hasRowControls: () => false,
|
||||
subscribeRowControls() {},
|
||||
getRowControls: () => null,
|
||||
});
|
||||
try {
|
||||
panel.attachRecentImagery(factory);
|
||||
assert.equal(mounted.length, 0, 'no body before the panel mounts');
|
||||
panel.mount(f.container);
|
||||
assert.equal(mounted.length, 1);
|
||||
assert.equal(mounted[0].container, body);
|
||||
panel.mount(f.container);
|
||||
assert.equal(mounted[0].destroyed, true, 'a remount rebuilds it');
|
||||
assert.equal(mounted.length, 2);
|
||||
panel.attachRecentImagery(null);
|
||||
assert.equal(mounted[1].destroyed, true);
|
||||
panel.attachRecentImagery(factory);
|
||||
assert.equal(mounted.length, 3);
|
||||
panel.destroy();
|
||||
assert.equal(mounted[2].destroyed, true);
|
||||
panel.attachRecentImagery(factory);
|
||||
assert.equal(mounted.length, 3, 'a destroyed panel mounts nothing');
|
||||
} finally {
|
||||
panel.destroy();
|
||||
globalThis.document = previousDocument;
|
||||
}
|
||||
});
|
||||
@@ -12,6 +12,7 @@ const SHARE_PANEL_STATE_SPECS = Object.freeze([
|
||||
{ id: 'data-panel' },
|
||||
{ id: 'cctv-panel' },
|
||||
{ id: 'weather-panel' },
|
||||
{ id: 'recent-imagery-panel' },
|
||||
{ id: 'radio-panel' },
|
||||
{ id: 'scene-panel' },
|
||||
{ id: 'global-context-panel' },
|
||||
@@ -23,6 +24,7 @@ const COCKPIT_ENTRY_COLLAPSE_PANEL_IDS = Object.freeze([
|
||||
'data-panel',
|
||||
'cctv-panel',
|
||||
'weather-panel',
|
||||
'recent-imagery-panel',
|
||||
'scene-panel',
|
||||
'pp-toggles',
|
||||
'global-context-panel',
|
||||
@@ -284,6 +286,7 @@ export class PanelChrome {
|
||||
'pp-toggles',
|
||||
'cctv-panel',
|
||||
'weather-panel',
|
||||
'recent-imagery-panel',
|
||||
'global-context-panel',
|
||||
].includes(panelEl?.id);
|
||||
const collapsed = panelEl.classList.contains('collapsed');
|
||||
|
||||
@@ -95,6 +95,7 @@ export class PanelLayoutController {
|
||||
this._ppToggles = document.getElementById('pp-toggles');
|
||||
this._cctvPanel = document.getElementById('cctv-panel');
|
||||
this._weatherPanel = document.getElementById('weather-panel');
|
||||
this._recentImageryPanel = document.getElementById('recent-imagery-panel');
|
||||
this._sliderPanel = document.getElementById('param-slider-panel');
|
||||
this._detectionBtn = document.getElementById('detection-toggle');
|
||||
}
|
||||
@@ -195,7 +196,11 @@ export class PanelLayoutController {
|
||||
this._ppToggles.querySelector('.pp-header-row')?.removeAttribute('title');
|
||||
stack.prepend(this._ppToggles);
|
||||
const globalContextPanel = document.getElementById('global-context-panel');
|
||||
for (const panel of [this._cctvPanel, this._weatherPanel]) {
|
||||
for (const panel of [
|
||||
this._cctvPanel,
|
||||
this._weatherPanel,
|
||||
this._recentImageryPanel,
|
||||
]) {
|
||||
if (!panel) continue;
|
||||
for (const property of ['top', 'right', 'bottom', 'left', 'z-index'])
|
||||
panel.style.removeProperty(property);
|
||||
@@ -222,6 +227,7 @@ export class PanelLayoutController {
|
||||
this._ppToggles,
|
||||
this._cctvPanel,
|
||||
this._weatherPanel,
|
||||
this._recentImageryPanel,
|
||||
globalContextPanel,
|
||||
]) {
|
||||
if (panel) this._rightStackResizeObserver.observe(panel);
|
||||
|
||||
@@ -577,3 +577,47 @@ test('narrow-screen rails pin every hosted panel glow inside its panel box', ()
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('right layout ignores a hidden panel: no lane, no gap, no auto-collapse', () => {
|
||||
const f = fixture('right');
|
||||
const imagery = element('recent-imagery-panel', { height: 0 });
|
||||
imagery.hidden = true;
|
||||
imagery.scrollHeight = 0;
|
||||
imagery.rect.height = 0;
|
||||
f.stack.children.push(imagery);
|
||||
imagery.parentElement = f.stack;
|
||||
// A tall panel expands into focus mode: the hidden sibling is not among the
|
||||
// later panels that focus mode collapses, and it counts for nothing.
|
||||
f.expand(f.first, 900);
|
||||
f.run();
|
||||
assert.equal(f.stack.dataset.layoutMode, 'focus');
|
||||
assert.equal(imagery.classList.contains('collapsed'), false);
|
||||
assert.equal(imagery.classList.contains('layout-auto-collapsed'), false);
|
||||
assert.equal(f.stack.dataset.expandedCount, '1');
|
||||
assert.equal(
|
||||
imagery.style.getPropertyValue('--right-panel-allocated-height'),
|
||||
'',
|
||||
);
|
||||
assert.equal(imagery.getAttribute('aria-hidden'), undefined);
|
||||
const alone = parseFloat(
|
||||
f.first.style.getPropertyValue('--right-panel-allocated-height'),
|
||||
);
|
||||
// Shown (and expanded) it joins the allocation like any other panel.
|
||||
imagery.hidden = false;
|
||||
imagery.scrollHeight = 300;
|
||||
imagery.rect.height = 300;
|
||||
const retriesBefore = f.retries();
|
||||
f.run();
|
||||
assert.ok(
|
||||
f.collapsed.includes('recent-imagery-panel'),
|
||||
'focus mode collapses the later panel and asks for another pass',
|
||||
);
|
||||
assert.equal(f.retries(), retriesBefore + 1);
|
||||
f.run();
|
||||
assert.equal(f.stack.dataset.expandedCount, '1');
|
||||
assert.ok(
|
||||
parseFloat(
|
||||
f.first.style.getPropertyValue('--right-panel-allocated-height'),
|
||||
) <= alone,
|
||||
);
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -163,6 +163,7 @@
|
||||
#right-context-rail > #pp-toggles,
|
||||
#right-context-rail > #cctv-panel,
|
||||
#right-context-rail > #weather-panel,
|
||||
#right-context-rail > #recent-imagery-panel,
|
||||
#right-context-rail > #global-context-panel {
|
||||
position: relative;
|
||||
inset: auto;
|
||||
@@ -175,19 +176,23 @@
|
||||
pointer-events: auto;
|
||||
}
|
||||
#right-context-rail #cctv-panel,
|
||||
#right-context-rail #weather-panel {
|
||||
#right-context-rail #weather-panel,
|
||||
#right-context-rail #recent-imagery-panel {
|
||||
width: 100%;
|
||||
max-height: 100%;
|
||||
pointer-events: auto;
|
||||
}
|
||||
#right-context-rail > #weather-panel {
|
||||
#right-context-rail > #weather-panel,
|
||||
/* Collapsed, it must stay hideable by the rail's exclusive layout. */
|
||||
#right-context-rail > #recent-imagery-panel:not(.collapsed) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
#right-context-rail #cctv-panel.collapsed,
|
||||
#right-context-rail #weather-panel.collapsed {
|
||||
#right-context-rail #weather-panel.collapsed,
|
||||
#right-context-rail #recent-imagery-panel.collapsed {
|
||||
width: var(--right-display-collapsed-width);
|
||||
margin-left: auto;
|
||||
}
|
||||
@@ -201,6 +206,7 @@
|
||||
#right-context-rail > #pp-toggles.collapsed .pp-header-row,
|
||||
#right-context-rail #cctv-panel.collapsed .cctv-panel-inner,
|
||||
#right-context-rail #weather-panel.collapsed .weather-panel-inner,
|
||||
#right-context-rail #recent-imagery-panel.collapsed .recent-imagery-panel-inner,
|
||||
#right-context-rail
|
||||
#global-context-panel.collapsed
|
||||
.global-context-panel-inner {
|
||||
@@ -257,6 +263,7 @@
|
||||
#right-context-rail.layout-focus > #pp-toggles:not(.collapsed),
|
||||
#right-context-rail.layout-focus > #cctv-panel:not(.collapsed),
|
||||
#right-context-rail.layout-focus > #weather-panel:not(.collapsed),
|
||||
#right-context-rail.layout-focus > #recent-imagery-panel:not(.collapsed),
|
||||
#right-context-rail.layout-focus > #global-context-panel:not(.collapsed) {
|
||||
flex: 0 1 var(--right-panel-allocated-height);
|
||||
height: var(--right-panel-allocated-height);
|
||||
@@ -275,6 +282,9 @@
|
||||
#right-context-rail.layout-focus
|
||||
> #cctv-panel:not(.collapsed)
|
||||
.cctv-panel-inner,
|
||||
#right-context-rail.layout-focus
|
||||
> #recent-imagery-panel:not(.collapsed)
|
||||
.recent-imagery-panel-inner,
|
||||
#right-context-rail.layout-focus
|
||||
> #global-context-panel:not(.collapsed)
|
||||
.global-context-panel-inner {
|
||||
@@ -321,6 +331,9 @@
|
||||
#right-context-rail[data-rail-measuring]
|
||||
> [data-panel-id]:not(.collapsed)
|
||||
> .weather-panel-inner,
|
||||
#right-context-rail[data-rail-measuring]
|
||||
> [data-panel-id]:not(.collapsed)
|
||||
> .recent-imagery-panel-inner,
|
||||
#right-context-rail[data-rail-measuring]
|
||||
> [data-panel-id]:not(.collapsed)
|
||||
> .global-context-panel-inner {
|
||||
@@ -1364,7 +1377,8 @@ body.recording-mode #right-context-rail > [data-panel-id] {
|
||||
pointer-events: none !important;
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
#right-context-rail #weather-panel.collapsed {
|
||||
#right-context-rail #weather-panel.collapsed,
|
||||
#right-context-rail #recent-imagery-panel.collapsed {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,483 @@
|
||||
/* Recent Imagery: a right-rail readout laid out by the rail like WEATHER; only
|
||||
the body scrolls. Every block has a fixed height, so no click moves a
|
||||
control. Card typography comes from the rail-card rules in weather.css. */
|
||||
.recent-imagery-panel-inner {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-height: 0;
|
||||
max-height: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--panel-radius);
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(24px) saturate(1.4);
|
||||
-webkit-backdrop-filter: blur(24px) saturate(1.4);
|
||||
box-shadow:
|
||||
0 8px 32px rgba(0, 0, 0, 0.5),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.03) inset,
|
||||
0 1px 0 rgba(255, 255, 255, 0.06) inset;
|
||||
}
|
||||
#recent-imagery-panel .panel-glow {
|
||||
position: absolute;
|
||||
inset: -18px;
|
||||
background: radial-gradient(
|
||||
ellipse at center,
|
||||
rgba(0, 212, 255, 0.16) 0%,
|
||||
transparent 65%
|
||||
);
|
||||
filter: blur(16px);
|
||||
opacity: 0.25;
|
||||
pointer-events: none;
|
||||
}
|
||||
#recent-imagery-panel .panel-header {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
#recent-imagery-panel-count {
|
||||
color: var(--text-secondary);
|
||||
font: 9px var(--font-mono);
|
||||
letter-spacing: 1px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
#recent-imagery-panel-body {
|
||||
min-height: 0;
|
||||
max-height: calc(var(--right-stack-max-height, 50svh) - 64px);
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(0, 212, 255, 0.46) transparent;
|
||||
}
|
||||
#recent-imagery-panel.collapsed #recent-imagery-panel-body,
|
||||
#recent-imagery-panel.collapsed #recent-imagery-panel-count,
|
||||
#recent-imagery-panel[hidden],
|
||||
#recent-imagery-panel [hidden],
|
||||
#recent-imagery-split-line[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
#recent-imagery-panel.collapsed .panel-title {
|
||||
font-size: 8px;
|
||||
letter-spacing: 1.8px;
|
||||
}
|
||||
#recent-imagery-panel.collapsed .panel-header {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
#recent-imagery-panel.collapsed .recent-imagery-panel-inner {
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
/* ---- the fixed stack ---------------------------------------------------- */
|
||||
.recent-imagery-readout {
|
||||
--weather-card-border: rgba(98, 181, 255, 0.28);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
color: var(--text-dim);
|
||||
font: 9px/1.45 var(--font-mono);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.recent-imagery-readout .ri-block {
|
||||
flex: 0 0 auto;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
/* Reserved controls keep their box; they are only invisible. */
|
||||
.recent-imagery-readout .is-reserved {
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
.recent-imagery-readout .data-toggle-chip {
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
height: 22px;
|
||||
padding: 0 4px;
|
||||
overflow: hidden;
|
||||
text-align: center;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.recent-imagery-readout .data-toggle-chip:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
/* 1. SELECT BOX · USE VIEW · CLEAR, three equal cells. */
|
||||
.recent-imagery-readout .ri-actions-row {
|
||||
height: 22px;
|
||||
}
|
||||
.recent-imagery-readout .ri-actions-row .rail-card-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
}
|
||||
.recent-imagery-readout .ri-actions-row .data-toggle-chip {
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
/* 2 and 3b. One-line notice and hint: always there, empty or not. */
|
||||
.recent-imagery-readout .ri-line {
|
||||
height: 14px;
|
||||
line-height: 14px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
/* The notice text yields room to ZOOM IN only while it shows; the chip sits
|
||||
in a fixed slot at the line's right end either way. The ellipsis is a
|
||||
safety net: every notice is written to fit the panel. */
|
||||
.recent-imagery-readout .ri-notice {
|
||||
position: relative;
|
||||
}
|
||||
.recent-imagery-readout .ri-notice-text {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.recent-imagery-readout .ri-notice.has-action .ri-notice-text {
|
||||
padding-right: 52px;
|
||||
}
|
||||
.recent-imagery-readout .ri-zoom-in {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 46px;
|
||||
height: 14px;
|
||||
padding: 0 3px;
|
||||
font-size: 8px;
|
||||
line-height: 12px;
|
||||
letter-spacing: 0.4px;
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.recent-imagery-readout .ri-notice.warn {
|
||||
color: rgba(255, 196, 120, 0.95);
|
||||
}
|
||||
.recent-imagery-readout .ri-notice.info {
|
||||
color: var(--accent);
|
||||
}
|
||||
.recent-imagery-readout .ri-hint {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* 3. The strip: fixed height, newest day first. */
|
||||
.recent-imagery-readout .ri-strip {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
height: 120px;
|
||||
padding: 2px 2px 6px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scroll-snap-type: x proximity;
|
||||
scrollbar-width: thin;
|
||||
outline: none;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.recent-imagery-readout .ri-strip:focus-visible {
|
||||
box-shadow: 0 0 0 1px var(--accent);
|
||||
}
|
||||
.recent-imagery-readout .ri-strip.searching {
|
||||
opacity: 0.7;
|
||||
}
|
||||
.recent-imagery-readout .ri-strip-empty {
|
||||
flex: 1 0 100%;
|
||||
align-self: center;
|
||||
text-align: center;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.recent-imagery-readout .ri-card {
|
||||
position: relative;
|
||||
flex: 0 0 94px;
|
||||
height: 112px;
|
||||
box-sizing: border-box;
|
||||
display: grid;
|
||||
grid-template-rows: 42px 13px 11px 11px 18px;
|
||||
row-gap: 2px;
|
||||
padding: 3px 2px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
scroll-snap-align: start;
|
||||
transition:
|
||||
border-color var(--transition-fast),
|
||||
background-color var(--transition-fast);
|
||||
}
|
||||
.recent-imagery-readout .ri-card:hover {
|
||||
border-color: var(--glass-border-hover);
|
||||
}
|
||||
.recent-imagery-readout .ri-card.focused {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-dim);
|
||||
}
|
||||
.recent-imagery-readout .ri-card.preview {
|
||||
box-shadow: inset 0 0 0 1px rgba(139, 233, 255, 0.35);
|
||||
}
|
||||
.recent-imagery-readout .ri-card.pinned {
|
||||
box-shadow: inset 0 0 0 1px rgba(139, 233, 255, 0.75);
|
||||
}
|
||||
.recent-imagery-readout .ri-thumb {
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
background: rgba(120, 130, 140, 0.18);
|
||||
}
|
||||
.recent-imagery-readout .ri-thumb[data-status='empty'] {
|
||||
background: rgba(90, 96, 104, 0.22);
|
||||
}
|
||||
.recent-imagery-readout .ri-thumb-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.recent-imagery-readout .ri-thumb-text {
|
||||
padding: 0 4px;
|
||||
text-align: center;
|
||||
font-size: 8px;
|
||||
}
|
||||
.recent-imagery-readout .ri-card-flag,
|
||||
.recent-imagery-readout .ri-card-start {
|
||||
position: absolute;
|
||||
padding: 0 4px;
|
||||
border-radius: 3px;
|
||||
background: rgba(4, 17, 27, 0.9);
|
||||
font-size: 7px;
|
||||
font-weight: 700;
|
||||
line-height: 12px;
|
||||
letter-spacing: 0.8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.recent-imagery-readout .ri-card-flag {
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
color: var(--accent);
|
||||
}
|
||||
.recent-imagery-readout .ri-card-start {
|
||||
right: 3px;
|
||||
bottom: 3px;
|
||||
color: rgba(255, 196, 120, 0.95);
|
||||
}
|
||||
.recent-imagery-readout .ri-card-date,
|
||||
.recent-imagery-readout .ri-card-sensor,
|
||||
.recent-imagery-readout .ri-card-cloud {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.recent-imagery-readout .ri-card-date {
|
||||
font-size: 10px;
|
||||
line-height: 13px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.recent-imagery-readout .ri-card-sensor,
|
||||
.recent-imagery-readout .ri-card-cloud {
|
||||
font-size: 8px;
|
||||
line-height: 11px;
|
||||
letter-spacing: 0;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.recent-imagery-readout .ri-card-chips {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 4px;
|
||||
}
|
||||
.recent-imagery-readout .ri-card-chips .data-toggle-chip {
|
||||
height: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* 4. The selection bar: mode, then two rows that are always there. */
|
||||
.recent-imagery-readout .ri-selection {
|
||||
display: grid;
|
||||
grid-template-rows: 20px 20px 20px;
|
||||
row-gap: 4px;
|
||||
padding: 5px;
|
||||
border: 1px solid var(--weather-card-border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.recent-imagery-readout .ri-mode {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 4px;
|
||||
}
|
||||
.recent-imagery-readout .ri-selection .data-toggle-chip {
|
||||
height: 20px;
|
||||
}
|
||||
.recent-imagery-readout .ri-slot {
|
||||
display: grid;
|
||||
grid-template-columns: 44px minmax(0, 1fr) 22px;
|
||||
align-items: center;
|
||||
column-gap: 6px;
|
||||
height: 20px;
|
||||
}
|
||||
.recent-imagery-readout .ri-slot-tag {
|
||||
height: 16px;
|
||||
line-height: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 3px;
|
||||
text-align: center;
|
||||
font-size: 8px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.recent-imagery-readout .ri-slot.lit .ri-slot-tag {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.recent-imagery-readout .ri-slot-value {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
letter-spacing: 0;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.recent-imagery-readout .ri-slot.lit .ri-slot-value {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.recent-imagery-readout .ri-slot[data-state='preview'] .ri-slot-value {
|
||||
color: var(--text-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
.recent-imagery-readout .ri-unpin {
|
||||
width: 22px;
|
||||
padding: 0;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
/* 5. Opacity (both images), SWAP and export, one row. SWAP keeps its slot
|
||||
while no swipe is live. */
|
||||
.recent-imagery-readout .ri-controls {
|
||||
display: grid;
|
||||
grid-template-columns: 76px minmax(0, 1fr) 40px 128px;
|
||||
align-items: center;
|
||||
column-gap: 6px;
|
||||
height: 22px;
|
||||
}
|
||||
.recent-imagery-readout .ri-opacity-label {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 4px;
|
||||
font-size: 8px;
|
||||
letter-spacing: 1px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.recent-imagery-readout .ri-opacity-value {
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.recent-imagery-readout .ri-opacity {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
.recent-imagery-readout .ri-opacity:disabled {
|
||||
opacity: 0.35;
|
||||
}
|
||||
.recent-imagery-readout .ri-swap {
|
||||
width: 40px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
.recent-imagery-readout .ri-exports .rail-card-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 4px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 6. DETAILS: a collapsed rail card at the bottom, for every note. */
|
||||
.recent-imagery-readout .ri-details .rail-card {
|
||||
border-color: var(--weather-card-border);
|
||||
}
|
||||
.recent-imagery-readout .ri-details .rail-card-title {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
letter-spacing: 1.2px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.recent-imagery-readout .ri-details .rail-card-line.muted {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.recent-imagery-readout .ri-details .rail-card-actions .data-toggle-chip {
|
||||
height: 20px;
|
||||
padding: 0 7px;
|
||||
}
|
||||
|
||||
/* ---- box tool cursor ---------------------------------------------------- */
|
||||
body.gev-imagery-box #cesium-container canvas,
|
||||
body.gev-imagery-box .cesium-widget canvas {
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
/* ---- split line --------------------------------------------------------- */
|
||||
/* Above the right rail (z-index 110) so the handle can always be grabbed. */
|
||||
#recent-imagery-split-line {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: var(--recent-imagery-split, 50%);
|
||||
z-index: 130;
|
||||
width: 1px;
|
||||
pointer-events: none;
|
||||
background: rgba(218, 241, 247, 0.76);
|
||||
box-shadow: 0 0 12px rgba(94, 216, 239, 0.72);
|
||||
}
|
||||
#recent-imagery-split-line span {
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
background: rgba(4, 17, 27, 0.82);
|
||||
color: rgba(239, 247, 249, 0.92);
|
||||
font: 700 9px/1.4 var(--font-mono);
|
||||
letter-spacing: 1.2px;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
}
|
||||
#recent-imagery-split-line span.before {
|
||||
right: 8px;
|
||||
}
|
||||
#recent-imagery-split-line span.after {
|
||||
left: 8px;
|
||||
}
|
||||
#recent-imagery-split-line .recent-imagery-split-handle {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
border: 1px solid rgba(180, 215, 222, 0.72);
|
||||
border-radius: 50%;
|
||||
outline: 0;
|
||||
color: rgba(239, 247, 249, 0.96);
|
||||
background: rgba(4, 17, 27, 0.96);
|
||||
box-shadow:
|
||||
0 0 0 5px rgba(62, 112, 125, 0.3),
|
||||
0 0 13px rgba(94, 216, 239, 0.3);
|
||||
font: 700 14px/1 var(--font-mono);
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: auto;
|
||||
touch-action: none;
|
||||
cursor: ew-resize;
|
||||
}
|
||||
#recent-imagery-split-line .recent-imagery-split-handle:focus-visible {
|
||||
outline: 2px solid #fff;
|
||||
outline-offset: 4px;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
#recent-imagery-panel-body {
|
||||
max-height: 48vh;
|
||||
}
|
||||
}
|
||||
@@ -218,6 +218,8 @@
|
||||
#right-context-rail #pp-toggles.collapsed .pp-header-row,
|
||||
#right-context-rail #cctv-panel,
|
||||
#right-context-rail #cctv-panel.collapsed,
|
||||
#right-context-rail #recent-imagery-panel,
|
||||
#right-context-rail #recent-imagery-panel.collapsed,
|
||||
#right-context-rail #radio-panel,
|
||||
#right-context-rail #radio-panel.collapsed,
|
||||
#right-context-rail #global-context-panel,
|
||||
|
||||
@@ -12,6 +12,18 @@
|
||||
<div id="weather-panel-body" data-rail-scroller></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="recent-imagery-panel" class="panel-collapsible" data-panel-id="recent-imagery-panel" hidden>
|
||||
<div class="panel-glow"></div>
|
||||
<div class="recent-imagery-panel-inner">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">RECENT IMAGERY</span>
|
||||
<span id="recent-imagery-panel-count" aria-label="Imagery days in the box"></span>
|
||||
<span class="panel-divider"></span>
|
||||
<button class="panel-collapse-btn" data-collapse-target="recent-imagery-panel" type="button" title="Collapse panel">▼</button>
|
||||
</div>
|
||||
<div id="recent-imagery-panel-body" data-rail-scroller></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="global-context-panel" class="panel-collapsible collapsed" data-panel-id="global-context-panel">
|
||||
<div class="panel-glow"></div>
|
||||
<div class="global-context-panel-inner">
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
@import './src/ui/styles/cctv.css';
|
||||
@import './src/ui/styles/scenes.css';
|
||||
@import './src/ui/styles/bhote-koshi.css';
|
||||
@import './src/ui/styles/recent-imagery.css';
|
||||
@import './src/ui/styles/recording.css';
|
||||
@import './src/ui/styles/responsive.css';
|
||||
@import './src/ui/styles/command-dock.css';
|
||||
|
||||
Reference in new issue
Block a user