diff --git a/configure/src/metaconfigs/layer-tile-config.json b/configure/src/metaconfigs/layer-tile-config.json
index 88b819b22..808c1f092 100644
--- a/configure/src/metaconfigs/layer-tile-config.json
+++ b/configure/src/metaconfigs/layer-tile-config.json
@@ -485,14 +485,14 @@
{
"field": "time.dataStartTime",
"name": "Data Start Time",
- "description": "The earliest time for which data is available in this layer. This is for display purposes only and does not constrain queries. Format: ISO 8601 datetime string (e.g., 2020-01-01T00:00:00Z).",
+ "description": "The earliest time for which data is available in this layer. This is for display purposes only and does not constrain queries. Use an ISO 8601 datetime (e.g., 2020-01-01T00:00:00Z), or now to follow the current date, optionally offset by an ISO 8601 duration (e.g., now - P1D). An unreadable value is ignored.",
"type": "text",
"width": 6
},
{
"field": "time.dataEndTime",
"name": "Data End Time",
- "description": "The latest time for which data is available in this layer. This is for display purposes only and does not constrain queries. Format: ISO 8601 datetime string (e.g., 2025-12-31T23:59:59Z).",
+ "description": "The latest time for which data is available in this layer. This is for display purposes only and does not constrain queries. Use an ISO 8601 datetime (e.g., 2025-12-31T23:59:59Z), or now for a collection that is still growing, so the layer stays current without edits, optionally offset by an ISO 8601 duration (e.g., now + P5D). An unreadable value is ignored.",
"type": "text",
"width": 6
}
diff --git a/configure/src/metaconfigs/layer-vector-config.json b/configure/src/metaconfigs/layer-vector-config.json
index 87d732ef6..273bff8ee 100644
--- a/configure/src/metaconfigs/layer-vector-config.json
+++ b/configure/src/metaconfigs/layer-vector-config.json
@@ -508,14 +508,14 @@
{
"field": "time.dataStartTime",
"name": "Data Start Time",
- "description": "The earliest time for which data is available in this layer. This is for display purposes only and does not constrain queries. Format: ISO 8601 datetime string (e.g., 2020-01-01T00:00:00Z).",
+ "description": "The earliest time for which data is available in this layer. This is for display purposes only and does not constrain queries. Use an ISO 8601 datetime (e.g., 2020-01-01T00:00:00Z), or now to follow the current date, optionally offset by an ISO 8601 duration (e.g., now - P1D). An unreadable value is ignored.",
"type": "text",
"width": 6
},
{
"field": "time.dataEndTime",
"name": "Data End Time",
- "description": "The latest time for which data is available in this layer. This is for display purposes only and does not constrain queries. Format: ISO 8601 datetime string (e.g., 2025-12-31T23:59:59Z).",
+ "description": "The latest time for which data is available in this layer. This is for display purposes only and does not constrain queries. Use an ISO 8601 datetime (e.g., 2025-12-31T23:59:59Z), or now for a collection that is still growing, so the layer stays current without edits, optionally offset by an ISO 8601 duration (e.g., now + P5D). An unreadable value is ignored.",
"type": "text",
"width": 6
}
diff --git a/configure/src/metaconfigs/tab-time-config.json b/configure/src/metaconfigs/tab-time-config.json
index e6a610083..45693d256 100644
--- a/configure/src/metaconfigs/tab-time-config.json
+++ b/configure/src/metaconfigs/tab-time-config.json
@@ -51,7 +51,7 @@
{
"field": "time.format",
"name": "Time Format",
- "description": "The time format to be displayed on the Time UI. Uses D3 time format specifiers: https://github.com/d3/d3-time-format . Default: %Y-%m-%dT%H:%M:%SZ",
+ "description": "The time format to be displayed on the Time UI. Accepts either style: a '%' anywhere in the string selects D3 time format specifiers (https://github.com/d3/d3-time-format ) - for instance %Y-%m-%dT%H:%M:%SZ - otherwise the string is read as moment.js time format tokens (https://momentjs.com/docs/#/displaying/format/ ) - for instance YYYY-MM-DDTHH:mm:ss[Z]. Default: YYYY-MM-DDTHH:mm:ss[Z]",
"type": "text",
"disableSwitch": "time.enabled",
"width": 3
diff --git a/docs/DATES.md b/docs/DATES.md
new file mode 100644
index 000000000..c7a8e5f9e
--- /dev/null
+++ b/docs/DATES.md
@@ -0,0 +1,119 @@
+# How dates work in MMGIS
+
+Three different kinds of date show up in this app, and every date a user sees anywhere (the Timeline, the Layers panel, an exported map) is one of them. They are easy to confuse and the confusion is costly: a reader who sees an unlabeled date on a map assumes it is the date the data was collected, which is the one date the app can least often produce.
+
+This page names the three kinds, then catalogues where each value actually comes from in the code, so a feature that needs to show a date can pick the right one and know its limits.
+
+## The three kinds of date
+
+**Acquisition time: when the data was collected.** Every layer's data was collected at some point in the real world, whether or not the layer responds to the time slider. It is usually a range rather than an instant: a satellite pass, a month of composited scenes, a multi-year campaign. This is the date readers assume a map carries.
+
+**Interface time: where the user put the slider.** The Time Control's state. It is a control input, a request the user is making, not a fact about any data. It matters because it decides what the app asks the tile servers for.
+
+**Export time: when the picture was made.** The wall-clock moment a screenshot or export was produced. It is always true and trivially available, and it is what makes an open-ended date like "to present" readable later.
+
+Two things the vocabulary hides:
+
+- A **time-enabled layer** is one whose config has `time.enabled` set. The slider changes what it shows. A layer that is not time-enabled ignores the slider entirely, but its data still has an acquisition time; the slider can sit on 2024 while a layer collected in 2016 stays on screen.
+- For a time-enabled layer, the layer as a whole may cover a long span (say two decades of monthly data) while what is on the map right now is one slice of it. The date worth communicating is the slice, not the span.
+
+## Interface time: the Time Control
+
+The Time Control keeps three values, all ISO strings truncated to whole seconds with a trailing `Z`, in `src/essence/Basics/TimeControl_/TimeControl.js`:
+
+| Value | Meaning | Bus request that returns it |
+| --- | --- | --- |
+| `currentTime` | the cursor, the date the slider handle sits on | `time:getCurrent` |
+| `startTime` | the left edge of the slider's window | `time:getStart` |
+| `endTime` | the right edge of the slider's window | `time:getEnd` |
+
+Two things about these values are not obvious from the names:
+
+- **The window's right edge is never sent to a server.** Requests run from `startTime` to the cursor, never to `endTime`. Printing "start to end" describes a span the map never asked for.
+- **The slider has a mode that is not on the bus.** `TimeUI.js` has a Range mode and a Point mode. Switching to Point mode sets the window start to the epoch, 1970, and switching back restores the saved range start. A feature reading `startTime` raw will, in Point mode, print "since 1970." Nothing over the bus says which mode is active.
+
+Two more bus requests render a time as text, both using the mission's time format (see below): `time:getCurrentFormatted` returns the cursor, or `null` until time is enabled and seeded; `time:formatTime` takes any time the caller holds and formats it the same way, or `null` if it cannot be parsed.
+
+## How the cursor reaches a layer's tile request
+
+Every time the slider moves, `updateLayersTime` in `TimeControl.js` writes onto every layer with `time.enabled` set:
+
+- `time.start` = the Time Control's `startTime`
+- `time.end` = the Time Control's `currentTime`, the cursor
+
+so every time-enabled layer is stamped with the same window, start to cursor. A layer's `time.type` decides what happens next:
+
+- `global` and `requery` layers follow the cursor and are reloaded when it moves.
+- `local` layers keep a window of their own in `time.start` and `time.end` and are not restamped.
+
+`compileTileUrl` in `src/essence/Tools/_shared/adapters/tileUrlUtils.ts` then puts the window into the URL. It does this two ways, and a feature that inspects URLs to guess "does this layer vary with time" has to know both:
+
+- **Placeholders.** `{time}`, `{starttime}`, `{endtime}`, and `{customtime.N}` in the authored URL are replaced with the formatted times.
+- **Appended parameters.** For URLs the app builds itself, the authored URL has no placeholder at all. `stac-collection:`, `COG:`, and `titiler-url:` layers get `datetime=start/end` appended; TMS layers get `starttime=` and `time=` appended. These are most of a typical mission's stack, and they vary with the cursor just as much as placeholder URLs do.
+
+The per-layer `time.format` field controls how the times are written into the URL. It uses d3 format specifiers like `%Y-%m-%d` and nothing else.
+
+**What the tile server does with the span is invisible.** A STAC or TiTiler service picks scenes inside the requested span and never reports which ones. So for a time-enabled layer, the true acquisition date of the pixels on screen is not obtainable from the frontend. The most honest date the app can print is the span it requested, labeled as a request.
+
+## The mission-wide time format
+
+The Configure page's Time tab has a mission-wide `time.format`. `formatMissionTime` in `TimeControl.js` applies it: if the string contains a `%` it is treated as d3 specifiers, otherwise as moment tokens, and the default is `YYYY-MM-DDTHH:mm:ss[Z]`. This is a separate setting from the per-layer `time.format` above, which is d3 only. Both are named `time.format`; they live at different levels of the config and accept different token languages.
+
+## Acquisition time: the Data Time Extent fields
+
+The only home for a layer's acquisition range is a pair of fields on the layer in Configure, labeled **Data Time Extent**: `time.dataStartTime` and `time.dataEndTime`. They exist for display and never constrain a query. Each accepts either a concrete datetime or a policy string:
+
+- `now` resolves to the current date at the moment it is read
+- `now - P1D`, `now + P5D`, and any other ISO 8601 duration offset from `now`
+
+`temporalExtentFor` in `src/essence/Basics/Layers_/Layers_.js` resolves the policy at call time through `layerTimePolicy.ts`, and the `layers:getTemporalExtent` bus request serves the result for one layer, by UUID or name, or for all layers at once. The Timeline and the Layers panel read it.
+
+Two ways the fields get filled:
+
+- **By hand.** A mission admin types them into the Data Time Extent fields.
+- **From a VEDA STAC collection.** The tile layer editor's VEDA STAC Source action (`scripts/lib/vedaStacLayer.js`) reads the collection's temporal extent and writes `dataStartTime` and `dataEndTime`. An ongoing collection, one whose STAC extent has no end, gets `dataEndTime: "now"`. Layers authored any other way, including hand-typed STAC, COG, and TiTiler URLs, get nothing automatically, and their acquisition date then exists only as text inside the tile URL, which is not data.
+
+One limit: **resolving `now` discards that it was `now`.** The resolver returns a date. A consumer cannot tell "collection is ongoing" from "collection ended today."
+
+## Period length: `time.interval` and `time.isPeriodic`
+
+A time-enabled layer may carry two more fields in its `time` block: `interval`, an ISO 8601 duration such as `P1D` or `P1M` giving the length of one period, and `isPeriodic`, whether the data repeats on that cadence. The VEDA STAC Source action writes them from the collection's `dashboard:time_interval` and `dashboard:is_periodic`, and nothing else writes them. The export legend reads `interval` for two things: it names the period the row can narrow a layer's coverage to — `2025-06` for a monthly layer — and it sets how precisely every date on that row prints. An interval shorter than an hour sets precision only. Nothing reads `isPeriodic`.
+
+## Export time
+
+The export legend's header carries it: `new Date().toISOString()`, rendered through `time:formatTime`. Filenames do not — `buildExportFilename` in `shareActions.ts` stamps the filename with `viewState.time`, which is the cursor, not the wall clock.
+
+## What the export legend shows
+
+`getExportLegendModel.ts` in `src/essence/Tools/_shared/legend/` builds the band: a header, then one row for every layer that is toggled on, painting (opacity above zero), not a header layer, and listed. A layer with no colour ramp and no categorical stops still gets a row, carrying its name and its date line alone.
+
+The header is the mission name, then `Time cursor ` — the cursor as `time:getCurrentFormatted` renders it, left out when that returns null — then `Exported ` — the wall-clock moment the export was made, printed as the raw ISO string when that moment cannot be formatted.
+
+Each row carries one date line, and every date line opens with one of two words. **Collected** means the data on screen was gathered inside the range that follows: the app knows the layer's coverage and, for a layer that follows the slider, has narrowed it to what the request could have returned. **Requested** means the app knows only the span it asked the server for. A bare `A → B` never appears, so a range can never be mistaken for a stronger claim than it is.
+
+A layer's **coverage** is its Data Time Extent: the two config fields `time.dataStartTime` and `time.dataEndTime`, which an admin typed into the layer's Configure page, the VEDA STAC Source action copied from the STAC collection's temporal extent, or a mission blueprint shipped. Core resolves them into concrete dates for every layer in one `layers:getTemporalExtent` call, turning a `now` policy into today. That is the only statement the app holds about when a layer's data exists; nothing is read from tile responses or URL text.
+
+A layer that is **not time-enabled** shows its coverage unchanged: `Collected → `, or `Collected from ` / `Collected until ` for a half-open extent. No coverage means no date line.
+
+For a **time-enabled layer**, `time.enabled` is the whole test. The URL is not inspected: core appends `datetime=` and `starttime=` to URLs that carry no placeholder, so a placeholder test would drop most of a mission's stack. Such a layer's cursor is its own `time.end` when `time.type` is `local`, and the Time Control's `time:getCurrent` otherwise; its window start is its own `time.start`, or `time:getStart`. The request the map made is window start to cursor. Then:
+
+- **With coverage**, the row shows the part of the coverage the request could have returned: the overlap of the request span with the coverage. The pixels on screen come from inside the coverage, and the overlap is the part of it the request could reach, whatever mosaic rule the server applied inside it. A layer covering 2015 to 2016 with the cursor on 2024 prints `Collected 2015 → 2016`, never a date the layer has no data for. The line prints `Collected → `, with the cursor as the end when the coverage runs past it. When neither the request nor the coverage bounds the past — an open request start, which is what Point mode leaves, against a coverage with no start either — the line is `Collected until `, the same half-open wording the not-time-enabled paragraph uses.
+- **With coverage and a cadence**, the overlap narrows further. A `time.interval` of an hour or longer names the layer's period length, and when the period holding the cursor holds any of the coverage, that period is the range: `Collected 2025-06` for a monthly layer with the cursor in June 2025 and data for it. `P1Y`, `P1M`, and `P1D` periods are the UTC year, month, or day containing the cursor; any other duration is stepped forward from `dataStartTime`. The printed period is clipped to the coverage at both ends, so it never names a day the layer has no data for: a weekly layer whose data stops on the ninth prints `Collected 2025-01-08 → 2025-01-09`, not the whole week. It is not clipped to the requested span, though — a monthly composite is the whole month even when the window opened mid-month. When the cursor's period holds none of the coverage, or the period cannot be computed, the row prints the plain overlap from the rule above. An interval under an hour is not a period but a collection of individually timestamped scenes, and never narrows the range. The period arithmetic lives in `layerPeriod.ts`, on top of the ISO-duration parsing core owns in `layerTimePolicy.ts`.
+- **With no coverage**, the row shows the span the map asked for, **Requested** ` → `, or `Requested up to ` when the window start is missing or sits within a day of 1970-01-01, which is where Point mode puts it.
+- **Request and coverage that do not overlap at all**, a cursor sitting before the layer's first scene, print `Requested`, since the server had nothing inside the span to draw and the app cannot say what, if anything, is on screen.
+- Without a cursor, the row shows no date line.
+
+On any `Collected` line, a range whose two ends print as the same label — a single day at day precision, say — shows that label once rather than `X → X`. A period that is not calendar-aligned prints at its unit's precision and so can read wider than it is: a two-year period starting mid-2025 prints as `2025 → 2027`.
+
+**How precisely a row's dates print** is decided by the layer's `time.interval`, not by the mission's time format. A daily collection has no business printing seconds. The smallest unit in the interval sets the precision:
+
+| Smallest unit in `time.interval` | Prints as |
+| --- | --- |
+| years | `2026` |
+| months | `2026-07` |
+| days or weeks | `2026-07-03` |
+| hours | `2026-07-03 06:00Z` |
+| minutes or seconds | `2026-07-03T06:12:22Z` |
+| no interval, or unparseable | `2026-07-03` |
+
+Every date on a row, whether in a `Collected` or `Requested` line, prints at that precision, and a range prints both ends at it. The two header lines are the exception: the cursor and the export time are instants, not periods, and go through core — the cursor through `time:getCurrentFormatted`, the export time through `time:formatTime` — so they read the way the mission's own Time Control writes them. `renderLegendBand.ts` draws each date line under its row's name, and the header lines under the mission name.
diff --git a/docs/LAYER_RUNTIME_DETECTION.md b/docs/LAYER_RUNTIME_DETECTION.md
new file mode 100644
index 000000000..5691e1b32
--- /dev/null
+++ b/docs/LAYER_RUNTIME_DETECTION.md
@@ -0,0 +1,75 @@
+# What we can detect about a layer at runtime
+
+Reference for any feature that needs to know "is this layer actually showing something right now?" — export legends, layer lists, auto-zoom, analysis prompts.
+
+## Two different questions
+
+✅ available · ⚠️ available with caveats · ❌ not possible
+
+| Question | Meaning | Cost |
+| --- | --- | --- |
+| **Presence** | Did the source return data for the area covering the viewport? | Free — already in the render path. |
+| **Transparency** | Of the pixels returned, is any actually opaque? | Requires reading pixel alpha. Available for some layer kinds only. |
+
+Most features only need **presence**. Reach for transparency only when "the layer covers this area but every pixel is nodata" has to be distinguished.
+
+## Gating signals — check these before believing anything below
+
+| Signal | What it actually means | Trap |
+| --- | --- | --- |
+| **Zoom cutoffs** — `L_.enforceVisibilityCutoffs` (`Layers_.js` ~1870) plus per-layer `minZoom` / `maxZoom` / `maxNativeZoom` (`Map_.js` ~1729-1732) | The layer paints nothing outside its zoom range | The most common cause of a blank layer. A layer that is on, fully loaded, and full of data still draws zero pixels here. |
+| `L_._layersLoaded[i]` | The layer object was created — and for source-fetched types (vector/query ~1269-1272 and ~1330-1332, velocity ~1619-1620, image ~2320-2328) its source resolved | Never "tiles have painted." On the tile path `addTo(map)` (`Map_.js` ~1747) starts tile requests *before* the flag is set (~1755). |
+| `L_.layers.loadStatus[name]` | Some request came back without an error | **Not a presence signal.** deck fires `onTileLoad` even when `getTileData` resolves to `null`, so a layer whose every tile came back empty still reports `status: 'ok'` — and the status latches, never flipping back from `ok` (`Layers_.js` ~2279-2290). |
+| **Render settled** | Leaflet has a lifecycle: `loading` / `load` → `L_.setGlobalLoading` / `setGlobalLoaded` (`Map_.js` ~1757-1758, ~1772-1811), consumed via the `L_._globalLoadings` list (`Layers_.js` ~4322-4338) | Wired on the Leaflet tile path only. deck's `onViewportLoad` / `layer.isLoaded` are not wired anywhere (zero hits for `onViewportLoad` in `src/`). Without a settled signal, a not-yet-loaded tile and a genuinely empty one are indistinguishable. |
+
+## deck.gl
+
+`L_.layers.layer[uuid]` holds the layer *descriptor*, not the live rendered instance. Loaded tiles, geometry and images live on the live instance, reachable only through `getNativeMap()` and deck's internal layer manager — that caveat applies to **every** row below, presence included.
+
+| Layer kind | Presence | Transparency |
+| --- | --- | --- |
+| vector, scatterplot | ⚠️ Exact — geometry is on `props.data`, via internals | — |
+| raster tile | ⚠️ Tile content is `null` when there is no data, via internals | ⚠️ Tiles decode to a readable `ImageBitmap`, via internals — and never tainted (see below) |
+| COG | ⚠️ Tile either loads or does not, via internals | ⚠️ nodata is discarded on the GPU (`DeckCOGLayer.ts` ~275, ~291), but the raw band `Float32Array` and the nodata sentinel are both in hand in `getTileData` (~196-211), so a CPU-side check is buildable there. The returned `TileData` simply doesn't retain that copy. |
+| vectortile | ⚠️ Via internals | ⚠️ Via internals |
+| WMS | ⚠️ `onImageLoad` / `onImageLoadError` are wired (`Map_.js` ~1696-1705), but the callback carries only a `requestId` | ⚠️ The decoded image is retained on the live layer's `state.image`, via internals. It is decoded from fetched bytes, so it is not tainted. |
+
+deck raster tiles are fetched with a bare `fetch(url)` (`DeckGLHelpers.ts` ~211). A tile host without `Access-Control-Allow-Origin` therefore fails the fetch outright → `onTileError` → nothing renders. That is also *why* the bitmaps that do survive are never tainted. A 404 or `204 No Content` becomes `null` (`isImageTileResponse`) and nothing is drawn — this is deck-only behavior.
+
+## Leaflet
+
+| Layer kind | Presence | Transparency |
+| --- | --- | --- |
+| vector, query | ✅ Exact — `getBounds()` measures rendered geometry | — |
+| raster tile | ✅ `tileload` / `tileerror` are wired (`Map_.js` ~1760-1770); a 404 fires `tileerror`, not a null tile | ⚠️ Live DOM tiles set no `crossOrigin`, so pixel reads throw for cross-origin hosts. Same-origin tiles — including MMGIS's own — read fine, and re-fetching with CORS works when the host sends `Access-Control-Allow-Origin` (see `IdentifierTool`). |
+| data (shader) | ✅ | ✅ MMGIS paints the canvas itself, and its tiles do set `crossOrigin` (`leaflet.tilelayer.gl.js` ~616) |
+| image (GeoTIFF), velocity | ✅ | ✅ MMGIS paints these canvases itself |
+| vectortile | ✅ | ⚠️ Per-tile SVG only |
+| video | ⚠️ The overlay is created, but no `load` / `error` handler is attached (`Map_.js` ~2374-2378), so nothing confirms it played | ⚠️ Non-absolute urls are prefixed onto our own origin (~2339-2342), so the common case is same-origin and untainted; an absolute cross-origin src taints |
+| model | ❌ | ❌ Nothing is created on the Leaflet path — globe only |
+
+## `boundingBox`
+
+The mission config's `boundingBox` is author-written metadata and is routinely wrong about what data *exists* — a collection-mosaic tile layer paints wherever the collection has scenes, while its configured bbox often describes a single granule. Never treat it as evidence that data is there.
+
+It is, however, a valid **upper bound on where a layer can paint** — but only on Leaflet, and only for some types:
+
+| Type | Role of `boundingBox` on Leaflet |
+| --- | --- |
+| tile | Hard tile-request clip via Leaflet's `bounds` option (`Map_.js` ~1648-1658, ~1741) |
+| data | Hard clip via `bounds` (~2052-2072) |
+| video | **Required**, and it *is* the render extent (~2346-2364) |
+| image, vectortile | None — the bbox is parsed but never passed to the layer |
+
+On deck.gl no such clip is applied for any type, so the upper-bound reading does not carry over.
+
+## Screenshots and canvas tainting
+
+`LeafletScreenshot.js` (~73-76) passes both `allowTaint: true` and `useCORS: true` to html2canvas.
+
+- **`useCORS`** is what protects ` ` tiles: html2canvas re-fetches them itself with `crossOrigin='anonymous'`. The protection is the `crossOrigin` attribute, not the re-fetch. A host that omits `Access-Control-Allow-Origin` fails that load, so the tile is silently **missing** from the capture rather than tainting it.
+- **`allowTaint: true`** applies to `` and `` elements: html2canvas copies them through with `drawImage` instead of the `getImageData` check that would skip a tainted one. An already-tainted canvas or video therefore taints the export canvas, and `canvas.toBlob` (~104) then throws `SecurityError`.
+
+## Practical ceiling
+
+Detection can be accurate for the common cases and unknowable for others, so the only safe rule is: **act on positive evidence, and fall back to including the layer when there is none.** "Only show what is on screen" cannot be an absolute guarantee.
diff --git a/docs/pages/APIs/JavaScript/Main/Event-Bus-API.md b/docs/pages/APIs/JavaScript/Main/Event-Bus-API.md
index c3221c59c..631ed0597 100644
--- a/docs/pages/APIs/JavaScript/Main/Event-Bus-API.md
+++ b/docs/pages/APIs/JavaScript/Main/Event-Bus-API.md
@@ -397,6 +397,8 @@ const newState = await window.mmgisAPI.request('layers:toggle', 'myLayerName')
| `time:getCurrent` | none | `string` | Get current time |
| `time:getStart` | none | `string` | Get start time |
| `time:getEnd` | none | `string` | Get end time |
+| `time:getCurrentFormatted` | none | `string` | Current time rendered through the mission's `time.format` (d3 specifiers when it contains a `%`, otherwise moment tokens); `null` until time is enabled and seeded |
+| `time:formatTime` | `string \| number` | `string` | A caller-supplied time rendered through that same mission format; `null` for a missing or unparseable time |
| `time:set` | `{ startTime, endTime, currentTime, ... }` | `boolean` | Set time range |
```javascript
diff --git a/docs/pages/Configure/Tabs/Time/Time_Tab.md b/docs/pages/Configure/Tabs/Time/Time_Tab.md
index dca2ae57c..5e9950509 100644
--- a/docs/pages/Configure/Tabs/Time/Time_Tab.md
+++ b/docs/pages/Configure/Tabs/Time/Time_Tab.md
@@ -24,9 +24,9 @@ If enabled and visible, the Time UI will be initially open on the bottom of the
## Time Format
-The time format to be displayed on the Time UI. Uses D3 time format specifiers: https://github.com/d3/d3-time-format
+The time format to be displayed on the Time UI. Accepts either style: a `%` anywhere in the string selects [D3 time format specifiers](https://d3js.org/d3-time-format#locale_format) - for instance `%Y-%m-%dT%H:%M:%SZ` - otherwise the string is read as [moment.js time format tokens](https://momentjs.com/docs/#/displaying/format/) - for instance `YYYY-MM-DDTHH:mm:ss[Z]`.
-Default: `%Y-%m-%dT%H:%M:%SZ`
+Default: `YYYY-MM-DDTHH:mm:ss[Z]`
## Initial Start Time
diff --git a/mission-profiles/full-demo.json b/mission-profiles/full-demo.json
index bee6b2d38..cf0819186 100644
--- a/mission-profiles/full-demo.json
+++ b/mission-profiles/full-demo.json
@@ -27,7 +27,8 @@
"FetchStats",
"ShareExport",
"LayerFilterThemes",
- "LayerFilter"
+ "LayerFilter",
+ "Timeline"
],
"overrides": {
"Card": {
@@ -196,6 +197,27 @@
"focusedWidth": "",
"expandedSize": "320"
}
+ },
+ {
+ "position": "bottom",
+ "priority": 3,
+ "layoutType": "stacked",
+ "hasHeader": false,
+ "stateConstraints": {
+ "allowedStates": [
+ "expanded"
+ ],
+ "defaultState": "expanded"
+ },
+ "capabilities": {
+ "resizable": false,
+ "supportedOrientation": "horizontal",
+ "maxSize": 200
+ },
+ "panelTools": [
+ "TimelineTool"
+ ],
+ "id": "bottom-panel"
}
],
"floatingPanels": [
@@ -286,7 +308,11 @@
"type": "global",
"format": "%Y-%m-%dT%H:%M:%SZ",
"start": "2015-03-13T00:00:00Z",
- "end": "2026-01-29T00:00:00Z"
+ "end": "2026-01-29T00:00:00Z",
+ "interval": "P1D",
+ "isPeriodic": false,
+ "dataStartTime": "2015-03-13T00:00:00Z",
+ "dataEndTime": "2026-01-29T23:59:59Z"
},
"variables": {
"legendOrientation": "vertical",
@@ -346,7 +372,11 @@
"type": "global",
"format": "%Y-%m-%dT%H:%M:%SZ",
"start": "2023-10-18T00:00:00Z",
- "end": "2025-07-10T00:00:00Z"
+ "end": "2025-07-10T00:00:00Z",
+ "interval": "P1D",
+ "isPeriodic": false,
+ "dataStartTime": "2023-10-18T00:00:00Z",
+ "dataEndTime": "2025-07-10T23:59:59Z"
},
"variables": {
"legendOrientation": "vertical",
@@ -402,7 +432,9 @@
"blend": "none"
},
"time": {
- "enabled": false
+ "enabled": false,
+ "dataStartTime": "2025-01-12T00:00:00Z",
+ "dataEndTime": "2025-01-12T23:59:59Z"
},
"variables": {
"legendOrientation": "vertical",
@@ -518,7 +550,11 @@
"type": "global",
"format": "%Y-%m-%dT%H:%M:%SZ",
"start": "2023-01-15T00:00:00Z",
- "end": "2026-01-28T00:00:00Z"
+ "end": "2026-01-28T00:00:00Z",
+ "interval": "P1D",
+ "isPeriodic": false,
+ "dataStartTime": "2023-01-15T00:00:00Z",
+ "dataEndTime": "2026-01-28T23:59:59Z"
},
"variables": {
"legendOrientation": "vertical",
@@ -584,7 +620,11 @@
"type": "global",
"format": "%Y-%m-%dT%H:%M:%SZ",
"compositeTile": false,
- "refreshIntervalEnabled": false
+ "refreshIntervalEnabled": false,
+ "interval": "P1D",
+ "isPeriodic": false,
+ "dataStartTime": "2024-09-30T00:00:00Z",
+ "dataEndTime": "2024-10-17T23:59:59Z"
},
"variables": {
"legendOrientation": "vertical",
@@ -745,7 +785,11 @@
"type": "global",
"format": "%Y-%m-%dT%H:%M:%SZ",
"start": "2023-01-01T01:59:15Z",
- "end": "2024-11-23T23:49:03Z"
+ "end": "2024-11-23T23:49:03Z",
+ "interval": "PT1S",
+ "isPeriodic": false,
+ "dataStartTime": "2023-01-01T01:59:15Z",
+ "dataEndTime": "2024-11-23T23:49:03Z"
},
"variables": {
"legendOrientation": "vertical",
diff --git a/mission-profiles/generated/full-demo-mission.json b/mission-profiles/generated/full-demo-mission.json
index 73838da09..bf378ab1d 100644
--- a/mission-profiles/generated/full-demo-mission.json
+++ b/mission-profiles/generated/full-demo-mission.json
@@ -147,6 +147,27 @@
"focusedWidth": "",
"expandedSize": "320"
}
+ },
+ {
+ "position": "bottom",
+ "priority": 3,
+ "layoutType": "stacked",
+ "hasHeader": false,
+ "stateConstraints": {
+ "allowedStates": [
+ "expanded"
+ ],
+ "defaultState": "expanded"
+ },
+ "capabilities": {
+ "resizable": false,
+ "supportedOrientation": "horizontal",
+ "maxSize": 200
+ },
+ "panelTools": [
+ "TimelineTool"
+ ],
+ "id": "bottom-panel"
}
],
"floatingPanels": [
@@ -267,7 +288,8 @@
"on": true,
"variables": {
"exportPng": true,
- "exportPdf": true
+ "exportPdf": true,
+ "includeLegend": true
},
"metadata": {
"icon": "share-variant",
@@ -458,6 +480,22 @@
"width": 66,
"height": 0
}
+ },
+ {
+ "name": "Timeline",
+ "icon": "timeline",
+ "js": "TimelineTool",
+ "on": true,
+ "variables": {},
+ "metadata": {
+ "icon": "timeline",
+ "requiredOrientation": "horizontal",
+ "compatiblePositions": [
+ "bottom"
+ ],
+ "preferredPosition": "bottom",
+ "modernLayoutSupport": true
+ }
}
],
"layers": [
@@ -492,7 +530,11 @@
"type": "global",
"format": "%Y-%m-%dT%H:%M:%SZ",
"start": "2015-03-13T00:00:00Z",
- "end": "2026-01-29T00:00:00Z"
+ "end": "2026-01-29T00:00:00Z",
+ "interval": "P1D",
+ "isPeriodic": false,
+ "dataStartTime": "2015-03-13T00:00:00Z",
+ "dataEndTime": "2026-01-29T23:59:59Z"
},
"variables": {
"legendOrientation": "vertical",
@@ -552,7 +594,11 @@
"type": "global",
"format": "%Y-%m-%dT%H:%M:%SZ",
"start": "2023-10-18T00:00:00Z",
- "end": "2025-07-10T00:00:00Z"
+ "end": "2025-07-10T00:00:00Z",
+ "interval": "P1D",
+ "isPeriodic": false,
+ "dataStartTime": "2023-10-18T00:00:00Z",
+ "dataEndTime": "2025-07-10T23:59:59Z"
},
"variables": {
"legendOrientation": "vertical",
@@ -608,7 +654,9 @@
"blend": "none"
},
"time": {
- "enabled": false
+ "enabled": false,
+ "dataStartTime": "2025-01-12T00:00:00Z",
+ "dataEndTime": "2025-01-12T23:59:59Z"
},
"variables": {
"legendOrientation": "vertical",
@@ -724,7 +772,11 @@
"type": "global",
"format": "%Y-%m-%dT%H:%M:%SZ",
"start": "2023-01-15T00:00:00Z",
- "end": "2026-01-28T00:00:00Z"
+ "end": "2026-01-28T00:00:00Z",
+ "interval": "P1D",
+ "isPeriodic": false,
+ "dataStartTime": "2023-01-15T00:00:00Z",
+ "dataEndTime": "2026-01-28T23:59:59Z"
},
"variables": {
"legendOrientation": "vertical",
@@ -790,7 +842,11 @@
"type": "global",
"format": "%Y-%m-%dT%H:%M:%SZ",
"compositeTile": false,
- "refreshIntervalEnabled": false
+ "refreshIntervalEnabled": false,
+ "interval": "P1D",
+ "isPeriodic": false,
+ "dataStartTime": "2024-09-30T00:00:00Z",
+ "dataEndTime": "2024-10-17T23:59:59Z"
},
"variables": {
"legendOrientation": "vertical",
@@ -951,7 +1007,11 @@
"type": "global",
"format": "%Y-%m-%dT%H:%M:%SZ",
"start": "2023-01-01T01:59:15Z",
- "end": "2024-11-23T23:49:03Z"
+ "end": "2024-11-23T23:49:03Z",
+ "interval": "PT1S",
+ "isPeriodic": false,
+ "dataStartTime": "2023-01-01T01:59:15Z",
+ "dataEndTime": "2024-11-23T23:49:03Z"
},
"variables": {
"legendOrientation": "vertical",
diff --git a/src/essence/Basics/Colormaps/colormapNaming.ts b/src/essence/Basics/Colormaps/colormapNaming.ts
new file mode 100644
index 000000000..f01703b1a
--- /dev/null
+++ b/src/essence/Basics/Colormaps/colormapNaming.ts
@@ -0,0 +1,32 @@
+// Colormap naming, core-owned.
+//
+// TiTiler encodes a ramp's direction in its name: `viridis` is the forward
+// ramp, `viridis_r` the reversed one. Both the core raster renderer
+// (MapEngines/Adapters/colormapLUT) and the legend that has to agree with what
+// it painted resolve names through these helpers, so the two can never drift
+// apart. Pure string handling — no imports, no host state.
+
+const REVERSED_SUFFIX = /_r$/i
+
+export const isReversedColormap = (name: string | null | undefined): boolean =>
+ typeof name === 'string' && REVERSED_SUFFIX.test(name)
+
+export const getBaseColormapName = (name: string | null | undefined): string => {
+ if (!name) return ''
+ return name.replace(REVERSED_SUFFIX, '')
+}
+
+/**
+ * Case-insensitively matches a colormap name (optionally `_r`-suffixed)
+ * against a set of canonical keys, returning the key's original casing. The
+ * bundled js-colormaps evaluator keys its ramps by their published casing
+ * (e.g. `RdBu`), so a lowercased comparison is needed either way.
+ */
+export const findColormapKey = (
+ name: string | null | undefined,
+ keys: string[],
+): string | null => {
+ const base = getBaseColormapName(name).toLowerCase()
+ if (!base) return null
+ return keys.find((k) => k.toLowerCase() === base) ?? null
+}
diff --git a/src/essence/Basics/Layers_/Layers_.js b/src/essence/Basics/Layers_/Layers_.js
index 7ef8d83cf..000debbf0 100644
--- a/src/essence/Basics/Layers_/Layers_.js
+++ b/src/essence/Basics/Layers_/Layers_.js
@@ -7,6 +7,7 @@ import Attributions from '../../Ancillary/Attributions'
import ToolController_ from '../../Basics/ToolController_/ToolController_'
import LayerGeologic from './LayerGeologic/LayerGeologic'
import ServiceUrls from '../ServiceUrls/ServiceUrls'
+import { resolveTimePolicy } from '../TimeControl_/layerTimePolicy'
import { MAP_ENGINE, isRasterTileLayerType } from '../MapEngines/types/engine'
import {
getActiveTileLevel,
@@ -27,6 +28,15 @@ import $ from 'jquery'
// Provider cleanup functions for re-initialization
let _providerCleanups = []
+// Resolved at call time so an open-ended "now" is fresh on every ask.
+const temporalExtentFor = (uuid) => {
+ const time = L_.layers.data[uuid]?.time
+ return {
+ start: resolveTimePolicy(time?.dataStartTime),
+ end: resolveTimePolicy(time?.dataEndTime),
+ }
+}
+
/**
* What a layer's COG colormap supports: whether it has one to draw a legend
* ramp from, and whether that ramp can be changed at runtime.
@@ -490,6 +500,21 @@ const L_ = {
})
return capabilities
}),
+ // When each layer has data, as ISO datetimes or null. The
+ // config's dataStartTime/dataEndTime may be a policy ("now",
+ // "now - P1D"); this is where it is resolved, so a plugin
+ // never sees the policy string. Same call shapes as above.
+ window.mmgisAPI.provide('layers:getTemporalExtent', (layerUUID) => {
+ if (layerUUID != null) {
+ const uuid = L_.asLayerUUID(layerUUID)
+ return uuid == null ? null : temporalExtentFor(uuid)
+ }
+ const extents = {}
+ Object.keys(L_.layers.data).forEach((uuid) => {
+ extents[uuid] = temporalExtentFor(uuid)
+ })
+ return extents
+ }),
// Where each layer sits, for moving the map to it. Called with
// a layer identifier it answers for that one layer, resolving a
// name the way every other layer-keyed provider does; called
diff --git a/src/essence/Basics/MapEngines/Adapters/colormapLUT.ts b/src/essence/Basics/MapEngines/Adapters/colormapLUT.ts
index ee9d9c5cb..e675cd514 100644
--- a/src/essence/Basics/MapEngines/Adapters/colormapLUT.ts
+++ b/src/essence/Basics/MapEngines/Adapters/colormapLUT.ts
@@ -2,15 +2,14 @@
// reusing MMGIS's existing js-colormaps evaluator so the client-side render
// and the TiTiler render agree (including matplotlib `_r` reversed variants).
import { evaluate_cmap, data as colormapData } from '../../../../external/js-colormaps/js-colormaps.js'
+import { isReversedColormap, findColormapKey } from '../../Colormaps/colormapNaming'
const FALLBACK = 'viridis'
export function normalizeColormapName(name: string): { name: string; reverse: boolean } {
const raw = (name || '').trim()
- const reverse = /_r$/i.test(raw)
- const base = reverse ? raw.slice(0, -2) : raw
- const keys = Object.keys(colormapData)
- const match = keys.find((k) => k.toLowerCase() === base.toLowerCase())
+ const reverse = isReversedColormap(raw)
+ const match = findColormapKey(raw, Object.keys(colormapData))
return { name: match || FALLBACK, reverse: match ? reverse : false }
}
diff --git a/src/essence/Basics/TimeControl_/TimeControl.js b/src/essence/Basics/TimeControl_/TimeControl.js
index 2b743ef8e..2ea3c471c 100644
--- a/src/essence/Basics/TimeControl_/TimeControl.js
+++ b/src/essence/Basics/TimeControl_/TimeControl.js
@@ -28,6 +28,32 @@ const relativeTimeFormat = new RegExp(
/^(-?)(?:2[0-3]|[01]?[0-9]):[0-5][0-9]:[0-5][0-9]$/
)
+// A mission's time.format is written in one of two languages: d3 time-format
+// (e.g. '%Y-%m-%dT%H:%M:%SZ'), marked by a '%', or moment tokens (e.g.
+// 'YYYY-MM-DDTHH:mm:ss[Z]') as used elsewhere in the app (TimeUI.js,
+// DrawTool_Templater.js's default). Falls back to this when a mission has
+// time enabled but never configured a format.
+const DEFAULT_TIME_FORMAT = 'YYYY-MM-DDTHH:mm:ss[Z]'
+
+// Formats a time through the mission's configured time.format, choosing the
+// formatter that matches the language the format string is written in. Any
+// failure falls back to the default so a malformed format string can't break
+// a caller (e.g. an export stamping the time onto a legend).
+const formatMissionTime = (time) => {
+ const format = L_.configData.time?.format || DEFAULT_TIME_FORMAT
+ try {
+ return format.includes('%')
+ ? utcFormat(format)(new Date(time))
+ : moment.utc(time).format(format)
+ } catch (err) {
+ console.warn(
+ `Invalid 'Time Format' provided. Defaulting to ${DEFAULT_TIME_FORMAT}.`,
+ err
+ )
+ return moment.utc(time).format(DEFAULT_TIME_FORMAT)
+ }
+}
+
var TimeControl = {
enabled: false,
isRelative: true,
@@ -37,7 +63,6 @@ var TimeControl = {
endTime: null,
relativeStartTime: '01:00:00',
relativeEndTime: '00:00:00',
- globalTimeFormat: null,
_updateLockedForAcceptingInput: false,
customTimes: {
times: [],
@@ -63,6 +88,27 @@ var TimeControl = {
// seeded"; the getters below return null for both.
window.mmgisAPI.provide('time:isEnabled', () => TimeControl.enabled === true),
window.mmgisAPI.provide('time:getCurrent', () => TimeControl.getTime()),
+ // Same current time as time:getCurrent, but through the
+ // mission's time.format (d3 or moment style) rather than raw
+ // ISO — null whenever time isn't enabled or not yet seeded,
+ // matching time:isEnabled/getCurrent's own null-until-ready
+ // convention.
+ window.mmgisAPI.provide('time:getCurrentFormatted', () =>
+ TimeControl.enabled && TimeControl.currentTime != null
+ ? formatMissionTime(TimeControl.currentTime)
+ : null
+ ),
+ // Formats a caller-supplied time through that same mission
+ // format, so a plugin displaying a time it holds itself
+ // (e.g. a per-layer window on an exported legend) matches
+ // what TimeControl's UI shows. Deliberately not gated on
+ // TimeControl.enabled: the time comes from the caller, not
+ // from the cursor. Null for a missing or unparseable time.
+ window.mmgisAPI.provide('time:formatTime', (time) =>
+ time != null && !isNaN(new Date(time).getTime())
+ ? formatMissionTime(time)
+ : null
+ ),
window.mmgisAPI.provide('time:getStart', () => TimeControl.getStartTime()),
window.mmgisAPI.provide('time:getEnd', () => TimeControl.getEndTime()),
window.mmgisAPI.provide('time:set', (params) => {
@@ -83,9 +129,6 @@ var TimeControl = {
if (L_.configData.time && L_.configData.time.enabled === true) {
TimeControl.enabled = true
- TimeControl.globalTimeFormat = utcFormat(
- L_.configData.time.format
- )
} else {
TimeControl.enabled = false
return
diff --git a/src/essence/Basics/TimeControl_/layerTimePolicy.ts b/src/essence/Basics/TimeControl_/layerTimePolicy.ts
new file mode 100644
index 000000000..80c3a1254
--- /dev/null
+++ b/src/essence/Basics/TimeControl_/layerTimePolicy.ts
@@ -0,0 +1,97 @@
+/**
+ * Layer time policies: a layer's `dataStartTime`/`dataEndTime` may be a
+ * concrete ISO datetime — or a policy string that stays true as time
+ * passes, so configs for growing/forecast collections never go stale:
+ *
+ * "now" the current moment
+ * "now - P1D" an ISO-8601 duration before now
+ * "now + P5D" a duration after now (forecast windows)
+ *
+ * "now" resolves to the raw current moment, never rounded — matching
+ * veda-ui, which normalizes an ongoing (null-ended) STAC domain to the
+ * current datetime as-is.
+ *
+ * Core owns this vocabulary. Plugins never resolve it themselves: they ask
+ * `layers:getTemporalExtent` and receive plain ISO datetimes.
+ */
+
+const DURATION_RE =
+ /^P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/
+
+const POLICY_RE = /^now(?:\s*([+-])\s*(\S+))?$/
+
+export interface Duration {
+ years: number
+ months: number
+ weeks: number
+ days: number
+ hours: number
+ minutes: number
+ seconds: number
+}
+
+export function parseISODuration(value: string): Duration | null {
+ const m = DURATION_RE.exec(value)
+ if (!m || value === 'P' || value.endsWith('T')) return null
+ const [, years, months, weeks, days, hours, minutes, seconds] = m
+ const duration = {
+ years: Number(years || 0),
+ months: Number(months || 0),
+ weeks: Number(weeks || 0),
+ days: Number(days || 0),
+ hours: Number(hours || 0),
+ minutes: Number(minutes || 0),
+ seconds: Number(seconds || 0),
+ }
+ // A zero-length duration ("P0D") is no duration at all: nothing to offset
+ // by, and no period it could ever contain.
+ const total = Object.values(duration).reduce((sum, part) => sum + part, 0)
+ return total > 0 ? duration : null
+}
+
+// Months and years are not fixed millisecond amounts — apply them with UTC
+// date-component math, never ms arithmetic.
+export function addDuration(date: Date, d: Duration, sign: 1 | -1): Date {
+ const out = new Date(date)
+ out.setUTCFullYear(out.getUTCFullYear() + sign * d.years)
+ out.setUTCMonth(out.getUTCMonth() + sign * d.months)
+ out.setUTCDate(out.getUTCDate() + sign * (d.days + 7 * d.weeks))
+ out.setUTCHours(out.getUTCHours() + sign * d.hours)
+ out.setUTCMinutes(out.getUTCMinutes() + sign * d.minutes)
+ out.setUTCSeconds(out.getUTCSeconds() + sign * d.seconds)
+ return out
+}
+
+function toIso(date: Date): string {
+ return date.toISOString().split('.')[0] + 'Z'
+}
+
+/**
+ * Resolves a data time value — concrete or policy — to an ISO datetime
+ * string, or null when the value is absent or unparseable (callers keep
+ * their own fallback; a bad value must never break a consumer).
+ *
+ * @param value - `time.dataStartTime` / `time.dataEndTime`.
+ * @param options.now - Injectable current moment (tests).
+ */
+export function resolveTimePolicy(
+ value: string | null | undefined,
+ options: { now?: Date } = {}
+): string | null {
+ if (value == null || value === '') return null
+
+ const policy = POLICY_RE.exec(value.trim())
+ if (policy == null) {
+ const concrete = new Date(value)
+ return isNaN(concrete.getTime()) ? null : toIso(concrete)
+ }
+
+ let resolved = options.now != null ? new Date(options.now) : new Date()
+ const [, sign, offset] = policy
+ if (offset != null) {
+ const duration = parseISODuration(offset)
+ if (duration == null) return null
+ resolved = addDuration(resolved, duration, sign === '-' ? -1 : 1)
+ }
+ return toIso(resolved)
+}
diff --git a/src/essence/Tools/LayerManager/MMGISLayerManagerAdapter.tsx b/src/essence/Tools/LayerManager/MMGISLayerManagerAdapter.tsx
index 80b802ca1..99f03195f 100644
--- a/src/essence/Tools/LayerManager/MMGISLayerManagerAdapter.tsx
+++ b/src/essence/Tools/LayerManager/MMGISLayerManagerAdapter.tsx
@@ -1,11 +1,11 @@
import React from 'react'
import { useState, useCallback } from 'react'
import { LayerManagerPanel } from './lib'
-import type { Layer } from './lib/types'
+import type { Layer } from '../_shared/legend/types'
import { useMMGISEvent } from '../_shared/adapters/useMMGISEvent'
import { useMMGISToolVars } from '../_shared/adapters/useMMGISToolVars'
import { useMMGISHandlerReady } from '../_shared/adapters/useMMGISHandlerReady'
-import { getVisibleLayersWithLegends } from './adapters/getVisibleLayersWithLegends'
+import { getVisibleLayersWithLegends } from '../_shared/legend/getVisibleLayersWithLegends'
import { renderDescription } from './adapters/renderDescription'
import {
toggleVisibility,
diff --git a/src/essence/Tools/LayerManager/__tests__/LayerManagerPanel.spec.tsx b/src/essence/Tools/LayerManager/__tests__/LayerManagerPanel.spec.tsx
index d49578a36..1788b3604 100644
--- a/src/essence/Tools/LayerManager/__tests__/LayerManagerPanel.spec.tsx
+++ b/src/essence/Tools/LayerManager/__tests__/LayerManagerPanel.spec.tsx
@@ -1,7 +1,7 @@
import React from 'react'
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'
import { LayerManagerPanel } from '../lib/geo/LayerManagerPanel/LayerManagerPanel'
-import type { Layer } from '../lib/types'
+import type { Layer } from '../../_shared/legend/types'
import { mount, click } from './reactHarness'
/**
diff --git a/src/essence/Tools/LayerManager/__tests__/buildLayerLegendData.spec.js b/src/essence/Tools/LayerManager/__tests__/buildLayerLegendData.spec.js
deleted file mode 100644
index 34045f700..000000000
--- a/src/essence/Tools/LayerManager/__tests__/buildLayerLegendData.spec.js
+++ /dev/null
@@ -1,172 +0,0 @@
-import { test, expect } from 'vitest'
-import { buildLayerLegendData } from '../adapters/buildLayerLegendData.ts'
-
-// The three verdicts core can return for a layer. A colormap that can be
-// shown but not changed is what an `image` layer reports: it paints from the
-// same cog* fields, but bakes them in at construction.
-const NO_COG = { hasColormap: false, canChangeColormap: false }
-const EDITABLE_COG = { hasColormap: true, canChangeColormap: true }
-const READ_ONLY_COG = { hasColormap: true, canChangeColormap: false }
-
-test.describe('buildLayerLegendData', () => {
- test('returns text type for layer with no legend and no COG', () => {
- const result = buildLayerLegendData('layer1', { display_name: 'L1' }, null, true, NO_COG)
- expect(result.id).toBe('layer1')
- expect(result.title).toBe('L1')
- expect(result.type).toBe('none')
- expect(result.cog).toBeNull()
- expect(result.visible).toBe(true)
- })
-
- test('builds gradient legend from continuous shape', () => {
- const legend = [
- { shape: 'continuous', color: '#000000', value: '0 m' },
- { shape: 'continuous', color: '#ffffff', value: '100 m' },
- ]
- const result = buildLayerLegendData('layer2', { _legend: legend }, { layer2: 0.5 }, true, NO_COG)
- expect(result.type).toBe('gradient')
- expect(result.stops).toEqual(['#000000', '#ffffff'])
- expect(result.min).toBe(0)
- expect(result.max).toBe(100)
- expect(result.unit).toEqual({ label: 'm' })
- expect(result.opacity).toBe(0.5)
- })
-
- test('builds categorical legend and filters hidden entries', () => {
- const legend = [
- { color: '#ff0000', value: 'water' },
- { color: '#00ff00', value: 'land', hideFromLegend: true },
- { color: '#0000ff', value: 'sky' },
- ]
- const result = buildLayerLegendData('layer3', { _legend: legend }, null, true, NO_COG)
- expect(result.type).toBe('categorical')
- expect(result.categoricalStops).toEqual([
- { color: '#ff0000', label: 'water' },
- { color: '#0000ff', label: 'sky' },
- ])
- })
-
- test('produces COG metadata for a colormap-capable layer', () => {
- const cfg = {
- cogColormap: 'plasma',
- cogMin: 0,
- cogMax: 1000,
- cogUnits: 'm',
- }
- const result = buildLayerLegendData(
- 'layer4', cfg, null, true, EDITABLE_COG, 'https://example.com/titiler',
- )
- expect(result.cog).not.toBeNull()
- expect(result.cog?.titilerUrl).toBe('https://example.com/titiler')
- expect(result.cog?.colormap).toBe('plasma')
- expect(result.cog?.defaultMin).toBe(0)
- expect(result.cog?.defaultMax).toBe(1000)
- expect(result.cog?.editable).toBe(true)
- expect(result.type).toBe('gradient')
- expect(result.stops).toBeNull()
- })
-
- // Core's answer already accounts for the mission-wide override, so a raw
- // config read would disagree with the service the tiles are drawn from.
- test('takes the service URL from core, not from the raw layer config', () => {
- const cfg = { cogColormap: 'plasma', titilerUrl: 'https://from-config.test' }
- const result = buildLayerLegendData(
- 'layer4b', cfg, null, true, EDITABLE_COG, 'https://from-core.test',
- )
- expect(result.cog?.titilerUrl).toBe('https://from-core.test')
- })
-
- test('leaves the service URL null when core resolves none', () => {
- const cfg = { cogColormap: 'plasma' }
- const result = buildLayerLegendData('layer4c', cfg, null, true, EDITABLE_COG)
- expect(result.cog?.titilerUrl).toBeNull()
- })
-
- test('prefers the current colormap and rescale over the configured ones', () => {
- const cfg = {
- cogColormap: 'viridis',
- cogMin: 0,
- cogMax: 1,
- currentCogColormap: 'rdbu_r',
- currentCogMin: -0.1,
- currentCogMax: 0.2,
- }
- const result = buildLayerLegendData('layer5', cfg, null, true, EDITABLE_COG)
- expect(result.cog?.colormap).toBe('rdbu_r')
- expect(result.cog?.min).toBe(-0.1)
- expect(result.cog?.max).toBe(0.2)
- // The defaults stay pinned to the mission config so the control can
- // offer a reset.
- expect(result.cog?.defaultColormap).toBe('viridis')
- expect(result.cog?.defaultMin).toBe(0)
- expect(result.cog?.defaultMax).toBe(1)
- })
-
- // The common shape for a mission raster: a configured legend AND a COG
- // colormap. The legend decides how the bar is drawn; the COG block has to
- // survive that branch or the layer silently loses its colormap controls.
- test('keeps COG metadata for a capable layer that also has a legend', () => {
- const legend = [
- { shape: 'continuous', color: '#000000', value: '0 m' },
- { shape: 'continuous', color: '#ffffff', value: '10 m' },
- ]
- const cfg = { _legend: legend, cogColormap: 'viridis', cogMin: 0, cogMax: 10 }
- const result = buildLayerLegendData('layer6', cfg, null, true, EDITABLE_COG)
- expect(result.type).toBe('gradient')
- expect(result.stops).toEqual(['#000000', '#ffffff'])
- expect(result.cog).not.toBeNull()
- expect(result.cog?.editable).toBe(true)
- })
-
- test('keeps COG metadata for a capable layer with a categorical legend', () => {
- const legend = [
- { color: '#ff0000', value: 'water' },
- { color: '#0000ff', value: 'sky' },
- ]
- const cfg = { _legend: legend, cogColormap: 'viridis' }
- const result = buildLayerLegendData('layer7', cfg, null, true, EDITABLE_COG)
- expect(result.type).toBe('categorical')
- expect(result.cog).not.toBeNull()
- })
-
- // An `image` layer: the ramp and its bounds are shown, but nothing offers
- // to change them.
- test('marks a showable but unchangeable colormap uneditable', () => {
- const cfg = { cogColormap: 'viridis', cogMin: 0, cogMax: 4000, cogUnits: 'm' }
- const result = buildLayerLegendData('layer8', cfg, null, true, READ_ONLY_COG)
- expect(result.cog).not.toBeNull()
- expect(result.cog?.editable).toBe(false)
- expect(result.cog?.colormap).toBe('viridis')
- // The gradient bar still draws, over the COG bounds.
- expect(result.type).toBe('gradient')
- expect(result.min).toBe(0)
- expect(result.max).toBe(4000)
- expect(result.unit).toEqual({ label: 'm' })
- })
-
- test('keeps a legend gradient when the layer has no COG colormap', () => {
- const legend = [
- { shape: 'continuous', color: '#000000', value: '0' },
- { shape: 'continuous', color: '#ffffff', value: '10' },
- ]
- const cfg = { _legend: legend, cogColormap: 'viridis' }
- const result = buildLayerLegendData('layer9', cfg, null, true, NO_COG)
- expect(result.type).toBe('gradient')
- expect(result.cog).toBeNull()
- })
-
- test('leaves a layer without COG metadata when core reports none', () => {
- const cfg = { cogColormap: 'viridis', cogMin: 0, cogMax: 1 }
- const result = buildLayerLegendData('layer10', cfg, null, true, NO_COG)
- expect(result.cog).toBeNull()
- expect(result.type).toBe('none')
- })
-
- // Core without the handler answers null rather than a verdict; that must
- // read as "no COG", not throw.
- test('leaves a layer without COG metadata when core answers nothing', () => {
- const cfg = { cogColormap: 'viridis', cogMin: 0, cogMax: 1 }
- expect(buildLayerLegendData('layer11', cfg, null, true, null).cog).toBeNull()
- expect(buildLayerLegendData('layer11', cfg, null, true, undefined).cog).toBeNull()
- })
-})
diff --git a/src/essence/Tools/LayerManager/__tests__/libBoundary.spec.js b/src/essence/Tools/LayerManager/__tests__/libBoundary.spec.js
index f942bda99..f4a503266 100644
--- a/src/essence/Tools/LayerManager/__tests__/libBoundary.spec.js
+++ b/src/essence/Tools/LayerManager/__tests__/libBoundary.spec.js
@@ -22,6 +22,25 @@ import { join, relative, resolve, dirname } from 'node:path'
const PLUGIN_ROOT = resolve(process.cwd(), 'src/essence/Tools/LayerManager')
const LIB_ROOT = join(PLUGIN_ROOT, 'lib')
+// Only these four modules under _shared/legend are host-agnostic — pure
+// types, formatting rules, and colormap-list/cache logic with no dependency
+// on host state, a host bus, or a network call — so lib/ may reach them the
+// same way it reaches its own modules. `colormaps` re-exports the naming
+// primitives from Basics/Colormaps/colormapNaming, which is core-owned but
+// equally pure (no imports, no host state), so the portable half stays
+// portable. Everything else in that directory is NOT exempt:
+// getVisibleLayersWithLegends and getExportLegendModel import mmgisAPI (a
+// host bus client), and resolveColormapColors makes a relative import into
+// src/external/ — all host-coupled in ways lib/ must never be.
+const SHARED_LEGEND_ROOT = resolve(PLUGIN_ROOT, '../_shared/legend')
+const SHARED_LEGEND_ALLOWLIST = ['types', 'format', 'colormaps', 'colormapCache']
+
+const isAllowedSharedLegendImport = (target) => {
+ if (dirname(target) !== SHARED_LEGEND_ROOT) return false
+ const base = target.replace(/\.(ts|tsx|js|jsx)$/, '').split('/').pop()
+ return SHARED_LEGEND_ALLOWLIST.includes(base)
+}
+
const SOURCE_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx']
const sourceFilesUnder = (dir) => {
@@ -68,6 +87,7 @@ describe('lib/ is free of the host', () => {
if (!specifier.startsWith('.')) continue
const target = resolve(dirname(file), specifier)
if (target.startsWith(LIB_ROOT)) continue
+ if (isAllowedSharedLegendImport(target)) continue
escaping.push(
`${relative(PLUGIN_ROOT, file)} -> ${specifier}`,
)
@@ -126,4 +146,28 @@ describe('lib/ is free of the host', () => {
expect(/\bmmgis(API|global)\b/i.test("window.mmgisglobal?.WITH_TITILER === 'true'"))
.toBe(true)
})
+
+ test('the shared-legend allowlist is scoped to the host-agnostic files, not the whole directory', () => {
+ const allowed = (name) =>
+ isAllowedSharedLegendImport(join(SHARED_LEGEND_ROOT, name))
+ // What lib/ actually imports today — must stay allowed.
+ expect(allowed('types')).toBe(true)
+ expect(allowed('format')).toBe(true)
+ expect(allowed('colormaps')).toBe(true)
+ expect(allowed('colormapCache')).toBe(true)
+ // Host-coupled modules in the same directory — a lib/ import of any
+ // of these must now fail the "no relative import escapes lib/"
+ // check above, the way it would for any other host module.
+ expect(allowed('getExportLegendModel')).toBe(false)
+ expect(allowed('getVisibleLayersWithLegends')).toBe(false)
+ expect(allowed('resolveColormapColors')).toBe(false)
+ // A file outside _shared/legend entirely, even with an allowlisted
+ // basename, is not exempt — including a core module.
+ expect(isAllowedSharedLegendImport(join(PLUGIN_ROOT, '../_shared/adapters/types'))).toBe(false)
+ expect(
+ isAllowedSharedLegendImport(
+ resolve(PLUGIN_ROOT, '../../Basics/Colormaps/colormapNaming'),
+ ),
+ ).toBe(false)
+ })
})
diff --git a/src/essence/Tools/LayerManager/adapters/buildLayerLegendData.ts b/src/essence/Tools/LayerManager/adapters/buildLayerLegendData.ts
deleted file mode 100644
index 41a52ad5f..000000000
--- a/src/essence/Tools/LayerManager/adapters/buildLayerLegendData.ts
+++ /dev/null
@@ -1,137 +0,0 @@
-import type { CogCapabilities } from '../../_shared/adapters/mmgisAPI'
-import type { Layer, LegendType, CategoricalStop, CogData } from '../lib/types'
-
-type MMGISLegendEntry = {
- shape?: string
- color?: string
- value?: string | number
- label?: string
- hideFromLegend?: boolean
-}
-
-type MMGISLayerConfig = {
- display_name?: string
- description?: string
- _legend?: MMGISLegendEntry[]
- currentCogColormap?: string
- cogColormap?: string
- currentCogMin?: number
- cogMin?: number
- currentCogMax?: number
- cogMax?: number
- cogUnits?: string | null
-}
-
-const detectLegendType = (legend: MMGISLegendEntry[] | undefined): LegendType => {
- if (!Array.isArray(legend) || legend.length === 0) return 'text'
- const first = legend[0]
- if (first.shape === 'continuous' || first.shape === 'discreet') return 'gradient'
- if (first.color && first.value !== undefined) return 'categorical'
- return 'text'
-}
-
-const buildGradientFields = (legend: MMGISLegendEntry[]) => {
- const stops = legend.map((entry) => entry.color || '')
- const values = legend.map((entry) => {
- const parsed = parseFloat(String(entry.value))
- return isNaN(parsed) ? entry.value : parsed
- })
- const numericValues = values.filter((v): v is number => typeof v === 'number')
- const min = numericValues.length > 0
- ? Math.min(...numericValues)
- : (values[values.length - 1] as number)
- const max = numericValues.length > 0
- ? Math.max(...numericValues)
- : (values[0] as number)
- let unit: { label: string } | null = null
- const firstVal = legend[0]?.value
- if (firstVal !== undefined) {
- const match = String(firstVal).match(/[\d.]+\s*(.+)/)
- if (match && match[1]) unit = { label: match[1].trim() }
- }
- return { stops, min, max, unit }
-}
-
-const buildCategoricalFields = (legend: MMGISLegendEntry[]): CategoricalStop[] =>
- legend
- .filter((entry) => !entry.hideFromLegend)
- .map((entry) => ({
- color: entry.color || '',
- label: String(entry.value || entry.label || ''),
- }))
-
-/**
- * Shapes one layer's config into the legend model the UI renders.
- *
- * `cogCapabilities` comes from core over the request bus and carries two
- * separate answers: `hasColormap` builds the COG block, so the legend draws
- * the ramp and its bounds, while `canChangeColormap` decides whether that
- * block is editable. A layer can have the first without the second.
- *
- * `titilerUrl` likewise comes from core, already resolved; null leaves the
- * ramp swatches with nowhere to load from.
- */
-export const buildLayerLegendData = (
- layerName: string,
- layerConfig: MMGISLayerConfig,
- opacities: Record | null | undefined,
- visible: boolean,
- cogCapabilities: CogCapabilities | null | undefined,
- titilerUrl: string | null = null,
-): Layer => {
- const opacity = opacities?.[layerName] ?? 1
-
- const cog: CogData | null = cogCapabilities?.hasColormap
- ? {
- isCog: true,
- editable: cogCapabilities.canChangeColormap === true,
- colormap: layerConfig.currentCogColormap || layerConfig.cogColormap || 'viridis',
- min: layerConfig.currentCogMin ?? layerConfig.cogMin ?? 0,
- max: layerConfig.currentCogMax ?? layerConfig.cogMax ?? 255,
- defaultMin: layerConfig.cogMin ?? 0,
- defaultMax: layerConfig.cogMax ?? 255,
- defaultColormap: layerConfig.cogColormap || 'viridis',
- units: layerConfig.cogUnits ?? null,
- titilerUrl,
- }
- : null
-
- const base: Layer = {
- id: layerName,
- title: layerConfig.display_name || layerName,
- description: layerConfig.description || null,
- opacity,
- visible,
- type: 'none',
- cog,
- }
-
- const legend = layerConfig._legend
- if (!legend || (Array.isArray(legend) && legend.length === 0)) {
- if (cog) {
- return {
- ...base,
- type: 'gradient',
- min: cog.min,
- max: cog.max,
- stops: null,
- unit: cog.units ? { label: cog.units } : null,
- }
- }
- return base
- }
-
- const legendType = detectLegendType(legend)
- if (legendType === 'gradient') {
- const { stops, min, max, unit } = buildGradientFields(legend)
- return { ...base, type: 'gradient', stops, min, max, unit }
- }
- if (legendType === 'categorical') {
- return {
- ...base,
- type: 'categorical',
- categoricalStops: buildCategoricalFields(legend),
- }
- }
- return { ...base, type: 'text' }
-}
diff --git a/src/essence/Tools/LayerManager/lib/geo/ColorRampPicker/ColorRampPicker.tsx b/src/essence/Tools/LayerManager/lib/geo/ColorRampPicker/ColorRampPicker.tsx
index b337e814c..1e65c5ff0 100644
--- a/src/essence/Tools/LayerManager/lib/geo/ColorRampPicker/ColorRampPicker.tsx
+++ b/src/essence/Tools/LayerManager/lib/geo/ColorRampPicker/ColorRampPicker.tsx
@@ -17,7 +17,7 @@ import {
isReversedColormap,
toForwardColormapNames,
validateRescale,
-} from '../../utils/colormaps'
+} from '../../../../_shared/legend/colormaps'
export type ColorRampPickerProps = {
layerId: string
diff --git a/src/essence/Tools/LayerManager/lib/geo/ColorRampPicker/ColorRampSwatch.tsx b/src/essence/Tools/LayerManager/lib/geo/ColorRampPicker/ColorRampSwatch.tsx
index 94a80c59f..f3a48855e 100644
--- a/src/essence/Tools/LayerManager/lib/geo/ColorRampPicker/ColorRampSwatch.tsx
+++ b/src/essence/Tools/LayerManager/lib/geo/ColorRampPicker/ColorRampSwatch.tsx
@@ -1,7 +1,7 @@
import React from 'react'
import { memo, useEffect, useRef, useState, type RefObject } from 'react'
import { useColormapColors } from '../../hooks/useColormapColors'
-import { buildGradientCss, formatColormapLabel } from '../../utils/colormaps'
+import { buildGradientCss, formatColormapLabel } from '../../../../_shared/legend/colormaps'
export type ColorRampSwatchProps = {
/** Forward ramp name; direction comes from `reversed`. */
diff --git a/src/essence/Tools/LayerManager/lib/geo/GradientGraphic/GradientGraphic.tsx b/src/essence/Tools/LayerManager/lib/geo/GradientGraphic/GradientGraphic.tsx
index 181a1877f..dc5caf945 100644
--- a/src/essence/Tools/LayerManager/lib/geo/GradientGraphic/GradientGraphic.tsx
+++ b/src/essence/Tools/LayerManager/lib/geo/GradientGraphic/GradientGraphic.tsx
@@ -2,7 +2,8 @@ import React from 'react'
import { useState, useCallback, useRef, type MouseEvent } from 'react'
import { scaleLinear } from 'd3'
import { useColormapColors } from '../../hooks/useColormapColors'
-import { buildGradientCss, isReversedColormap } from '../../utils/colormaps'
+import { buildGradientCss, isReversedColormap } from '../../../../_shared/legend/colormaps'
+import { formatLegendValue, formatLegendBound } from '../../../../_shared/legend/format'
import type { CogData } from '../../types'
export type GradientGraphicProps = {
@@ -13,27 +14,6 @@ export type GradientGraphicProps = {
cog?: CogData | null
}
-const formatLegendValue = (val: number | string): string | number => {
- const num = Number(val)
- if (isNaN(num)) return val
- if (num === 0) return 0
- if (Math.abs(num) < 9999 && Math.abs(num) > 0.0009) {
- return parseFloat(num.toFixed(3))
- }
- return num.toExponential(2)
-}
-
-const formatTooltipValue = (rawVal: number, unit?: { label: string } | null): string => {
- if (rawVal === 0) return unit?.label ? `0 ${unit.label}` : '0'
- let value: number | string
- if (Math.abs(rawVal) < 9999 && Math.abs(rawVal) > 0.0009) {
- value = parseFloat(rawVal.toFixed(3))
- } else {
- value = rawVal.toExponential(2)
- }
- return unit?.label ? `${value} ${unit.label}` : String(value)
-}
-
export function GradientGraphic({ stops, min, max, unit, cog }: GradientGraphicProps) {
const [hoverVal, setHoverVal] = useState(null)
const [tooltipPos, setTooltipPos] = useState({ x: 0 })
@@ -88,7 +68,7 @@ export function GradientGraphic({ stops, min, max, unit, cog }: GradientGraphicP
className="blocks-gradient-graphic__tooltip blocks-gradient-graphic__tooltip--visible"
style={{ left: tooltipPos.x }}
>
- {formatTooltipValue(hoverVal, unit)}
+ {formatLegendBound(hoverVal, unit?.label ?? null)}
)}
diff --git a/src/essence/Tools/LayerManager/lib/geo/LayerLegend/LayerLegend.tsx b/src/essence/Tools/LayerManager/lib/geo/LayerLegend/LayerLegend.tsx
index a1c8476bc..55fb76f58 100644
--- a/src/essence/Tools/LayerManager/lib/geo/LayerLegend/LayerLegend.tsx
+++ b/src/essence/Tools/LayerManager/lib/geo/LayerLegend/LayerLegend.tsx
@@ -388,8 +388,8 @@ export function LayerLegend({
(null)
const vars = useMMGISToolVars('mapcontrol')
+ // Shared with ShareExport's adapter — see _shared/share/resolveIncludeLegend.
+ const includeLegend = resolveIncludeLegend(vars)
// Same handler pattern as MMGISShareExportAdapter, wired to the shared
// share actions.
@@ -71,23 +74,23 @@ export function MMGISMapControlAdapter() {
const handleDownloadPng = useCallback(async () => {
setShareBusy(true)
try {
- await downloadSharePng()
+ await downloadSharePng({ includeLegend })
} catch (err) {
console.error('MapControl: PNG download failed', err)
} finally {
setShareBusy(false)
}
- }, [])
+ }, [includeLegend])
const handleDownloadPdf = useCallback(async () => {
setShareBusy(true)
try {
- await downloadSharePdf()
+ await downloadSharePdf({ includeLegend })
} catch (err) {
console.error('MapControl: PDF download failed', err)
} finally {
setShareBusy(false)
}
- }, [])
+ }, [includeLegend])
// Default ON; a saved false/0 disables the feature.
const showBasemapSwitcher = !isFalsy(vars.showBasemapSwitcher)
diff --git a/src/essence/Tools/MapControl/config.json b/src/essence/Tools/MapControl/config.json
index e0de47565..8dd8e070c 100644
--- a/src/essence/Tools/MapControl/config.json
+++ b/src/essence/Tools/MapControl/config.json
@@ -66,6 +66,14 @@
"type": "checkbox",
"width": 3,
"defaultChecked": true
+ },
+ {
+ "field": "variables.includeLegend",
+ "name": "Include Legend On Exports",
+ "description": "Append a legend below exported PNGs and PDFs: layer names, color bars with ranges and units, and the selected date.",
+ "type": "checkbox",
+ "width": 3,
+ "defaultChecked": true
}
]
}
diff --git a/src/essence/Tools/ShareExport/MMGISShareExportAdapter.tsx b/src/essence/Tools/ShareExport/MMGISShareExportAdapter.tsx
index 19fbb8a0b..f76f7c741 100644
--- a/src/essence/Tools/ShareExport/MMGISShareExportAdapter.tsx
+++ b/src/essence/Tools/ShareExport/MMGISShareExportAdapter.tsx
@@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import { ShareMenu } from './lib'
import { mmgisRequest } from '../_shared/adapters/mmgisAPI'
import { useMMGISHandlerReady } from '../_shared/adapters/useMMGISHandlerReady'
+import { resolveIncludeLegend } from '../_shared/share'
import {
resolveShareFormats,
type ShareToolVars,
@@ -26,6 +27,7 @@ const COPIED_RESET_MS = 1800
*/
export function MMGISShareExportAdapter() {
const [formats, setFormats] = useState(DEFAULT_FORMATS)
+ const [includeLegend, setIncludeLegend] = useState(true)
const [busy, setBusy] = useState(false)
const [copied, setCopied] = useState(false)
const copiedTimer = useRef(null)
@@ -37,6 +39,7 @@ export function MMGISShareExportAdapter() {
PLUGIN_ID,
)
setFormats(resolveShareFormats(vars))
+ setIncludeLegend(resolveIncludeLegend(vars))
} catch (err) {
console.error('ShareExport: refresh failed', err)
}
@@ -74,24 +77,24 @@ export function MMGISShareExportAdapter() {
const handleDownloadPng = useCallback(async () => {
setBusy(true)
try {
- await downloadSharePng()
+ await downloadSharePng({ includeLegend })
} catch (err) {
console.error('ShareExport: PNG download failed', err)
} finally {
setBusy(false)
}
- }, [])
+ }, [includeLegend])
const handleDownloadPdf = useCallback(async () => {
setBusy(true)
try {
- await downloadSharePdf()
+ await downloadSharePdf({ includeLegend })
} catch (err) {
console.error('ShareExport: PDF download failed', err)
} finally {
setBusy(false)
}
- }, [])
+ }, [includeLegend])
return (
the
+ * adapter's resolved flag -> the `includeLegend` dep the export action reads.
+ * Nothing downstream fails loudly when a link in that chain breaks — the
+ * export just quietly always (or never) carries a legend — so the chain is
+ * asserted end to end here.
+ */
+
+const shareActionCalls: { name: string; deps: { includeLegend?: boolean } }[] = []
+
+vi.mock('../../_shared/adapters/shareActions', () => ({
+ copyShareLink: async () => 'https://mmgis/?v=1',
+ downloadSharePng: async (deps = {}) => {
+ shareActionCalls.push({ name: 'png', deps })
+ return null
+ },
+ downloadSharePdf: async (deps = {}) => {
+ shareActionCalls.push({ name: 'pdf', deps })
+ return null
+ },
+}))
+
+// The presentational menu is replaced by a probe that hands back the
+// callbacks, so the spec drives the adapter's handlers directly rather than
+// through the dropdown's DOM.
+let menuProps: { onDownloadPng?: () => void; onDownloadPdf?: () => void } = {}
+vi.mock('../lib', () => ({
+ ShareMenu: (props: typeof menuProps) => {
+ menuProps = props
+ return null
+ },
+}))
+
+const { MMGISShareExportAdapter } = await import('../MMGISShareExportAdapter')
+
+;(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean })
+ .IS_REACT_ACT_ENVIRONMENT = true
+
+const setVars = (vars: Record) => {
+ window.mmgisAPI = {
+ request: async (name: string) =>
+ name === 'tool:getVars' ? vars : null,
+ hasHandler: (name: string) => name === 'tool:getVars',
+ on: () => () => {},
+ emit: () => {},
+ } as unknown as Window['mmgisAPI']
+}
+
+const mountAdapter = async () => {
+ const container = document.createElement('div')
+ document.body.appendChild(container)
+ await act(async () => {
+ createRoot(container).render( )
+ })
+}
+
+describe('MMGISShareExportAdapter includeLegend wiring', () => {
+ afterEach(() => {
+ shareActionCalls.length = 0
+ menuProps = {}
+ delete window.mmgisAPI
+ })
+
+ test('passes the resolved flag into both export actions', async () => {
+ setVars({})
+ await mountAdapter()
+ await act(async () => {
+ menuProps.onDownloadPng?.()
+ })
+ await act(async () => {
+ menuProps.onDownloadPdf?.()
+ })
+ expect(shareActionCalls).toEqual([
+ { name: 'png', deps: { includeLegend: true } },
+ { name: 'pdf', deps: { includeLegend: true } },
+ ])
+ })
+
+ // Configure persists an unchecked checkbox as the string 'false'.
+ test("a saved 'false' turns the legend off for the export", async () => {
+ setVars({ includeLegend: 'false' })
+ await mountAdapter()
+ await act(async () => {
+ menuProps.onDownloadPng?.()
+ })
+ expect(shareActionCalls).toEqual([
+ { name: 'png', deps: { includeLegend: false } },
+ ])
+ })
+
+ test('a saved false boolean turns the legend off for the export', async () => {
+ setVars({ includeLegend: false })
+ await mountAdapter()
+ await act(async () => {
+ menuProps.onDownloadPdf?.()
+ })
+ expect(shareActionCalls).toEqual([
+ { name: 'pdf', deps: { includeLegend: false } },
+ ])
+ })
+})
diff --git a/src/essence/Tools/ShareExport/adapters/shareConfig.ts b/src/essence/Tools/ShareExport/adapters/shareConfig.ts
index 01d790e8f..4c8494834 100644
--- a/src/essence/Tools/ShareExport/adapters/shareConfig.ts
+++ b/src/essence/Tools/ShareExport/adapters/shareConfig.ts
@@ -2,10 +2,14 @@
// enabled export formats. The share link is always available; PNG and PDF are
// each toggleable per-dashboard and default to on (so an unset/undefined value
// is treated as enabled, matching the config's defaultChecked: true).
+//
+// includeLegend resolution lives in _shared/share/resolveIncludeLegend —
+// shared with the MapControl adapter, which toggles the same export flag.
export type ShareToolVars = {
exportPng?: boolean
exportPdf?: boolean
+ includeLegend?: boolean
}
// Structurally identical to lib/types' ShareFormatFlags by design: the adapter
diff --git a/src/essence/Tools/ShareExport/config.json b/src/essence/Tools/ShareExport/config.json
index c0fd27d1c..b072002d1 100644
--- a/src/essence/Tools/ShareExport/config.json
+++ b/src/essence/Tools/ShareExport/config.json
@@ -2,16 +2,18 @@
"defaults": {
"variables": {
"exportPng": true,
- "exportPdf": true
+ "exportPdf": true,
+ "includeLegend": true
}
},
"defaultIcon": "share-variant",
"description": "A \"Share map\" button that opens a menu to share the current view as a link, PNG, or PDF.",
"descriptionFull": {
- "title": "Adds a \"Share map\" button, typically placed in a floating panel over the map (e.g. float-top-right). Clicking it opens a compact dropdown menu. \"Copy link to view\" copies a complete, self-contained URL (layers, place, zoom, time) to the clipboard. \"Export as PNG\" downloads a snapshot of the current map, and \"Export as PDF\" embeds that same snapshot centered on a portrait page. The PNG and PDF items can each be toggled on or off per-dashboard; the share link is always available.",
+ "title": "Adds a \"Share map\" button, typically placed in a floating panel over the map (e.g. float-top-right). Clicking it opens a compact dropdown menu. \"Copy link to view\" copies a complete, self-contained URL (layers, place, zoom, time) to the clipboard. \"Export as PNG\" downloads a snapshot of the current map, and \"Export as PDF\" embeds that same snapshot centered on a portrait page. Both exports append a legend band below the map — visible layer names, color bars with ranges and units, and the selected date — which can be toggled off. The PNG and PDF items can each be toggled on or off per-dashboard; the share link is always available.",
"example": {
"exportPng": true,
- "exportPdf": true
+ "exportPdf": true,
+ "includeLegend": true
}
},
"hasVars": true,
@@ -59,6 +61,20 @@
"defaultChecked": true
}
]
+ },
+ {
+ "name": "Export Legend",
+ "description": "Appends a legend band below exported images.",
+ "components": [
+ {
+ "field": "variables.includeLegend",
+ "name": "Include Legend On Exports",
+ "description": "Append a legend below exported PNGs and PDFs: layer names, color bars with ranges and units, and the selected date.",
+ "type": "checkbox",
+ "width": 6,
+ "defaultChecked": true
+ }
+ ]
}
]
}
diff --git a/src/essence/Tools/Timeline/TimelineAdapter.tsx b/src/essence/Tools/Timeline/TimelineAdapter.tsx
index 50f7f4d46..9519f5933 100644
--- a/src/essence/Tools/Timeline/TimelineAdapter.tsx
+++ b/src/essence/Tools/Timeline/TimelineAdapter.tsx
@@ -8,6 +8,7 @@ import {
mmgisGetVisibleLayers,
mmgisIsTimeEnabled,
type LayerConfig,
+ mmgisGetTemporalExtents,
} from '../_shared/adapters/mmgisAPI'
import { useMMGISHandlerReady } from '../_shared/adapters/useMMGISHandlerReady'
import {
@@ -170,9 +171,10 @@ export const TimelineAdapter: React.FC = () => {
let cancelled = false
const fetchLayers = async () => {
- const [configs, visibleLayers] = await Promise.all([
+ const [configs, visibleLayers, extents] = await Promise.all([
mmgisGetLayerConfigs(),
mmgisGetVisibleLayers(),
+ mmgisGetTemporalExtents(),
])
if (cancelled || !configs) return
@@ -188,20 +190,11 @@ export const TimelineAdapter: React.FC = () => {
let color = 'var(--theme-color-base, #71767a)' // default grey
if (layer.time && layer.time.enabled) {
- const timeConfig = layer.time
-
- if (timeConfig.dataStartTime) {
- const parsedStart = new Date(timeConfig.dataStartTime)
- if (!isNaN(parsedStart.getTime())) {
- start = parsedStart
- }
- }
- if (timeConfig.dataEndTime) {
- const parsedEnd = timeConfig.dataEndTime === 'now' ? new Date() : new Date(timeConfig.dataEndTime)
- if (!isNaN(parsedEnd.getTime())) {
- end = parsedEnd
- }
- }
+ // Core resolves the authored data times; null means
+ // unset or unreadable, keep the fallback.
+ const extent = extents?.[layerName]
+ if (extent?.start != null) start = new Date(extent.start)
+ if (extent?.end != null) end = new Date(extent.end)
// Time-enabled layers stand out in the theme's secondary colour
color = 'var(--theme-color-secondary, #c91b6e)'
diff --git a/src/essence/Tools/Timeline/config.json b/src/essence/Tools/Timeline/config.json
index f6546a772..a837c4bd2 100644
--- a/src/essence/Tools/Timeline/config.json
+++ b/src/essence/Tools/Timeline/config.json
@@ -1,4 +1,7 @@
{
+ "defaults": {
+ "variables": {}
+ },
"name": "Timeline",
"description": "Interactive timeline visualization for navigating temporal data with zoom and layer visibility controls",
"defaultIcon": "timeline",
diff --git a/src/essence/Tools/_shared/adapters/mmgisAPI.ts b/src/essence/Tools/_shared/adapters/mmgisAPI.ts
index cd00ed384..64c3a2fe4 100644
--- a/src/essence/Tools/_shared/adapters/mmgisAPI.ts
+++ b/src/essence/Tools/_shared/adapters/mmgisAPI.ts
@@ -22,10 +22,20 @@ export type LayerConfig = {
display_name?: string
time?: {
enabled?: boolean
+ /**
+ * 'global' and 'requery' track the global cursor; 'local' carries its
+ * own window in `start`/`end`.
+ */
+ type?: string
+ start?: string | null
+ end?: string | null
+ /** As authored: a concrete ISO datetime or a policy string ("now",
+ * "now - P1D"). Ask mmgisGetTemporalExtents for the dates. */
dataStartTime?: string
dataEndTime?: string
[key: string]: unknown
}
+ url?: string
[key: string]: unknown
}
@@ -175,6 +185,35 @@ export const mmgisGetLayerCogCapabilities = (
)
}
+/** When a layer has data, as ISO datetimes; null where unset or unreadable. */
+export type TemporalExtent = {
+ start: string | null
+ end: string | null
+}
+
+/**
+ * Temporal extent for every layer, keyed by layer UUID, resolved by core at
+ * the moment of asking. Null against a core without the handler.
+ */
+export const mmgisGetTemporalExtents = (): Promise | null> => {
+ return mmgisRequestIfProvided>(
+ 'layers:getTemporalExtent',
+ )
+}
+
+/** Temporal extent for one layer, by UUID or display name. */
+export const mmgisGetLayerTemporalExtent = (
+ layerUUID: string,
+): Promise => {
+ return mmgisRequestIfProvided(
+ 'layers:getTemporalExtent',
+ layerUUID,
+ )
+}
+
/** A geographic extent as `[[south, west], [north, east]]`. */
export type LayerBounds = [[number, number], [number, number]]
@@ -236,6 +275,44 @@ export const mmgisIsTimeEnabled = (): Promise => {
return mmgisRequestIfProvided('time:isEnabled')
}
+/**
+ * The time cursor as core holds it: an ISO string, unformatted, for callers
+ * that need to do arithmetic on it rather than print it. Null when time is
+ * disabled or not yet seeded.
+ */
+export const mmgisGetCurrentTime = (): Promise => {
+ return mmgisRequestIfProvided('time:getCurrent')
+}
+
+/**
+ * The current time already rendered through the mission's configured time
+ * format (`L_.configData.time.format`), so a header displaying it matches
+ * what TimeControl's own UI shows rather than a raw ISO string. Null when
+ * time is disabled, not yet seeded, or against a core that predates the
+ * handler — callers fall back to their own raw time string in that case.
+ */
+export const mmgisGetCurrentTimeFormatted = (): Promise => {
+ return mmgisRequestIfProvided('time:getCurrentFormatted')
+}
+
+/**
+ * A caller-supplied time rendered through that same mission format, for
+ * displaying a time the caller holds itself rather than the cursor's. Null
+ * for a missing or unparseable time, and against a core that predates the
+ * handler — callers show no time at all rather than one formatted their own
+ * way, which would disagree with the mission's.
+ */
+export const mmgisFormatTime = (
+ time: string | number | null | undefined,
+): Promise => {
+ return mmgisRequestIfProvided('time:formatTime', time)
+}
+
+/** The global time cursor's window start; null until time is seeded. */
+export const mmgisGetTimeStart = (): Promise => {
+ return mmgisRequestIfProvided('time:getStart')
+}
+
/**
* Copies text to the clipboard via core's app:copyText handler; true on
* success. Against cores that predate the handler — including ones whose
diff --git a/src/essence/Tools/_shared/adapters/shareActions.ts b/src/essence/Tools/_shared/adapters/shareActions.ts
index c8a8d075b..ac957e195 100644
--- a/src/essence/Tools/_shared/adapters/shareActions.ts
+++ b/src/essence/Tools/_shared/adapters/shareActions.ts
@@ -8,6 +8,11 @@ import {
} from './mmgisAPI'
import { buildSharePdf, type JsPdfLike } from './sharePdf'
import { blobToDataUrl, downloadBlob } from './download'
+import { composeExportImage } from '../legend/composeExportImage'
+import {
+ getExportLegendModel,
+ type ExportLegendModel,
+} from '../legend/getExportLegendModel'
// Orchestrates the three export actions, reaching core only through the
// shared mmgisAPI client. Dependencies are injectable for tests.
@@ -60,7 +65,36 @@ export async function copyShareLink(
return url
}
-export type DownloadSharePngDeps = {
+export type LegendDeps = {
+ includeLegend?: boolean
+ getLegendModel?: () => Promise
+ compose?: (
+ screenshot: MapScreenshotResult,
+ model: ExportLegendModel,
+ ) => Promise
+}
+
+// The export must never fail because the legend did — deliver the plain map
+// and log, rather than surfacing a broken download.
+async function applyLegend(
+ screenshot: MapScreenshotResult,
+ deps: LegendDeps,
+): Promise {
+ const {
+ includeLegend = true,
+ getLegendModel = getExportLegendModel,
+ compose = composeExportImage,
+ } = deps
+ if (!includeLegend) return screenshot
+ try {
+ return await compose(screenshot, await getLegendModel())
+ } catch (err) {
+ console.warn('Share export: legend omitted', err)
+ return screenshot
+ }
+}
+
+export type DownloadSharePngDeps = LegendDeps & {
getScreenshot?: () => Promise
download?: (blob: Blob, filename: string) => void
getViewState?: () => Promise
@@ -68,7 +102,7 @@ export type DownloadSharePngDeps = {
}
/**
- * Downloads the current map as a PNG. Returns the screenshot result.
+ * Downloads the current map as a PNG. Returns the composed screenshot result.
*/
export async function downloadSharePng(
deps: DownloadSharePngDeps = {},
@@ -80,14 +114,15 @@ export async function downloadSharePng(
} = deps
const screenshot = await getScreenshot()
if (!screenshot) throw new Error('No screenshot available')
+ const composed = await applyLegend(screenshot, deps)
const filename =
deps.filename ??
- buildExportFilename(screenshot.extension, await getViewState())
- download(screenshot.blob, filename)
- return screenshot
+ buildExportFilename(composed.extension, await getViewState())
+ download(composed.blob, filename)
+ return composed
}
-export type DownloadSharePdfDeps = {
+export type DownloadSharePdfDeps = LegendDeps & {
getScreenshot?: () => Promise
blobToDataUrl?: (blob: Blob) => Promise
buildPdf?: (
@@ -115,8 +150,9 @@ export async function downloadSharePdf(
} = deps
const screenshot = await getScreenshot()
if (!screenshot) throw new Error('No screenshot available')
- const dataUrl = await convertBlobToDataUrl(screenshot.blob)
- const doc = await buildPdf(dataUrl, screenshot.width, screenshot.height)
+ const composed = await applyLegend(screenshot, deps)
+ const dataUrl = await convertBlobToDataUrl(composed.blob)
+ const doc = await buildPdf(dataUrl, composed.width, composed.height)
const filename =
deps.filename ?? buildExportFilename('pdf', await getViewState())
doc.save(filename)
diff --git a/src/essence/Tools/_shared/legend/__tests__/buildLayerLegendData.spec.js b/src/essence/Tools/_shared/legend/__tests__/buildLayerLegendData.spec.js
new file mode 100644
index 000000000..7b5f419a6
--- /dev/null
+++ b/src/essence/Tools/_shared/legend/__tests__/buildLayerLegendData.spec.js
@@ -0,0 +1,376 @@
+import { test, expect } from 'vitest'
+import { buildLayerLegendData } from '../buildLayerLegendData.ts'
+
+// The three verdicts core can return for a layer. A colormap that can be
+// shown but not changed is what an `image` layer reports: it paints from the
+// same cog* fields, but bakes them in at construction.
+const NO_COG = { hasColormap: false, canChangeColormap: false }
+const EDITABLE_COG = { hasColormap: true, canChangeColormap: true }
+const READ_ONLY_COG = { hasColormap: true, canChangeColormap: false }
+
+test.describe('buildLayerLegendData', () => {
+ test('returns text type for layer with no legend and no COG', () => {
+ const result = buildLayerLegendData('layer1', { display_name: 'L1' }, null, true, NO_COG)
+ expect(result.id).toBe('layer1')
+ expect(result.title).toBe('L1')
+ expect(result.type).toBe('none')
+ expect(result.cog).toBeNull()
+ expect(result.visible).toBe(true)
+ })
+
+ test('builds gradient legend from continuous shape', () => {
+ const legend = [
+ { shape: 'continuous', color: '#000000', value: '0 m' },
+ { shape: 'continuous', color: '#ffffff', value: '100 m' },
+ ]
+ const result = buildLayerLegendData('layer2', { _legend: legend }, { layer2: 0.5 }, true, NO_COG)
+ expect(result.type).toBe('gradient')
+ expect(result.stops).toEqual(['#000000', '#ffffff'])
+ expect(result.min).toBe(0)
+ expect(result.max).toBe(100)
+ expect(result.unit).toEqual({ label: 'm' })
+ expect(result.opacity).toBe(0.5)
+ })
+
+ test('builds categorical legend and filters hidden entries', () => {
+ const legend = [
+ { color: '#ff0000', value: 'water' },
+ { color: '#00ff00', value: 'land', hideFromLegend: true },
+ { color: '#0000ff', value: 'sky' },
+ ]
+ const result = buildLayerLegendData('layer3', { _legend: legend }, null, true, NO_COG)
+ expect(result.type).toBe('categorical')
+ expect(result.categoricalStops).toEqual([
+ { color: '#ff0000', label: 'water' },
+ { color: '#0000ff', label: 'sky' },
+ ])
+ })
+
+ test('produces COG metadata for a colormap-capable layer', () => {
+ const cfg = {
+ cogColormap: 'plasma',
+ cogMin: 0,
+ cogMax: 1000,
+ cogUnits: 'm',
+ }
+ const result = buildLayerLegendData(
+ 'layer4', cfg, null, true, EDITABLE_COG, 'https://example.com/titiler',
+ )
+ expect(result.cog).not.toBeNull()
+ expect(result.cog?.titilerUrl).toBe('https://example.com/titiler')
+ expect(result.cog?.colormap).toBe('plasma')
+ expect(result.cog?.defaultMin).toBe(0)
+ expect(result.cog?.defaultMax).toBe(1000)
+ expect(result.cog?.editable).toBe(true)
+ expect(result.type).toBe('gradient')
+ expect(result.stops).toBeNull()
+ })
+
+ // Core's answer already accounts for the mission-wide override, so a raw
+ // config read would disagree with the service the tiles are drawn from.
+ test('takes the service URL from core, not from the raw layer config', () => {
+ const cfg = { cogColormap: 'plasma', titilerUrl: 'https://from-config.test' }
+ const result = buildLayerLegendData(
+ 'layer4b', cfg, null, true, EDITABLE_COG, 'https://from-core.test',
+ )
+ expect(result.cog?.titilerUrl).toBe('https://from-core.test')
+ })
+
+ test('leaves the service URL null when core resolves none', () => {
+ const cfg = { cogColormap: 'plasma' }
+ const result = buildLayerLegendData('layer4c', cfg, null, true, EDITABLE_COG)
+ expect(result.cog?.titilerUrl).toBeNull()
+ })
+
+ test('prefers the current colormap and rescale over the configured ones', () => {
+ const cfg = {
+ cogColormap: 'viridis',
+ cogMin: 0,
+ cogMax: 1,
+ currentCogColormap: 'rdbu_r',
+ currentCogMin: -0.1,
+ currentCogMax: 0.2,
+ }
+ const result = buildLayerLegendData('layer5', cfg, null, true, EDITABLE_COG)
+ expect(result.cog?.colormap).toBe('rdbu_r')
+ expect(result.cog?.min).toBe(-0.1)
+ expect(result.cog?.max).toBe(0.2)
+ // The defaults stay pinned to the mission config so the control can
+ // offer a reset.
+ expect(result.cog?.defaultColormap).toBe('viridis')
+ expect(result.cog?.defaultMin).toBe(0)
+ expect(result.cog?.defaultMax).toBe(1)
+ })
+
+ // The common shape for a mission raster: a configured legend AND a COG
+ // colormap. The legend decides how the bar is drawn; the COG block has to
+ // survive that branch or the layer silently loses its colormap controls.
+ test('keeps COG metadata for a capable layer that also has a legend', () => {
+ const legend = [
+ { shape: 'continuous', color: '#000000', value: '0 m' },
+ { shape: 'continuous', color: '#ffffff', value: '10 m' },
+ ]
+ const cfg = { _legend: legend, cogColormap: 'viridis', cogMin: 0, cogMax: 10 }
+ const result = buildLayerLegendData('layer6', cfg, null, true, EDITABLE_COG)
+ expect(result.type).toBe('gradient')
+ expect(result.stops).toEqual(['#000000', '#ffffff'])
+ expect(result.cog).not.toBeNull()
+ expect(result.cog?.editable).toBe(true)
+ })
+
+ // populateCogScale (LayersTool) writes its derived colormap snapshot into
+ // the same `_legend` field an authored legend uses, marking it with
+ // `_legendAutoGenerated`. That marker must make it lose to the live cog
+ // block rather than being treated as authored.
+ test('an auto-generated _legend defers to the live cog bar', () => {
+ const legend = [
+ { shape: 'continuous', color: '#000000', value: '0 m' },
+ { shape: 'continuous', color: '#ffffff', value: '10 m' },
+ ]
+ const cfg = {
+ _legend: legend,
+ _legendAutoGenerated: true,
+ cogColormap: 'viridis',
+ cogMin: 2,
+ cogMax: 8,
+ cogUnits: 'K',
+ }
+ const result = buildLayerLegendData('layer12', cfg, null, true, EDITABLE_COG)
+ expect(result.type).toBe('gradient')
+ expect(result.stops).toBeNull()
+ expect(result.min).toBe(2)
+ expect(result.max).toBe(8)
+ expect(result.unit).toEqual({ label: 'K' })
+ expect(result.cog).not.toBeNull()
+ expect(result.cog?.colormap).toBe('viridis')
+ })
+
+ // populateCogScale also runs for velocity layers, which core reports no
+ // colormap for. With no live cog data to prefer, the derived `_legend` is
+ // the only legend the layer has, so it renders rather than being dropped.
+ test('renders an auto-generated _legend when there is no cog', () => {
+ const legend = [
+ { shape: 'continuous', color: '#000000', value: '0 m/s' },
+ { shape: 'continuous', color: '#ffffff', value: '10 m/s' },
+ ]
+ const cfg = { _legend: legend, _legendAutoGenerated: true }
+ const result = buildLayerLegendData('layer17', cfg, null, true, NO_COG)
+ expect(result.cog).toBeNull()
+ expect(result.type).toBe('gradient')
+ expect(result.stops).toEqual(['#000000', '#ffffff'])
+ expect(result.min).toBe(0)
+ expect(result.max).toBe(10)
+ expect(result.unit).toEqual({ label: 'm/s' })
+ })
+
+ test('keeps COG metadata for a capable layer with a categorical legend', () => {
+ const legend = [
+ { color: '#ff0000', value: 'water' },
+ { color: '#0000ff', value: 'sky' },
+ ]
+ const cfg = { _legend: legend, cogColormap: 'viridis' }
+ const result = buildLayerLegendData('layer7', cfg, null, true, EDITABLE_COG)
+ expect(result.type).toBe('categorical')
+ expect(result.cog).not.toBeNull()
+ })
+
+ // An `image` layer: the ramp and its bounds are shown, but nothing offers
+ // to change them.
+ test('marks a showable but unchangeable colormap uneditable', () => {
+ const cfg = { cogColormap: 'viridis', cogMin: 0, cogMax: 4000, cogUnits: 'm' }
+ const result = buildLayerLegendData('layer8', cfg, null, true, READ_ONLY_COG)
+ expect(result.cog).not.toBeNull()
+ expect(result.cog?.editable).toBe(false)
+ expect(result.cog?.colormap).toBe('viridis')
+ // The gradient bar still draws, over the COG bounds.
+ expect(result.type).toBe('gradient')
+ expect(result.min).toBe(0)
+ expect(result.max).toBe(4000)
+ expect(result.unit).toEqual({ label: 'm' })
+ })
+
+ test('keeps a legend gradient when the layer has no COG colormap', () => {
+ const legend = [
+ { shape: 'continuous', color: '#000000', value: '0' },
+ { shape: 'continuous', color: '#ffffff', value: '10' },
+ ]
+ const cfg = { _legend: legend, cogColormap: 'viridis' }
+ const result = buildLayerLegendData('layer9', cfg, null, true, NO_COG)
+ expect(result.type).toBe('gradient')
+ expect(result.cog).toBeNull()
+ })
+
+ test('leaves a layer without COG metadata when core reports none', () => {
+ const cfg = { cogColormap: 'viridis', cogMin: 0, cogMax: 1 }
+ const result = buildLayerLegendData('layer10', cfg, null, true, NO_COG)
+ expect(result.cog).toBeNull()
+ expect(result.type).toBe('none')
+ })
+
+ // Core without the handler answers null rather than a verdict; that must
+ // read as "no COG", not throw.
+ test('leaves a layer without COG metadata when core answers nothing', () => {
+ const cfg = { cogColormap: 'viridis', cogMin: 0, cogMax: 1 }
+ expect(buildLayerLegendData('layer11', cfg, null, true, null).cog).toBeNull()
+ expect(buildLayerLegendData('layer11', cfg, null, true, undefined).cog).toBeNull()
+ })
+
+ // A purely numeric value must never invent a unit out of its own digits:
+ // an unanchored numeric match backtracks into '-0.1' and reads '1' as the
+ // unit.
+ test('derives no unit from a negative decimal value', () => {
+ const legend = [
+ { shape: 'continuous', color: '#000000', value: '-0.1' },
+ { shape: 'continuous', color: '#ffffff', value: '1' },
+ ]
+ const result = buildLayerLegendData('layer13', { _legend: legend }, null, true, NO_COG)
+ expect(result.unit).toBeNull()
+ })
+
+ test('derives a unit from a decimal value with a suffix', () => {
+ const legend = [
+ { shape: 'continuous', color: '#000000', value: '0.5 ppm' },
+ { shape: 'continuous', color: '#ffffff', value: '1 ppm' },
+ ]
+ const result = buildLayerLegendData('layer14', { _legend: legend }, null, true, NO_COG)
+ expect(result.unit).toEqual({ label: 'ppm' })
+ })
+
+ test('derives a unit from a signed exponent value with a suffix', () => {
+ const legend = [
+ { shape: 'continuous', color: '#000000', value: '-3e2 m' },
+ { shape: 'continuous', color: '#ffffff', value: '1e2 m' },
+ ]
+ const result = buildLayerLegendData('layer15', { _legend: legend }, null, true, NO_COG)
+ expect(result.unit).toEqual({ label: 'm' })
+ })
+
+ test('derives no unit from a plain integer value', () => {
+ const legend = [
+ { shape: 'continuous', color: '#000000', value: '10' },
+ { shape: 'continuous', color: '#ffffff', value: '20' },
+ ]
+ const result = buildLayerLegendData('layer16', { _legend: legend }, null, true, NO_COG)
+ expect(result.unit).toBeNull()
+ })
+
+ // The mixed form documented in docs/pages/Tools/Legend/Legend.md: runs of
+ // discreet/continuous entries interleaved with individually shaped
+ // circle/square/rect ones, all labelled with words. Deciding the type from
+ // the first entry's shape alone would make the whole thing one gradient,
+ // labelled with two of those words as its bounds.
+ test('renders a mixed shape legend as swatches, not one gradient', () => {
+ const legend = [
+ { color: 'purple', shape: 'discreet', value: 'This' },
+ { color: 'cyan', shape: 'discreet', value: 'is' },
+ { color: 'purple', shape: 'continuous', value: 'what' },
+ { color: 'pink', shape: 'circle', value: 'csv' },
+ { color: 'crimson', shape: 'square', value: 'possibly' },
+ { color: 'indigo', shape: 'rect', value: 'contain' },
+ ]
+ const result = buildLayerLegendData('layer18', { _legend: legend }, null, true, NO_COG)
+ expect(result.type).toBe('categorical')
+ expect(result.categoricalStops.map((s) => s.label)).toEqual([
+ 'This', 'is', 'what', 'csv', 'possibly', 'contain',
+ ])
+ expect(result.stops).toBeUndefined()
+ })
+
+ // Every entry is a scale shape, but the values are words — there is no
+ // range to draw, so the bar would be labelled with two stray words.
+ test('renders a scale legend with word values as swatches', () => {
+ const legend = [
+ { color: 'purple', shape: 'discreet', value: 'low' },
+ { color: 'red', shape: 'discreet', value: 'high' },
+ ]
+ const result = buildLayerLegendData('layer19', { _legend: legend }, null, true, NO_COG)
+ expect(result.type).toBe('categorical')
+ expect(result.min).toBeUndefined()
+ expect(result.max).toBeUndefined()
+ })
+
+ // Binned labels parse to a number followed by the rest of the bin, which
+ // is not a unit — '0.5-1.0 m' must not yield the unit '-1.0 m'.
+ test('renders binned scale labels as swatches rather than inventing a unit', () => {
+ const legend = [
+ { color: '#111111', shape: 'discreet', value: '0.0-0.5 m' },
+ { color: '#222222', shape: 'discreet', value: '0.5-1.0 m' },
+ ]
+ const result = buildLayerLegendData('layer20', { _legend: legend }, null, true, NO_COG)
+ expect(result.type).toBe('categorical')
+ expect(result.unit).toBeUndefined()
+ expect(result.categoricalStops.map((s) => s.label)).toEqual([
+ '0.0-0.5 m',
+ '0.5-1.0 m',
+ ])
+ })
+
+ // The gradient path filters hidden entries the same way the categorical
+ // one does; an author-hidden nodata colour is not a ramp stop, and its
+ // value is not a bound.
+ test('filters hidden entries out of a gradient ramp', () => {
+ const legend = [
+ { shape: 'continuous', color: '#000000', value: '0 m' },
+ { shape: 'continuous', color: '#ffffff', value: '100 m' },
+ { shape: 'continuous', color: '#ff00ff', value: '-9999 m', hideFromLegend: true },
+ ]
+ const result = buildLayerLegendData('layer21', { _legend: legend }, null, true, NO_COG)
+ expect(result.type).toBe('gradient')
+ expect(result.stops).toEqual(['#000000', '#ffffff'])
+ expect(result.min).toBe(0)
+ expect(result.max).toBe(100)
+ })
+
+ // Every entry hidden leaves nothing to draw — the layer falls through to
+ // whatever the cog block offers, exactly as an absent legend does.
+ test('an all-hidden legend is treated as no legend', () => {
+ const legend = [
+ { color: '#ff0000', value: 'water', hideFromLegend: true },
+ ]
+ expect(
+ buildLayerLegendData('layer22', { _legend: legend }, null, true, NO_COG).type,
+ ).toBe('none')
+ })
+
+ // A class keyed 0 is a real label, so the fallback to `label` has to test
+ // for undefined rather than for truthiness.
+ test('keeps a categorical value of 0 as its label', () => {
+ const legend = [
+ { color: '#ff0000', value: 0 },
+ { color: '#00ff00', value: 1 },
+ ]
+ const result = buildLayerLegendData('layer23', { _legend: legend }, null, true, NO_COG)
+ expect(result.categoricalStops).toEqual([
+ { color: '#ff0000', label: '0' },
+ { color: '#00ff00', label: '1' },
+ ])
+ })
+
+ test('falls back to the label when an entry has no value', () => {
+ const legend = [{ color: '#ff0000', label: 'water' }]
+ const result = buildLayerLegendData('layer24', { _legend: legend }, null, true, NO_COG)
+ expect(result.categoricalStops).toEqual([
+ { color: '#ff0000', label: 'water' },
+ ])
+ })
+
+ // An unrescaled raster has no bounds to report; 0 and 255 would print on
+ // the export as an authoritative range the layer was never rescaled to.
+ test('leaves COG bounds null when the mission configures none', () => {
+ const result = buildLayerLegendData(
+ 'layer25', { cogColormap: 'viridis' }, null, true, EDITABLE_COG,
+ )
+ expect(result.cog?.min).toBeNull()
+ expect(result.cog?.max).toBeNull()
+ expect(result.type).toBe('gradient')
+ expect(result.min).toBeNull()
+ expect(result.max).toBeNull()
+ })
+
+ test('keeps configured COG bounds as they are', () => {
+ const cfg = { cogColormap: 'viridis', cogMin: -2, cogMax: 6 }
+ const result = buildLayerLegendData('layer26', cfg, null, true, EDITABLE_COG)
+ expect(result.cog?.min).toBe(-2)
+ expect(result.cog?.max).toBe(6)
+ })
+})
diff --git a/src/essence/Tools/LayerManager/__tests__/colormapCache.spec.js b/src/essence/Tools/_shared/legend/__tests__/colormapCache.spec.js
similarity index 98%
rename from src/essence/Tools/LayerManager/__tests__/colormapCache.spec.js
rename to src/essence/Tools/_shared/legend/__tests__/colormapCache.spec.js
index 3e0f90d31..ae190db3c 100644
--- a/src/essence/Tools/LayerManager/__tests__/colormapCache.spec.js
+++ b/src/essence/Tools/_shared/legend/__tests__/colormapCache.spec.js
@@ -3,7 +3,7 @@ import {
fetchColormapColors,
clearColormapCache,
resolveTiTilerBase,
-} from '../lib/utils/colormapCache.ts'
+} from '../colormapCache.ts'
const BASE = 'https://titiler.test'
const VIRIDIS = { 0: [68, 1, 84, 255], 1: [253, 231, 37, 255] }
diff --git a/src/essence/Tools/LayerManager/__tests__/colormaps.spec.js b/src/essence/Tools/_shared/legend/__tests__/colormaps.spec.js
similarity index 99%
rename from src/essence/Tools/LayerManager/__tests__/colormaps.spec.js
rename to src/essence/Tools/_shared/legend/__tests__/colormaps.spec.js
index d19a343b6..c1efb7d26 100644
--- a/src/essence/Tools/LayerManager/__tests__/colormaps.spec.js
+++ b/src/essence/Tools/_shared/legend/__tests__/colormaps.spec.js
@@ -9,7 +9,7 @@ import {
parseColormapList,
toForwardColormapNames,
validateRescale,
-} from '../lib/utils/colormaps.ts'
+} from '../colormaps.ts'
test.describe('colormap names', () => {
test('reads direction off the name suffix', () => {
diff --git a/src/essence/Tools/_shared/legend/__tests__/coverageOverlap.spec.ts b/src/essence/Tools/_shared/legend/__tests__/coverageOverlap.spec.ts
new file mode 100644
index 000000000..817932eb3
--- /dev/null
+++ b/src/essence/Tools/_shared/legend/__tests__/coverageOverlap.spec.ts
@@ -0,0 +1,272 @@
+import { describe, test, expect } from 'vitest'
+import {
+ coverageOverlap,
+ hasDataIn,
+ clipPeriodToCoverage,
+} from '../coverageOverlap'
+
+const request = (start: string | null, end: string) => ({ start, end })
+const coverage = (start: string | null, end: string | null) => ({ start, end })
+
+describe('coverageOverlap', () => {
+ test('a request inside the coverage is the request', () => {
+ expect(
+ coverageOverlap(
+ request('2016-01-01T00:00:00Z', '2016-06-01T00:00:00Z'),
+ coverage('2015-01-01T00:00:00Z', '2017-01-01T00:00:00Z'),
+ ),
+ ).toEqual({
+ start: '2016-01-01T00:00:00Z',
+ end: '2016-06-01T00:00:00Z',
+ })
+ })
+
+ // Only the covered part of a request can be on screen, so each end comes
+ // from whichever bound is the tighter one.
+ test('a coverage narrower than the request clips both ends', () => {
+ expect(
+ coverageOverlap(
+ request('2010-01-01T00:00:00Z', '2024-01-01T00:00:00Z'),
+ coverage('2015-01-01T00:00:00Z', '2016-12-31T00:00:00Z'),
+ ),
+ ).toEqual({
+ start: '2015-01-01T00:00:00Z',
+ end: '2016-12-31T00:00:00Z',
+ })
+ })
+
+ test('a cursor short of the coverage end leaves the cursor as the end', () => {
+ expect(
+ coverageOverlap(
+ request('2010-01-01T00:00:00Z', '2016-03-01T00:00:00Z'),
+ coverage('2015-01-01T00:00:00Z', '2020-01-01T00:00:00Z'),
+ ),
+ ).toEqual({
+ start: '2015-01-01T00:00:00Z',
+ end: '2016-03-01T00:00:00Z',
+ })
+ })
+
+ // Point mode leaves no request start to speak of; the coverage then says
+ // where the overlap begins.
+ test('an absent request start takes the coverage start', () => {
+ expect(
+ coverageOverlap(
+ request(null, '2024-01-01T00:00:00Z'),
+ coverage('2015-01-01T00:00:00Z', null),
+ ),
+ ).toEqual({
+ start: '2015-01-01T00:00:00Z',
+ end: '2024-01-01T00:00:00Z',
+ })
+ })
+
+ // Neither side bounds the past, so neither can the overlap.
+ test('an absent request start and an absent coverage start stay unbounded', () => {
+ expect(
+ coverageOverlap(
+ request(null, '2024-01-01T00:00:00Z'),
+ coverage(null, '2016-01-01T00:00:00Z'),
+ ),
+ ).toEqual({ start: null, end: '2016-01-01T00:00:00Z' })
+ })
+
+ test('coverage running to an unbounded future ends at the cursor', () => {
+ expect(
+ coverageOverlap(
+ request('2015-01-01T00:00:00Z', '2024-01-01T00:00:00Z'),
+ coverage('2010-01-01T00:00:00Z', null),
+ ),
+ ).toEqual({
+ start: '2015-01-01T00:00:00Z',
+ end: '2024-01-01T00:00:00Z',
+ })
+ })
+
+ test('a cursor before the coverage overlaps nothing', () => {
+ expect(
+ coverageOverlap(
+ request('2010-01-01T00:00:00Z', '2014-01-01T00:00:00Z'),
+ coverage('2015-01-01T00:00:00Z', '2016-01-01T00:00:00Z'),
+ ),
+ ).toBeNull()
+ })
+
+ test('a request starting after the coverage ends overlaps nothing', () => {
+ expect(
+ coverageOverlap(
+ request('2020-01-01T00:00:00Z', '2024-01-01T00:00:00Z'),
+ coverage('2015-01-01T00:00:00Z', '2016-01-01T00:00:00Z'),
+ ),
+ ).toBeNull()
+ })
+
+ // A request that reaches exactly the first instant of the coverage did
+ // reach it, and that instant is the whole overlap.
+ test('touching at a single instant is an overlap of that instant', () => {
+ expect(
+ coverageOverlap(
+ request('2010-01-01T00:00:00Z', '2015-01-01T00:00:00Z'),
+ coverage('2015-01-01T00:00:00Z', '2016-01-01T00:00:00Z'),
+ ),
+ ).toEqual({
+ start: '2015-01-01T00:00:00Z',
+ end: '2015-01-01T00:00:00Z',
+ })
+ })
+
+ test('is null without a cursor it can read', () => {
+ expect(
+ coverageOverlap(
+ request('2015-01-01T00:00:00Z', 'not a date'),
+ coverage('2015-01-01T00:00:00Z', null),
+ ),
+ ).toBeNull()
+ })
+
+ // A bound that will not parse is no bound at all: it cannot be allowed to
+ // narrow a range it says nothing about.
+ test('an unreadable bound is read as unbounded', () => {
+ expect(
+ coverageOverlap(
+ request('whenever', '2024-01-01T00:00:00Z'),
+ coverage('2015-01-01T00:00:00Z', 'whenever'),
+ ),
+ ).toEqual({
+ start: '2015-01-01T00:00:00Z',
+ end: '2024-01-01T00:00:00Z',
+ })
+ })
+})
+
+describe('hasDataIn', () => {
+ const june = { start: '2025-06-01T00:00:00Z', end: '2025-07-01T00:00:00Z' }
+
+ test('is true for a period the coverage runs through', () => {
+ expect(
+ hasDataIn(
+ coverage('2015-01-01T00:00:00Z', '2026-01-01T00:00:00Z'),
+ june,
+ ),
+ ).toBe(true)
+ })
+
+ test('is true for a period the coverage only partly fills', () => {
+ expect(
+ hasDataIn(
+ coverage('2015-01-01T00:00:00Z', '2025-06-15T00:00:00Z'),
+ june,
+ ),
+ ).toBe(true)
+ })
+
+ test('is false for a period after everything the layer holds', () => {
+ expect(
+ hasDataIn(
+ coverage('2015-01-01T00:00:00Z', '2016-12-31T00:00:00Z'),
+ june,
+ ),
+ ).toBe(false)
+ })
+
+ test('is false for a period before the layer starts', () => {
+ expect(hasDataIn(coverage('2030-01-01T00:00:00Z', null), june)).toBe(
+ false,
+ )
+ })
+
+ // A period ends where the next one starts, so a period ending on the
+ // first instant of the coverage holds none of it.
+ test('is false for a period ending where the coverage starts', () => {
+ expect(hasDataIn(coverage('2025-07-01T00:00:00Z', null), june)).toBe(
+ false,
+ )
+ })
+
+ // The mirror of the case above: a period starting on the coverage's last
+ // instant still holds that instant.
+ test('is true for a period starting where the coverage ends', () => {
+ expect(
+ hasDataIn(
+ coverage('2015-01-01T00:00:00Z', '2025-06-01T00:00:00Z'),
+ june,
+ ),
+ ).toBe(true)
+ })
+
+ test('is true when the coverage bounds nothing', () => {
+ expect(hasDataIn(coverage(null, null), june)).toBe(true)
+ })
+
+ test('is false for a period it cannot read', () => {
+ expect(
+ hasDataIn(coverage(null, null), {
+ start: 'not a date',
+ end: '2025-07-01T00:00:00Z',
+ }),
+ ).toBe(false)
+ })
+})
+
+describe('clipPeriodToCoverage', () => {
+ const week = { start: '2025-01-08T00:00:00Z', end: '2025-01-15T00:00:00Z' }
+
+ test('leaves a period the coverage runs through alone', () => {
+ expect(
+ clipPeriodToCoverage(
+ week,
+ coverage('2024-01-01T00:00:00Z', '2026-01-01T00:00:00Z'),
+ ),
+ ).toEqual({ ...week, endIsPeriodEnd: true })
+ })
+
+ // The layer's data stops partway through the period, so the period stops
+ // there too rather than naming days it has nothing for.
+ test('a coverage ending inside the period ends the period there', () => {
+ expect(
+ clipPeriodToCoverage(
+ week,
+ coverage('2025-01-01T00:00:00Z', '2025-01-09T23:59:59Z'),
+ ),
+ ).toEqual({
+ start: '2025-01-08T00:00:00Z',
+ end: '2025-01-09T23:59:59Z',
+ endIsPeriodEnd: false,
+ })
+ })
+
+ test('a coverage starting inside the period starts the period there', () => {
+ expect(
+ clipPeriodToCoverage(week, coverage('2025-01-10T00:00:00Z', null)),
+ ).toEqual({
+ start: '2025-01-10T00:00:00Z',
+ end: '2025-01-15T00:00:00Z',
+ endIsPeriodEnd: true,
+ })
+ })
+
+ test('clips both ends at once', () => {
+ expect(
+ clipPeriodToCoverage(
+ week,
+ coverage('2025-01-10T00:00:00Z', '2025-01-12T00:00:00Z'),
+ ),
+ ).toEqual({
+ start: '2025-01-10T00:00:00Z',
+ end: '2025-01-12T00:00:00Z',
+ endIsPeriodEnd: false,
+ })
+ })
+
+ // A bound that says nothing cannot narrow a period, so an unbounded or
+ // unreadable coverage leaves it whole.
+ test('an unbounded or unreadable coverage clips nothing', () => {
+ expect(clipPeriodToCoverage(week, coverage(null, null))).toEqual({
+ ...week,
+ endIsPeriodEnd: true,
+ })
+ expect(
+ clipPeriodToCoverage(week, coverage('whenever', 'whenever')),
+ ).toEqual({ ...week, endIsPeriodEnd: true })
+ })
+})
diff --git a/src/essence/Tools/_shared/legend/__tests__/datePrecision.spec.ts b/src/essence/Tools/_shared/legend/__tests__/datePrecision.spec.ts
new file mode 100644
index 000000000..e555a8a2b
--- /dev/null
+++ b/src/essence/Tools/_shared/legend/__tests__/datePrecision.spec.ts
@@ -0,0 +1,103 @@
+import { describe, test, expect } from 'vitest'
+import { parseISODuration } from '../../../../Basics/TimeControl_/layerTimePolicy'
+import { formatAtPrecision, formatPeriodEnd } from '../datePrecision'
+
+const at = (interval: string | null, instant: string) =>
+ formatAtPrecision(
+ interval === null ? null : parseISODuration(interval),
+ instant,
+ )
+
+describe('formatAtPrecision', () => {
+ test('years print as a year', () => {
+ expect(at('P1Y', '2026-07-03T06:12:22Z')).toBe('2026')
+ expect(at('P2Y', '2026-07-03T06:12:22Z')).toBe('2026')
+ })
+
+ test('months print as a month', () => {
+ expect(at('P1M', '2026-07-03T06:12:22Z')).toBe('2026-07')
+ expect(at('P6M', '2026-07-03T06:12:22Z')).toBe('2026-07')
+ })
+
+ test('days and weeks print as a day', () => {
+ expect(at('P1D', '2026-07-03T06:12:22Z')).toBe('2026-07-03')
+ expect(at('P7D', '2026-07-03T06:12:22Z')).toBe('2026-07-03')
+ expect(at('P1W', '2026-07-03T06:12:22Z')).toBe('2026-07-03')
+ })
+
+ test('hours print to the hour', () => {
+ expect(at('PT1H', '2026-07-03T06:12:22Z')).toBe('2026-07-03 06:00Z')
+ expect(at('PT6H', '2026-07-03T06:12:22Z')).toBe('2026-07-03 06:00Z')
+ })
+
+ test('minutes and seconds print the whole timestamp', () => {
+ expect(at('PT30M', '2026-07-03T06:12:22Z')).toBe('2026-07-03T06:12:22Z')
+ expect(at('PT1S', '2026-07-03T06:12:22Z')).toBe('2026-07-03T06:12:22Z')
+ })
+
+ // The smallest unit in the duration is what a reader can distinguish, so
+ // a compound duration prints to its finest part.
+ test('a compound duration takes its smallest unit', () => {
+ expect(at('P1M10D', '2026-07-03T06:12:22Z')).toBe('2026-07-03')
+ expect(at('P1DT6H', '2026-07-03T06:12:22Z')).toBe('2026-07-03 06:00Z')
+ })
+
+ test('no interval prints a day', () => {
+ expect(at(null, '2026-07-03T06:12:22Z')).toBe('2026-07-03')
+ })
+
+ // parseISODuration returns null for anything it cannot read, which lands
+ // on the same day precision as no interval at all.
+ test('an unparseable interval prints a day', () => {
+ expect(at('every so often', '2026-07-03T06:12:22Z')).toBe('2026-07-03')
+ })
+
+ test('pads single-digit parts', () => {
+ expect(at('P1M', '2026-01-05T04:07:09Z')).toBe('2026-01')
+ expect(at('P1D', '2026-01-05T04:07:09Z')).toBe('2026-01-05')
+ expect(at('PT1H', '2026-01-05T04:07:09Z')).toBe('2026-01-05 04:00Z')
+ expect(at('PT1M', '2026-01-05T04:07:09Z')).toBe('2026-01-05T04:07:09Z')
+ })
+
+ // Nothing prints finer than a second, so a fraction is dropped rather
+ // than rounded up into the next one.
+ test('a fractional second prints its whole second', () => {
+ expect(at('PT1S', '2026-07-03T06:12:22.750Z')).toBe(
+ '2026-07-03T06:12:22Z',
+ )
+ })
+
+ // Every date on a row is UTC, so an instant written at an offset prints
+ // on the UTC day it falls on rather than its own local one.
+ test('an instant carrying an offset prints in UTC', () => {
+ expect(at('P1D', '2025-06-01T01:00:00+02:00')).toBe('2025-05-31')
+ })
+
+ test('is null for an instant it cannot read', () => {
+ expect(at('P1D', 'not a date')).toBeNull()
+ expect(formatAtPrecision(null, null)).toBeNull()
+ })
+})
+
+describe('formatPeriodEnd', () => {
+ // A period's end is where the next one starts, so printing it raw makes a
+ // seven-day period read as eight days.
+ test('a P7D period ends on its seventh day', () => {
+ expect(
+ formatPeriodEnd(parseISODuration('P7D'), '2025-06-08T00:00:00.000Z'),
+ ).toBe('2025-06-07')
+ })
+
+ test('an hourly period ends on its last hour', () => {
+ expect(
+ formatPeriodEnd(
+ parseISODuration('PT6H'),
+ '2025-01-01T18:00:00.000Z',
+ ),
+ ).toBe('2025-01-01 17:00Z')
+ })
+
+ test('is null for an end it cannot read', () => {
+ expect(formatPeriodEnd(parseISODuration('P7D'), 'not a date')).toBeNull()
+ })
+})
diff --git a/src/essence/Tools/_shared/legend/__tests__/filterLayersForExportView.spec.js b/src/essence/Tools/_shared/legend/__tests__/filterLayersForExportView.spec.js
new file mode 100644
index 000000000..edc5857b3
--- /dev/null
+++ b/src/essence/Tools/_shared/legend/__tests__/filterLayersForExportView.spec.js
@@ -0,0 +1,56 @@
+import { describe, test, expect, vi } from 'vitest'
+import { filterLayersForExportView } from '../filterLayersForExportView.ts'
+
+const baseLayer = (overrides) => ({
+ id: 'layer',
+ title: 'Layer',
+ description: null,
+ opacity: 1,
+ visible: true,
+ type: 'none',
+ cog: null,
+ ...overrides,
+})
+
+describe('filterLayersForExportView', () => {
+ test('drops a layer with opacity 0', () => {
+ const layers = [baseLayer({ id: 'a', opacity: 0 })]
+ expect(filterLayersForExportView(layers)).toEqual([])
+ })
+
+ test('keeps a barely-visible layer', () => {
+ const layers = [baseLayer({ id: 'a', opacity: 0.01 })]
+ expect(filterLayersForExportView(layers)).toHaveLength(1)
+ })
+
+ test('keeps every toggled-on layer that paints at all', () => {
+ const layers = [
+ baseLayer({ id: 'a' }),
+ baseLayer({ id: 'b', opacity: 0.5 }),
+ baseLayer({ id: 'c', opacity: 0 }),
+ ]
+ expect(filterLayersForExportView(layers).map((l) => l.id)).toEqual([
+ 'a',
+ 'b',
+ ])
+ })
+
+ test('logs the dropped layers when filtering empties a non-empty layer set', () => {
+ const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {})
+ const layers = [baseLayer({ id: 'a', title: 'Only layer', opacity: 0 })]
+ expect(filterLayersForExportView(layers)).toEqual([])
+ expect(infoSpy).toHaveBeenCalledTimes(1)
+ expect(infoSpy.mock.calls[0][1]).toEqual(
+ expect.arrayContaining([expect.stringContaining('Only layer')]),
+ )
+ infoSpy.mockRestore()
+ })
+
+ test('does not log when nothing was filtered out, or when there was nothing to filter', () => {
+ const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {})
+ filterLayersForExportView([baseLayer({ id: 'a' })])
+ filterLayersForExportView([])
+ expect(infoSpy).not.toHaveBeenCalled()
+ infoSpy.mockRestore()
+ })
+})
diff --git a/src/essence/Tools/_shared/legend/__tests__/format.spec.js b/src/essence/Tools/_shared/legend/__tests__/format.spec.js
new file mode 100644
index 000000000..cbf4bc90e
--- /dev/null
+++ b/src/essence/Tools/_shared/legend/__tests__/format.spec.js
@@ -0,0 +1,56 @@
+import { describe, test, expect } from 'vitest'
+import { formatLegendValue, formatLegendBound } from '../format.ts'
+
+describe('formatLegendValue', () => {
+ test('zero renders as a bare 0', () => {
+ expect(formatLegendValue(0)).toBe('0')
+ })
+
+ test('rounds to three significant decimals', () => {
+ expect(formatLegendValue(1.23456)).toBe('1.235')
+ })
+
+ test('falls back to exponential above the magnitude ceiling', () => {
+ expect(formatLegendValue(123456)).toBe('1.23e+5')
+ })
+
+ test('falls back to exponential below the magnitude floor', () => {
+ expect(formatLegendValue(0.0001)).toBe('1.00e-4')
+ })
+
+ test('passes non-numeric input through untouched', () => {
+ expect(formatLegendValue('n/a')).toBe('n/a')
+ })
+
+ test('renders null/undefined/blank as blank, not 0', () => {
+ expect(formatLegendValue(null)).toBe('')
+ expect(formatLegendValue(undefined)).toBe('')
+ expect(formatLegendValue('')).toBe('')
+ expect(formatLegendValue(' ')).toBe('')
+ })
+})
+
+describe('formatLegendBound', () => {
+ test('appends the unit when one is supplied', () => {
+ expect(formatLegendBound(5, 'm')).toBe('5 m')
+ })
+
+ test('is bare when no unit is supplied', () => {
+ expect(formatLegendBound(5, null)).toBe('5')
+ })
+
+ test('missing bounds render blank, with no dangling unit', () => {
+ expect(formatLegendBound(null, 'm')).toBe('')
+ expect(formatLegendBound(undefined, 'm')).toBe('')
+ expect(formatLegendBound('', 'ppm')).toBe('')
+ })
+
+ test('a non-numeric bound renders verbatim, once, with no unit appended', () => {
+ expect(formatLegendBound('<0.1 ppm', 'ppm')).toBe('<0.1 ppm')
+ })
+
+ test('a numeric bound (number or numeric string) still gets its unit', () => {
+ expect(formatLegendBound(0, 'm')).toBe('0 m')
+ expect(formatLegendBound('5', 'm')).toBe('5 m')
+ })
+})
diff --git a/src/essence/Tools/_shared/legend/__tests__/getExportLegendModel.spec.js b/src/essence/Tools/_shared/legend/__tests__/getExportLegendModel.spec.js
new file mode 100644
index 000000000..d4dfeaa7e
--- /dev/null
+++ b/src/essence/Tools/_shared/legend/__tests__/getExportLegendModel.spec.js
@@ -0,0 +1,1048 @@
+import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'
+
+vi.mock('../getVisibleLayersWithLegends', () => ({
+ getVisibleLayersWithLegends: vi.fn(),
+}))
+vi.mock('../resolveColormapColors', () => ({
+ resolveColormapColors: vi.fn(),
+}))
+// Only the handlers the model actually reaches for are mocked, so importing
+// it would fail loudly if it started requesting anything else — the viewport,
+// the zoom, or the blocking whole-mission layers:getBounds sweep among them.
+vi.mock('../../adapters/mmgisAPI', () => ({
+ mmgisGetViewState: vi.fn(),
+ mmgisGetLayerConfigs: vi.fn(),
+ mmgisGetTimeStart: vi.fn(),
+ mmgisGetCurrentTime: vi.fn(),
+ mmgisGetCurrentTimeFormatted: vi.fn(),
+ mmgisGetTemporalExtents: vi.fn(),
+ mmgisFormatTime: vi.fn(),
+}))
+
+import { getVisibleLayersWithLegends } from '../getVisibleLayersWithLegends'
+import { resolveColormapColors } from '../resolveColormapColors'
+import {
+ mmgisGetViewState,
+ mmgisGetLayerConfigs,
+ mmgisGetTimeStart,
+ mmgisGetCurrentTime,
+ mmgisGetCurrentTimeFormatted,
+ mmgisGetTemporalExtents,
+ mmgisFormatTime,
+} from '../../adapters/mmgisAPI'
+import { getExportLegendModel } from '../getExportLegendModel'
+
+const baseLayer = (overrides) => ({
+ id: 'layer',
+ title: 'Layer',
+ description: null,
+ opacity: 1,
+ visible: true,
+ type: 'none',
+ cog: null,
+ ...overrides,
+})
+
+// The mission's own time.format lives in core, and only the header's export
+// time is rendered through it, so the fake here just marks that a timestamp
+// went through core.
+const formatted = (time) => `fmt(${time})`
+
+const CURSOR = '2026-08-25T00:00:00Z'
+const WINDOW_START = '2015-03-13T00:00:00Z'
+
+beforeEach(() => {
+ vi.mocked(getVisibleLayersWithLegends).mockReset()
+ vi.mocked(resolveColormapColors).mockReset()
+ vi.mocked(mmgisGetViewState).mockReset()
+ vi.mocked(mmgisGetLayerConfigs).mockReset()
+ vi.mocked(mmgisGetTimeStart).mockReset()
+ vi.mocked(mmgisGetCurrentTime).mockReset()
+ vi.mocked(mmgisGetCurrentTimeFormatted).mockReset()
+ vi.mocked(mmgisGetTemporalExtents).mockReset()
+ vi.mocked(mmgisFormatTime).mockReset()
+ vi.mocked(mmgisGetViewState).mockResolvedValue({
+ missionName: 'Test Mission',
+ time: null,
+ center: null,
+ zoom: null,
+ })
+ vi.mocked(mmgisGetLayerConfigs).mockResolvedValue(null)
+ vi.mocked(mmgisGetTimeStart).mockResolvedValue(WINDOW_START)
+ vi.mocked(mmgisGetCurrentTime).mockResolvedValue(CURSOR)
+ vi.mocked(mmgisGetCurrentTimeFormatted).mockResolvedValue(null)
+ vi.mocked(mmgisGetTemporalExtents).mockResolvedValue(null)
+ vi.mocked(mmgisFormatTime).mockImplementation(async (time) =>
+ time == null ? null : formatted(time),
+ )
+})
+
+describe('getExportLegendModel', () => {
+ test('an authored legend wins over a live cog colormap', async () => {
+ vi.mocked(getVisibleLayersWithLegends).mockResolvedValue([
+ baseLayer({
+ title: 'Displacement',
+ type: 'gradient',
+ stops: ['#a', '#b'],
+ min: 0,
+ max: 10,
+ unit: { label: 'm' },
+ cog: {
+ isCog: true,
+ editable: true,
+ colormap: 'viridis',
+ min: -5,
+ max: 5,
+ defaultMin: -5,
+ defaultMax: 5,
+ defaultColormap: 'viridis',
+ units: 'K',
+ titilerUrl: null,
+ },
+ }),
+ ])
+ const model = await getExportLegendModel()
+ expect(model.rows).toEqual([
+ {
+ kind: 'gradient',
+ title: 'Displacement',
+ dateLine: null,
+ colors: ['#a', '#b'],
+ min: 0,
+ max: 10,
+ unit: 'm',
+ },
+ ])
+ expect(resolveColormapColors).not.toHaveBeenCalled()
+ })
+
+ test('falls back to the cog colormap when nothing is authored', async () => {
+ vi.mocked(getVisibleLayersWithLegends).mockResolvedValue([
+ baseLayer({
+ title: 'Raster',
+ type: 'gradient',
+ stops: null,
+ cog: {
+ isCog: true,
+ editable: true,
+ colormap: 'magma',
+ min: 2,
+ max: 8,
+ defaultMin: 2,
+ defaultMax: 8,
+ defaultColormap: 'magma',
+ units: 'K',
+ titilerUrl: null,
+ },
+ }),
+ ])
+ vi.mocked(resolveColormapColors).mockResolvedValue(['#000', '#fff'])
+ const model = await getExportLegendModel()
+ expect(resolveColormapColors).toHaveBeenCalledWith('magma', null)
+ expect(model.rows).toEqual([
+ {
+ kind: 'gradient',
+ title: 'Raster',
+ dateLine: null,
+ colors: ['#000', '#fff'],
+ min: 2,
+ max: 8,
+ unit: 'K',
+ },
+ ])
+ })
+
+ // The cog block's min/max already reflect current-over-config precedence
+ // upstream, in buildLayerLegendData — this only checks pass-through.
+ test('passes the cog block live min/max through unchanged', async () => {
+ vi.mocked(getVisibleLayersWithLegends).mockResolvedValue([
+ baseLayer({
+ title: 'Live',
+ type: 'gradient',
+ stops: null,
+ cog: {
+ isCog: true,
+ editable: true,
+ colormap: 'rdbu_r',
+ min: -0.1,
+ max: 0.2,
+ defaultMin: 0,
+ defaultMax: 1,
+ defaultColormap: 'viridis',
+ units: null,
+ titilerUrl: null,
+ },
+ }),
+ ])
+ vi.mocked(resolveColormapColors).mockResolvedValue(['#000'])
+ const model = await getExportLegendModel()
+ expect(model.rows[0].min).toBe(-0.1)
+ expect(model.rows[0].max).toBe(0.2)
+ })
+
+ test('builds a categorical row from categoricalStops', async () => {
+ const stops = [
+ { color: '#ff0000', label: 'water' },
+ { color: '#0000ff', label: 'sky' },
+ ]
+ vi.mocked(getVisibleLayersWithLegends).mockResolvedValue([
+ baseLayer({ title: 'Classes', type: 'categorical', categoricalStops: stops }),
+ ])
+ const model = await getExportLegendModel()
+ expect(model.rows).toEqual([
+ { kind: 'categorical', title: 'Classes', dateLine: null, stops },
+ ])
+ })
+
+ // A layer with nothing to draw still belongs on the band: the band lists
+ // what is on the map, and a layer without a ramp is still on the map.
+ test('gives a layer with no graphics a plain row', async () => {
+ vi.mocked(getVisibleLayersWithLegends).mockResolvedValue([
+ baseLayer({ title: 'Text layer', type: 'text' }),
+ baseLayer({ title: 'None layer', type: 'none' }),
+ baseLayer({
+ title: 'Empty gradient',
+ type: 'gradient',
+ stops: [],
+ }),
+ baseLayer({
+ title: 'Empty categorical',
+ type: 'categorical',
+ categoricalStops: [],
+ }),
+ ])
+ const model = await getExportLegendModel()
+ expect(model.rows).toEqual([
+ { kind: 'plain', title: 'Text layer', dateLine: null },
+ { kind: 'plain', title: 'None layer', dateLine: null },
+ { kind: 'plain', title: 'Empty gradient', dateLine: null },
+ { kind: 'plain', title: 'Empty categorical', dateLine: null },
+ ])
+ })
+
+ test('a plain row still carries its date line', async () => {
+ vi.mocked(getVisibleLayersWithLegends).mockResolvedValue([
+ baseLayer({ id: 'basemap', title: 'Basemap', type: 'none' }),
+ ])
+ vi.mocked(mmgisGetTemporalExtents).mockResolvedValue({
+ basemap: { start: '2016-01-01T00:00:00Z', end: null },
+ })
+ const model = await getExportLegendModel()
+ expect(model.rows[0]).toEqual({
+ kind: 'plain',
+ title: 'Basemap',
+ dateLine: 'Collected from 2016-01-01',
+ })
+ })
+
+ test('is an empty model when nothing qualifies', async () => {
+ vi.mocked(getVisibleLayersWithLegends).mockResolvedValue([])
+ const model = await getExportLegendModel()
+ expect(model.rows).toEqual([])
+ })
+
+ // Every date line names what kind of date it is, so a bare range can
+ // never be read as a claim about when the pixels were collected.
+ describe('date lines', () => {
+ const gradientLayer = (id) =>
+ baseLayer({
+ id,
+ title: id,
+ type: 'gradient',
+ stops: ['#a', '#b'],
+ })
+
+ const rowsFor = async (configs, layers) => {
+ vi.mocked(getVisibleLayersWithLegends).mockResolvedValue(
+ layers ?? Object.keys(configs).map(gradientLayer),
+ )
+ vi.mocked(mmgisGetLayerConfigs).mockResolvedValue(configs)
+ const model = await getExportLegendModel()
+ return model.rows
+ }
+
+ const templated =
+ 'https://host/{z}/{x}/{y}.png?datetime={starttime}/{endtime}'
+ // Core appends `datetime=` to a STAC layer's URL itself, so a
+ // time-enabled layer varies with the cursor with no placeholder in
+ // sight — `time.enabled` is the only signal worth reading.
+ const appended = 'stac-collection:no2-monthly'
+
+ // None of these layers carries an interval, so their dates print to
+ // the day.
+ const requested = (start, cursor) =>
+ `Requested ${start.slice(0, 10)} → ${cursor.slice(0, 10)}`
+ const upTo = (cursor) => `Requested up to ${cursor.slice(0, 10)}`
+
+ test('a placeholder-free time-enabled layer still gets a date line', async () => {
+ const rows = await rowsFor({
+ stac: { url: appended, time: { enabled: true, type: 'global' } },
+ })
+ expect(rows[0].dateLine).toBe(requested(WINDOW_START, CURSOR))
+ })
+
+ test('a layer with no time.type follows the cursor', async () => {
+ const rows = await rowsFor({
+ live: { url: templated, time: { enabled: true } },
+ })
+ expect(rows[0].dateLine).toBe(requested(WINDOW_START, CURSOR))
+ })
+
+ test('a local layer uses its own window and its own cursor', async () => {
+ const rows = await rowsFor({
+ own: {
+ url: templated,
+ time: {
+ enabled: true,
+ type: 'local',
+ start: '2020-01-01T00:00:00Z',
+ end: '2020-02-01T00:00:00Z',
+ },
+ },
+ })
+ expect(rows[0].dateLine).toBe(
+ requested('2020-01-01T00:00:00Z', '2020-02-01T00:00:00Z'),
+ )
+ })
+
+ // Point mode sets the window start to the epoch. "Requested 1970 →"
+ // describes a span nobody asked for.
+ test('an epoch window start prints an open-ended request', async () => {
+ vi.mocked(mmgisGetTimeStart).mockResolvedValue(
+ '1970-01-01T00:00:00Z',
+ )
+ const rows = await rowsFor({
+ live: { url: templated, time: { enabled: true, type: 'global' } },
+ })
+ expect(rows[0].dateLine).toBe(upTo(CURSOR))
+ })
+
+ // Point mode rebuilds the epoch from local date components, so east
+ // of Greenwich the start lands hours into 1970 rather than on it.
+ test('an epoch window start east of UTC is still open-ended', async () => {
+ vi.mocked(mmgisGetTimeStart).mockResolvedValue(
+ '1970-01-01T09:00:00Z',
+ )
+ const rows = await rowsFor({
+ live: { url: templated, time: { enabled: true, type: 'global' } },
+ })
+ expect(rows[0].dateLine).toBe(upTo(CURSOR))
+ })
+
+ // ...and west of it, where that same shifted epoch lands in 1969.
+ test('an epoch window start west of UTC is still open-ended', async () => {
+ vi.mocked(mmgisGetTimeStart).mockResolvedValue(
+ '1969-12-31T19:00:00Z',
+ )
+ const rows = await rowsFor({
+ live: { url: templated, time: { enabled: true, type: 'global' } },
+ })
+ expect(rows[0].dateLine).toBe(upTo(CURSOR))
+ })
+
+ // Only a start beside the epoch is Point mode's doing. A window a
+ // user really set decades ago is a span they asked for, so print it.
+ test('a window start decades before the epoch is printed', async () => {
+ vi.mocked(mmgisGetTimeStart).mockResolvedValue(
+ '1950-01-01T00:00:00Z',
+ )
+ const rows = await rowsFor({
+ live: { url: templated, time: { enabled: true, type: 'global' } },
+ })
+ expect(rows[0].dateLine).toBe(
+ requested('1950-01-01T00:00:00Z', CURSOR),
+ )
+ })
+
+ test('a missing window start prints an open-ended request', async () => {
+ vi.mocked(mmgisGetTimeStart).mockResolvedValue(null)
+ const rows = await rowsFor({
+ live: { url: templated, time: { enabled: true, type: 'global' } },
+ })
+ expect(rows[0].dateLine).toBe(upTo(CURSOR))
+ })
+
+ test('no cursor and no window means no date line', async () => {
+ vi.mocked(mmgisGetTimeStart).mockResolvedValue(null)
+ vi.mocked(mmgisGetCurrentTime).mockResolvedValue(null)
+ const rows = await rowsFor({
+ live: { url: templated, time: { enabled: true, type: 'global' } },
+ })
+ expect(rows[0].dateLine).toBeNull()
+ })
+
+ // The cursor's month is inside the layer's coverage, so the data on
+ // screen was collected in it.
+ test('a monthly layer narrows its coverage to the month holding the cursor', async () => {
+ vi.mocked(mmgisGetCurrentTime).mockResolvedValue(
+ '2025-06-15T09:00:00Z',
+ )
+ vi.mocked(mmgisGetTemporalExtents).mockResolvedValue({
+ monthly: {
+ start: '2015-01-01T00:00:00Z',
+ end: '2026-01-01T00:00:00Z',
+ },
+ })
+ const rows = await rowsFor({
+ monthly: {
+ url: appended,
+ time: { enabled: true, type: 'global', interval: 'P1M' },
+ },
+ })
+ expect(rows[0].dateLine).toBe('Collected 2025-06')
+ })
+
+ test('a yearly and a daily layer label just as compactly', async () => {
+ vi.mocked(mmgisGetCurrentTime).mockResolvedValue(
+ '2025-06-15T09:00:00Z',
+ )
+ vi.mocked(mmgisGetTemporalExtents).mockResolvedValue({
+ yearly: {
+ start: '2015-01-01T00:00:00Z',
+ end: '2026-01-01T00:00:00Z',
+ },
+ daily: {
+ start: '2015-01-01T00:00:00Z',
+ end: '2026-01-01T00:00:00Z',
+ },
+ })
+ const rows = await rowsFor({
+ yearly: {
+ url: appended,
+ time: { enabled: true, type: 'global', interval: 'P1Y' },
+ },
+ daily: {
+ url: appended,
+ time: { enabled: true, type: 'global', interval: 'P1D' },
+ },
+ })
+ expect(rows.map((row) => row.dateLine)).toEqual([
+ 'Collected 2025',
+ 'Collected 2025-06-15',
+ ])
+ })
+
+ // A cursor parked years past everything the layer holds cannot be
+ // printed as a collection date: what the request could have returned
+ // is the coverage itself.
+ test('a cursor past the coverage falls back to the covered part of the request', async () => {
+ vi.mocked(mmgisGetTimeStart).mockResolvedValue(
+ '2010-01-01T00:00:00Z',
+ )
+ vi.mocked(mmgisGetCurrentTime).mockResolvedValue(
+ '2024-05-01T00:00:00Z',
+ )
+ vi.mocked(mmgisGetTemporalExtents).mockResolvedValue({
+ yearly: {
+ start: '2015-01-01T00:00:00Z',
+ end: '2016-12-31T00:00:00Z',
+ },
+ plain: {
+ start: '2015-01-01T00:00:00Z',
+ end: '2016-12-31T00:00:00Z',
+ },
+ })
+ const rows = await rowsFor({
+ yearly: {
+ url: appended,
+ time: { enabled: true, type: 'global', interval: 'P1Y' },
+ },
+ plain: { url: appended, time: { enabled: true, type: 'global' } },
+ })
+ expect(rows.map((row) => row.dateLine)).toEqual([
+ 'Collected 2015 → 2016',
+ 'Collected 2015-01-01 → 2016-12-31',
+ ])
+ })
+
+ test('coverage running past the cursor ends the range at the cursor', async () => {
+ vi.mocked(mmgisGetTemporalExtents).mockResolvedValue({
+ ongoing: {
+ start: '2020-01-01T00:00:00Z',
+ end: '2030-01-01T00:00:00Z',
+ },
+ })
+ const rows = await rowsFor({
+ ongoing: {
+ url: appended,
+ time: { enabled: true, type: 'global' },
+ },
+ })
+ expect(rows[0].dateLine).toBe('Collected 2020-01-01 → 2026-08-25')
+ })
+
+ // A coverage with no end reaches the cursor, so the cursor's own day
+ // is a day the layer has data for.
+ test('a half-open coverage still narrows to the cursor period', async () => {
+ vi.mocked(mmgisGetTemporalExtents).mockResolvedValue({
+ daily: { start: '2015-01-01T00:00:00Z', end: null },
+ })
+ const rows = await rowsFor({
+ daily: {
+ url: appended,
+ time: { enabled: true, type: 'global', interval: 'P1D' },
+ },
+ })
+ expect(rows[0].dateLine).toBe('Collected 2026-08-25')
+ })
+
+ // A local layer keeps its own window, and its coverage narrows that
+ // window the same way the global cursor's is narrowed.
+ test('a local layer narrows its own window to its coverage', async () => {
+ vi.mocked(mmgisGetTemporalExtents).mockResolvedValue({
+ own: {
+ start: '2015-01-01T00:00:00Z',
+ end: '2020-01-15T00:00:00Z',
+ },
+ })
+ const rows = await rowsFor({
+ own: {
+ url: appended,
+ time: {
+ enabled: true,
+ type: 'local',
+ start: '2020-01-01T00:00:00Z',
+ end: '2020-02-01T00:00:00Z',
+ },
+ },
+ })
+ expect(rows[0].dateLine).toBe('Collected 2020-01-01 → 2020-01-15')
+ })
+
+ // The server had nothing inside the span to draw, so the app cannot
+ // say what, if anything, is on screen.
+ test('a cursor before the first scene prints only the request', async () => {
+ vi.mocked(mmgisGetTemporalExtents).mockResolvedValue({
+ future: { start: '2030-01-01T00:00:00Z', end: null },
+ })
+ const rows = await rowsFor({
+ future: { url: appended, time: { enabled: true, type: 'global' } },
+ })
+ expect(rows[0].dateLine).toBe(requested(WINDOW_START, CURSOR))
+ })
+
+ // Point mode asks from no particular start, so the coverage is what
+ // says how far back the request could have reached.
+ test('an open-ended request starts its range at the coverage start', async () => {
+ vi.mocked(mmgisGetTimeStart).mockResolvedValue(
+ '1970-01-01T00:00:00Z',
+ )
+ vi.mocked(mmgisGetTemporalExtents).mockResolvedValue({
+ stac: { start: '2016-01-01T00:00:00Z', end: null },
+ })
+ const rows = await rowsFor({
+ stac: { url: appended, time: { enabled: true, type: 'global' } },
+ })
+ expect(rows[0].dateLine).toBe('Collected 2016-01-01 → 2026-08-25')
+ })
+
+ // Nothing bounds the past — not the request, not the coverage — so
+ // the range stays open at that end rather than inventing a start.
+ test('an open-ended request against coverage with no start reads as open', async () => {
+ vi.mocked(mmgisGetTimeStart).mockResolvedValue(
+ '1970-01-01T00:00:00Z',
+ )
+ vi.mocked(mmgisGetTemporalExtents).mockResolvedValue({
+ stac: { start: null, end: '2016-09-01T00:00:00Z' },
+ })
+ const rows = await rowsFor({
+ stac: { url: appended, time: { enabled: true, type: 'global' } },
+ })
+ expect(rows[0].dateLine).toBe('Collected until 2016-09-01')
+ })
+
+ // A row's dates print at the layer's own period precision, which is
+ // not the mission's time format: nothing on a row goes through core.
+ test('no row date is sent to the time formatter', async () => {
+ vi.mocked(mmgisGetCurrentTime).mockResolvedValue(
+ '2025-06-15T09:00:00Z',
+ )
+ vi.mocked(mmgisGetTemporalExtents).mockResolvedValue({
+ fixed: { start: '2016-05-01T00:00:00Z', end: null },
+ })
+ await rowsFor({
+ monthly: {
+ url: appended,
+ time: { enabled: true, type: 'global', interval: 'P1M' },
+ },
+ live: { url: appended, time: { enabled: true, type: 'global' } },
+ fixed: { url: appended, time: { enabled: false } },
+ })
+ const formatterArgs = vi
+ .mocked(mmgisFormatTime)
+ .mock.calls.map(([time]) => time)
+ expect(formatterArgs).not.toContain('2025-06-15T09:00:00Z')
+ expect(formatterArgs).not.toContain(WINDOW_START)
+ expect(formatterArgs).not.toContain('2016-05-01T00:00:00Z')
+ })
+
+ // The period ends where the next one starts, so the printed end is
+ // the last day it covers: a P7D period reads as seven days, not eight.
+ test('an off-calendar interval anchors on the layer data start time', async () => {
+ vi.mocked(mmgisGetCurrentTime).mockResolvedValue(
+ '2025-06-04T06:00:00Z',
+ )
+ vi.mocked(mmgisGetTemporalExtents).mockResolvedValue({
+ weekly: { start: '2025-06-01T00:00:00Z', end: null },
+ })
+ const rows = await rowsFor({
+ weekly: {
+ url: appended,
+ time: { enabled: true, type: 'global', interval: 'P7D' },
+ },
+ })
+ expect(rows[0].dateLine).toBe('Collected 2025-06-01 → 2025-06-07')
+ })
+
+ // The layer's data stops partway through the week holding the cursor,
+ // so the printed period stops there rather than naming five days the
+ // layer has nothing for.
+ test('a period is clipped to a coverage that ends inside it', async () => {
+ vi.mocked(mmgisGetCurrentTime).mockResolvedValue(
+ '2025-01-10T00:00:00Z',
+ )
+ vi.mocked(mmgisGetTemporalExtents).mockResolvedValue({
+ weekly: {
+ start: '2025-01-01T00:00:00Z',
+ end: '2025-01-09T23:59:59Z',
+ },
+ })
+ const rows = await rowsFor({
+ weekly: {
+ url: appended,
+ time: { enabled: true, type: 'global', interval: 'P7D' },
+ },
+ })
+ expect(rows[0].dateLine).toBe('Collected 2025-01-08 → 2025-01-09')
+ })
+
+ test('a six-hourly period prints both its ends to the hour', async () => {
+ vi.mocked(mmgisGetCurrentTime).mockResolvedValue(
+ '2025-01-01T05:30:00Z',
+ )
+ vi.mocked(mmgisGetTemporalExtents).mockResolvedValue({
+ sixHourly: { start: '2025-01-01T00:00:00Z', end: null },
+ })
+ const rows = await rowsFor({
+ sixHourly: {
+ url: appended,
+ time: { enabled: true, type: 'global', interval: 'PT6H' },
+ },
+ })
+ expect(rows[0].dateLine).toBe(
+ 'Collected 2025-01-01 00:00Z → 2025-01-01 05:00Z',
+ )
+ })
+
+ // A period an hour long prints to the hour, so both its ends are the
+ // same label and `X → X` would read as a mistake.
+ test('a period whose ends print alike shows one label', async () => {
+ vi.mocked(mmgisGetCurrentTime).mockResolvedValue(
+ '2025-01-01T05:30:00Z',
+ )
+ vi.mocked(mmgisGetTemporalExtents).mockResolvedValue({
+ hourly: { start: '2025-01-01T00:00:00Z', end: null },
+ })
+ const rows = await rowsFor({
+ hourly: {
+ url: appended,
+ time: { enabled: true, type: 'global', interval: 'PT1H' },
+ },
+ })
+ expect(rows[0].dateLine).toBe('Collected 2025-01-01 05:00Z')
+ })
+
+ // An interval under an hour is a run of individually timestamped
+ // scenes, not a period, so it narrows nothing — but it still says how
+ // precisely to print.
+ test('a sub-hour interval never narrows the range to a period', async () => {
+ vi.mocked(mmgisGetTemporalExtents).mockResolvedValue({
+ rapid: { start: '2015-03-13T00:00:00Z', end: null },
+ })
+ const rows = await rowsFor({
+ rapid: {
+ url: appended,
+ time: { enabled: true, type: 'global', interval: 'PT1S' },
+ },
+ })
+ expect(rows[0].dateLine).toBe(
+ 'Collected 2015-03-13T00:00:00Z → 2026-08-25T00:00:00Z',
+ )
+ })
+
+ // How precisely a row prints is the layer's interval's business, not
+ // the mission time format's.
+ test('a fallen-through row prints at its interval precision', async () => {
+ const rows = await rowsFor({
+ yearly: {
+ url: appended,
+ time: { enabled: true, type: 'global', interval: 'P2Y' },
+ },
+ monthly: {
+ url: appended,
+ time: { enabled: true, type: 'global', interval: 'P3M' },
+ },
+ weekly: {
+ url: appended,
+ time: { enabled: true, type: 'global', interval: 'P2W' },
+ },
+ hourly: {
+ url: appended,
+ time: { enabled: true, type: 'global', interval: 'PT6H' },
+ },
+ })
+ expect(rows.map((row) => row.dateLine)).toEqual([
+ 'Requested 2015 → 2026',
+ 'Requested 2015-03 → 2026-08',
+ 'Requested 2015-03-13 → 2026-08-25',
+ 'Requested 2015-03-13 00:00Z → 2026-08-25 00:00Z',
+ ])
+ })
+
+ // An off-calendar cadence is stepped from the coverage start, so a
+ // coverage that names no start names no periods either.
+ test('an off-calendar interval with nothing to anchor on prints the plain overlap', async () => {
+ vi.mocked(mmgisGetTemporalExtents).mockResolvedValue({
+ weekly: { start: null, end: '2020-01-01T00:00:00Z' },
+ })
+ const rows = await rowsFor({
+ weekly: {
+ url: appended,
+ time: { enabled: true, type: 'global', interval: 'P7D' },
+ },
+ })
+ expect(rows[0].dateLine).toBe('Collected 2015-03-13 → 2020-01-01')
+ })
+
+ test('an unparseable interval prints the plain overlap', async () => {
+ vi.mocked(mmgisGetTemporalExtents).mockResolvedValue({
+ odd: { start: '2020-01-01T00:00:00Z', end: null },
+ })
+ const rows = await rowsFor({
+ odd: {
+ url: appended,
+ time: {
+ enabled: true,
+ type: 'global',
+ interval: 'every month or so',
+ },
+ },
+ })
+ expect(rows[0].dateLine).toBe('Collected 2020-01-01 → 2026-08-25')
+ })
+
+ test('a layer that is not time-enabled shows its authored extent', async () => {
+ vi.mocked(mmgisGetTemporalExtents).mockResolvedValue({
+ fixed: {
+ start: '2016-05-01T00:00:00Z',
+ end: '2016-09-01T00:00:00Z',
+ },
+ })
+ const rows = await rowsFor({
+ fixed: {
+ url: 'https://host/{z}/{x}/{y}.png',
+ time: { enabled: false },
+ },
+ })
+ expect(rows[0].dateLine).toBe('Collected 2016-05-01 → 2016-09-01')
+ })
+
+ // A layer that ignores the slider can still carry an interval, and it
+ // still decides how precisely the collected dates print.
+ test('an untimed monthly layer prints its extent to the month', async () => {
+ vi.mocked(mmgisGetTemporalExtents).mockResolvedValue({
+ fixed: {
+ start: '2016-05-01T00:00:00Z',
+ end: '2016-09-01T00:00:00Z',
+ },
+ })
+ const rows = await rowsFor({
+ fixed: {
+ url: 'https://host/{z}/{x}/{y}.png',
+ time: { enabled: false, interval: 'P1M' },
+ },
+ })
+ expect(rows[0].dateLine).toBe('Collected 2016-05 → 2016-09')
+ })
+
+ // An extent covering one day is one day, and `X → X` would only
+ // read as a mistake.
+ test('an extent whose ends print alike collapses to one label', async () => {
+ vi.mocked(mmgisGetTemporalExtents).mockResolvedValue({
+ fixed: {
+ start: '2025-01-12T00:00:00Z',
+ end: '2025-01-12T23:59:59Z',
+ },
+ })
+ const rows = await rowsFor({
+ fixed: {
+ url: 'https://host/{z}/{x}/{y}.png',
+ time: { enabled: false },
+ },
+ })
+ expect(rows[0].dateLine).toBe('Collected 2025-01-12')
+ })
+
+ test('an extent spanning two days still prints both ends', async () => {
+ vi.mocked(mmgisGetTemporalExtents).mockResolvedValue({
+ fixed: {
+ start: '2025-01-12T00:00:00Z',
+ end: '2025-01-13T23:59:59Z',
+ },
+ })
+ const rows = await rowsFor({
+ fixed: {
+ url: 'https://host/{z}/{x}/{y}.png',
+ time: { enabled: false },
+ },
+ })
+ expect(rows[0].dateLine).toBe('Collected 2025-01-12 → 2025-01-13')
+ })
+
+ test('a half-open extent reads as open-ended, not as a range', async () => {
+ vi.mocked(mmgisGetTemporalExtents).mockResolvedValue({
+ fromOnly: { start: '2016-05-01T00:00:00Z', end: null },
+ untilOnly: { start: null, end: '2016-09-01T00:00:00Z' },
+ neither: { start: null, end: null },
+ })
+ const rows = await rowsFor({
+ fromOnly: { url: 'https://host/a' },
+ untilOnly: { url: 'https://host/b' },
+ neither: { url: 'https://host/c' },
+ })
+ expect(rows.map((row) => row.dateLine)).toEqual([
+ 'Collected from 2016-05-01',
+ 'Collected until 2016-09-01',
+ null,
+ ])
+ })
+
+ test('a layer with no extent at all gets no date line', async () => {
+ const rows = await rowsFor({
+ plainOld: { url: 'https://host/{z}/{x}/{y}.png' },
+ })
+ expect(rows[0].dateLine).toBeNull()
+ })
+
+ test('asks core for every layer extent in one call', async () => {
+ await rowsFor({
+ a: { url: 'https://host/a' },
+ b: { url: 'https://host/b' },
+ })
+ expect(vi.mocked(mmgisGetTemporalExtents).mock.calls).toEqual([[]])
+ })
+
+ test('a throwing time bus only costs the rows that follow it', async () => {
+ vi.mocked(mmgisGetTimeStart).mockRejectedValue(new Error('no time'))
+ vi.mocked(mmgisGetCurrentTime).mockRejectedValue(
+ new Error('no time'),
+ )
+ vi.mocked(mmgisGetTemporalExtents).mockResolvedValue({
+ fixed: {
+ start: '2016-05-01T00:00:00Z',
+ end: '2016-09-01T00:00:00Z',
+ },
+ })
+ vi.mocked(mmgisFormatTime).mockRejectedValue(new Error('bad format'))
+ const rows = await rowsFor({
+ live: { url: templated, time: { enabled: true, type: 'global' } },
+ // A local layer carries its own window, and an untimed layer
+ // its own extent, so neither is lost with the global cursor.
+ own: {
+ url: templated,
+ time: {
+ enabled: true,
+ type: 'local',
+ start: '2020-01-01T00:00:00Z',
+ end: '2020-02-01T00:00:00Z',
+ },
+ },
+ fixed: { url: templated, time: { enabled: false } },
+ })
+ expect(rows.map((row) => row.dateLine)).toEqual([
+ null,
+ requested('2020-01-01T00:00:00Z', '2020-02-01T00:00:00Z'),
+ 'Collected 2016-05-01 → 2016-09-01',
+ ])
+ })
+
+ test('a throwing extent sweep leaves the rows intact', async () => {
+ vi.mocked(mmgisGetTemporalExtents).mockRejectedValue(
+ new Error('no extents'),
+ )
+ const rows = await rowsFor({
+ fixed: { url: templated, time: { enabled: false } },
+ })
+ expect(rows.map((row) => row.dateLine)).toEqual([null])
+ })
+
+ test('a layer core has no config for is read as untimed', async () => {
+ vi.mocked(getVisibleLayersWithLegends).mockResolvedValue([
+ gradientLayer('orphan'),
+ ])
+ vi.mocked(mmgisGetLayerConfigs).mockResolvedValue(null)
+ vi.mocked(mmgisGetTemporalExtents).mockResolvedValue({
+ orphan: { start: '2016-05-01T00:00:00Z', end: null },
+ })
+ const model = await getExportLegendModel()
+ expect(model.rows[0].dateLine).toBe('Collected from 2016-05-01')
+ })
+ })
+
+ describe('the header', () => {
+ const anyRow = () => [
+ baseLayer({ type: 'gradient', stops: ['#a', '#b'] }),
+ ]
+
+ beforeEach(() => {
+ vi.useFakeTimers()
+ vi.setSystemTime(new Date('2026-09-02T18:30:00Z'))
+ vi.mocked(getVisibleLayersWithLegends).mockResolvedValue(anyRow())
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ test('prints the cursor and the export time under the mission name', async () => {
+ vi.mocked(mmgisGetCurrentTimeFormatted).mockResolvedValue(
+ 'Sol 1234',
+ )
+ const model = await getExportLegendModel()
+ expect(model.missionName).toBe('Test Mission')
+ expect(model.headerLines).toEqual([
+ 'Time cursor Sol 1234',
+ `Exported ${formatted('2026-09-02T18:30:00.000Z')}`,
+ ])
+ })
+
+ test('leaves the cursor line out when core has no cursor to give', async () => {
+ vi.mocked(mmgisGetCurrentTimeFormatted).mockResolvedValue(null)
+ const model = await getExportLegendModel()
+ expect(model.headerLines).toEqual([
+ `Exported ${formatted('2026-09-02T18:30:00.000Z')}`,
+ ])
+ })
+
+ // The export time is the one date always worth having, so an
+ // unformattable one prints raw rather than vanishing.
+ test('falls back to the raw ISO export time when core cannot format it', async () => {
+ vi.mocked(mmgisFormatTime).mockResolvedValue(null)
+ const model = await getExportLegendModel()
+ expect(model.headerLines).toEqual([
+ 'Exported 2026-09-02T18:30:00.000Z',
+ ])
+ })
+ })
+
+ test('missing authored bounds pass through as null, not an empty string', async () => {
+ vi.mocked(getVisibleLayersWithLegends).mockResolvedValue([
+ baseLayer({
+ title: 'No bounds',
+ type: 'gradient',
+ stops: ['#a', '#b'],
+ unit: { label: 'm' },
+ }),
+ ])
+ const model = await getExportLegendModel()
+ expect(model.rows[0].min).toBeNull()
+ expect(model.rows[0].max).toBeNull()
+ })
+
+ test('renders no unit when neither the layer nor the cog names one', async () => {
+ vi.mocked(getVisibleLayersWithLegends).mockResolvedValue([
+ baseLayer({
+ title: 'Unitless',
+ type: 'gradient',
+ stops: null,
+ cog: {
+ isCog: true,
+ editable: true,
+ colormap: 'viridis',
+ min: 0,
+ max: 1,
+ defaultMin: 0,
+ defaultMax: 1,
+ defaultColormap: 'viridis',
+ units: null,
+ titilerUrl: null,
+ },
+ }),
+ ])
+ vi.mocked(resolveColormapColors).mockResolvedValue(['#000'])
+ const model = await getExportLegendModel()
+ expect(model.rows[0].unit).toBeNull()
+ })
+
+ test('threads the fetched layerConfigs into getVisibleLayersWithLegends rather than refetching', async () => {
+ vi.mocked(getVisibleLayersWithLegends).mockResolvedValue([])
+ const configs = { layer1: { display_name: 'Layer 1' } }
+ vi.mocked(mmgisGetLayerConfigs).mockResolvedValue(configs)
+ await getExportLegendModel()
+ expect(getVisibleLayersWithLegends).toHaveBeenCalledWith(
+ expect.objectContaining({ layerConfigs: configs }),
+ )
+ })
+
+ // A toggled-on layer gets a row. A configured boundingBox says nothing
+ // reliable about where a layer paints — a collection mosaic paints
+ // wherever its collection has data while its declared bbox describes one
+ // granule, and a vector layer's deck.gl path reports that same configured
+ // box — so no footprint, of any layer type, keeps a row out.
+ describe('which layers get a row', () => {
+ const twoLayers = [
+ baseLayer({
+ id: 'near',
+ title: 'Near',
+ type: 'gradient',
+ stops: ['#a', '#b'],
+ }),
+ baseLayer({
+ id: 'far',
+ title: 'Far',
+ type: 'gradient',
+ stops: ['#a', '#b'],
+ }),
+ ]
+
+ test('keeps a raster layer whose configured bounding box is nowhere near the map', async () => {
+ vi.mocked(getVisibleLayersWithLegends).mockResolvedValue(twoLayers)
+ vi.mocked(mmgisGetLayerConfigs).mockResolvedValue({
+ near: { type: 'tile', boundingBox: [-58, -35, -53, -30] },
+ // One granule over Nicaragua, on a mosaic painting over Uruguay.
+ far: { type: 'tile', boundingBox: [-87, 11, -83, 15] },
+ })
+ const model = await getExportLegendModel()
+ expect(model.rows.map((r) => r.title)).toEqual(['Near', 'Far'])
+ })
+
+ test('keeps a vector layer whose configured bounding box is nowhere near the map', async () => {
+ vi.mocked(getVisibleLayersWithLegends).mockResolvedValue(twoLayers)
+ vi.mocked(mmgisGetLayerConfigs).mockResolvedValue({
+ near: { type: 'vector', boundingBox: [-58, -35, -53, -30] },
+ far: { type: 'vector', boundingBox: [-87, 11, -83, 15] },
+ })
+ const model = await getExportLegendModel()
+ expect(model.rows.map((r) => r.title)).toEqual(['Near', 'Far'])
+ })
+
+ test('keeps a layer configured well outside the current zoom range', async () => {
+ vi.mocked(getVisibleLayersWithLegends).mockResolvedValue(twoLayers)
+ vi.mocked(mmgisGetLayerConfigs).mockResolvedValue({
+ near: { type: 'vector' },
+ far: { type: 'vector', minZoom: 18, maxZoom: 20 },
+ })
+ const model = await getExportLegendModel()
+ expect(model.rows.map((r) => r.title)).toEqual(['Near', 'Far'])
+ })
+
+ test('omits a fully transparent layer', async () => {
+ vi.mocked(getVisibleLayersWithLegends).mockResolvedValue([
+ twoLayers[0],
+ { ...twoLayers[1], opacity: 0 },
+ ])
+ const model = await getExportLegendModel()
+ expect(model.rows.map((r) => r.title)).toEqual(['Near'])
+ })
+ })
+})
diff --git a/src/essence/Tools/LayerManager/__tests__/getVisibleLayersWithLegends.spec.js b/src/essence/Tools/_shared/legend/__tests__/getVisibleLayersWithLegends.spec.js
similarity index 52%
rename from src/essence/Tools/LayerManager/__tests__/getVisibleLayersWithLegends.spec.js
rename to src/essence/Tools/_shared/legend/__tests__/getVisibleLayersWithLegends.spec.js
index 982cf8433..3b375fe7c 100644
--- a/src/essence/Tools/LayerManager/__tests__/getVisibleLayersWithLegends.spec.js
+++ b/src/essence/Tools/_shared/legend/__tests__/getVisibleLayersWithLegends.spec.js
@@ -1,5 +1,5 @@
-import { describe, test, expect, afterEach } from 'vitest'
-import { getVisibleLayersWithLegends } from '../adapters/getVisibleLayersWithLegends.ts'
+import { describe, test, expect, afterEach, vi } from 'vitest'
+import { getVisibleLayersWithLegends } from '../getVisibleLayersWithLegends.ts'
/**
* Covers the seam between core and the legend: the COG capabilities core
@@ -25,14 +25,22 @@ const CONFIGS = {
[BASEMAP]: { display_name: 'Basemap', cogColormap: 'viridis' },
}
-const setupMock = ({ capabilities, provideCapability = true, titilerUrls }) => {
+const setupMock = ({
+ capabilities,
+ provideCapability = true,
+ titilerUrls,
+ configs = CONFIGS,
+ visible = { [DISPLACEMENT]: true, [BASEMAP]: true },
+ listed,
+}) => {
const responses = {
- 'layers:getAllConfigs': CONFIGS,
- 'layers:getVisible': { [DISPLACEMENT]: true, [BASEMAP]: true },
+ 'layers:getAllConfigs': configs,
+ 'layers:getVisible': visible,
'layers:getAllOpacities': { [DISPLACEMENT]: 1, [BASEMAP]: 1 },
}
if (provideCapability) responses['layers:getCogCapabilities'] = capabilities
if (titilerUrls) responses['layers:getTiTilerUrl'] = titilerUrls
+ if (listed) responses['layers:getListed'] = listed
global.window = global.window || {}
global.window.mmgisAPI = {
@@ -149,4 +157,127 @@ describe('getVisibleLayersWithLegends', () => {
expect(byId(layers, DISPLACEMENT).cog?.titilerUrl).toBeNull()
})
+
+ // A caller (getExportLegendModel) that already fetched
+ // layers:getAllConfigs for its own purposes can pass it straight
+ // through, so this module never re-requests it from core.
+ test('uses a provided layerConfigs instead of requesting layers:getAllConfigs itself', async () => {
+ const responses = {
+ 'layers:getVisible': { [DISPLACEMENT]: true, [BASEMAP]: true },
+ 'layers:getAllOpacities': { [DISPLACEMENT]: 1, [BASEMAP]: 1 },
+ 'layers:getCogCapabilities': {
+ [DISPLACEMENT]: EDITABLE,
+ [BASEMAP]: NONE,
+ },
+ }
+ global.window = global.window || {}
+ global.window.mmgisAPI = {
+ request: async (name) => {
+ if (name === 'layers:getAllConfigs') {
+ throw new Error(
+ 'layers:getAllConfigs should not be requested when layerConfigs is provided',
+ )
+ }
+ if (responses[name] === undefined)
+ throw new Error(`No handler for ${name}`)
+ return responses[name]
+ },
+ hasHandler: (name) => responses[name] !== undefined,
+ on: () => () => {},
+ emit: () => {},
+ }
+
+ const layers = await getVisibleLayersWithLegends({
+ layerConfigs: CONFIGS,
+ })
+
+ expect(layers).toHaveLength(2)
+ expect(byId(layers, DISPLACEMENT).cog).not.toBeNull()
+ })
+})
+
+describe('getVisibleLayersWithLegends filtering', () => {
+ afterEach(() => {
+ delete global.window.mmgisAPI
+ })
+
+ // showOnlyVisible is what keeps a toggled-off layer out of an export's
+ // legend band. Without the guard the band lists layers that are not on
+ // the map.
+ test('showOnlyVisible drops a toggled-off layer', async () => {
+ setupMock({
+ capabilities: { [DISPLACEMENT]: NONE, [BASEMAP]: NONE },
+ visible: { [DISPLACEMENT]: true, [BASEMAP]: false },
+ })
+ const layers = await getVisibleLayersWithLegends({ showOnlyVisible: true })
+
+ expect(layers.map((l) => l.id)).toEqual([DISPLACEMENT])
+ expect(byId(layers, BASEMAP)).toBeUndefined()
+ })
+
+ test('without showOnlyVisible the toggled-off layer is still listed', async () => {
+ setupMock({
+ capabilities: { [DISPLACEMENT]: NONE, [BASEMAP]: NONE },
+ visible: { [DISPLACEMENT]: true, [BASEMAP]: false },
+ })
+ const layers = await getVisibleLayersWithLegends()
+
+ expect(layers.map((l) => l.id).sort()).toEqual(
+ [DISPLACEMENT, BASEMAP].sort(),
+ )
+ expect(byId(layers, BASEMAP).visible).toBe(false)
+ })
+
+ // A `header` config is a grouping row in the layer list, not a layer, so
+ // it has nothing to draw and must never reach the band.
+ test('a header config gets no row', async () => {
+ setupMock({
+ capabilities: {},
+ configs: {
+ ...CONFIGS,
+ 'Group_aaaaaaaaaaaaaaaa': { display_name: 'Group', type: 'header' },
+ },
+ visible: {
+ [DISPLACEMENT]: true,
+ [BASEMAP]: true,
+ 'Group_aaaaaaaaaaaaaaaa': true,
+ },
+ })
+ const layers = await getVisibleLayersWithLegends({ showOnlyVisible: true })
+
+ expect(layers.map((l) => l.id).sort()).toEqual(
+ [DISPLACEMENT, BASEMAP].sort(),
+ )
+ })
+
+ // A layer core reports as unlisted is deliberately hidden from the layer
+ // UI, so it stays out of the legend too.
+ test('an unlisted layer gets no row', async () => {
+ setupMock({
+ capabilities: {},
+ listed: { [DISPLACEMENT]: true, [BASEMAP]: false },
+ })
+ const layers = await getVisibleLayersWithLegends({ showOnlyVisible: true })
+
+ expect(layers.map((l) => l.id)).toEqual([DISPLACEMENT])
+ })
+
+ // Fail open: core answering nothing is not "every layer is hidden".
+ // Dropping all rows on a null answer would silently empty the band.
+ test('a null visibility map keeps every layer, with a warning', async () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ try {
+ setupMock({ capabilities: {}, visible: null })
+ const layers = await getVisibleLayersWithLegends({
+ showOnlyVisible: true,
+ })
+
+ expect(layers.map((l) => l.id).sort()).toEqual(
+ [DISPLACEMENT, BASEMAP].sort(),
+ )
+ expect(warn).toHaveBeenCalled()
+ } finally {
+ warn.mockRestore()
+ }
+ })
})
diff --git a/src/essence/Tools/_shared/legend/__tests__/layerPeriod.spec.ts b/src/essence/Tools/_shared/legend/__tests__/layerPeriod.spec.ts
new file mode 100644
index 000000000..df586edfa
--- /dev/null
+++ b/src/essence/Tools/_shared/legend/__tests__/layerPeriod.spec.ts
@@ -0,0 +1,239 @@
+import { describe, test, expect } from 'vitest'
+import { layerPeriodFor } from '../layerPeriod'
+
+// A period runs from its first instant up to the first instant of the next
+// one, so a caller can tell whether a layer has any data inside it.
+describe('layerPeriodFor calendar-aligned units', () => {
+ test('P1Y is the UTC year holding the cursor', () => {
+ expect(layerPeriodFor('P1Y', '2025-06-15T12:00:00Z', null)).toEqual({
+ start: '2025-01-01T00:00:00.000Z',
+ end: '2026-01-01T00:00:00.000Z',
+ })
+ })
+
+ test('P1M snaps to the UTC month holding the cursor', () => {
+ expect(layerPeriodFor('P1M', '2025-06-15T12:00:00Z', null)).toEqual({
+ start: '2025-06-01T00:00:00.000Z',
+ end: '2025-07-01T00:00:00.000Z',
+ })
+ // The last instant of a month still belongs to that month, and the
+ // first instant of the next one has already moved on.
+ expect(layerPeriodFor('P1M', '2025-06-30T23:59:59Z', null)).toEqual({
+ start: '2025-06-01T00:00:00.000Z',
+ end: '2025-07-01T00:00:00.000Z',
+ })
+ expect(layerPeriodFor('P1M', '2025-07-01T00:00:00Z', null)).toEqual({
+ start: '2025-07-01T00:00:00.000Z',
+ end: '2025-08-01T00:00:00.000Z',
+ })
+ })
+
+ // December's next boundary is the following January, not a thirteenth
+ // month.
+ test('P1M in December ends on the new year', () => {
+ expect(layerPeriodFor('P1M', '2025-12-09T00:00:00Z', null)).toEqual({
+ start: '2025-12-01T00:00:00.000Z',
+ end: '2026-01-01T00:00:00.000Z',
+ })
+ })
+
+ test('P1D is the UTC day holding the cursor', () => {
+ expect(layerPeriodFor('P1D', '2025-06-15T23:30:00Z', null)).toEqual({
+ start: '2025-06-15T00:00:00.000Z',
+ end: '2025-06-16T00:00:00.000Z',
+ })
+ })
+})
+
+describe('layerPeriodFor anchored durations', () => {
+ test('P7D steps from the anchor to the week holding the cursor', () => {
+ expect(
+ layerPeriodFor(
+ 'P7D',
+ '2025-01-10T06:00:00Z',
+ '2025-01-01T00:00:00Z',
+ ),
+ ).toEqual({
+ start: '2025-01-08T00:00:00.000Z',
+ end: '2025-01-15T00:00:00.000Z',
+ })
+ })
+
+ test('P1W is a week like P7D, not a calendar unit', () => {
+ expect(
+ layerPeriodFor(
+ 'P1W',
+ '2025-01-10T06:00:00Z',
+ '2025-01-01T00:00:00Z',
+ ),
+ ).toEqual({
+ start: '2025-01-08T00:00:00.000Z',
+ end: '2025-01-15T00:00:00.000Z',
+ })
+ })
+
+ test('a compound month-and-day duration steps by calendar', () => {
+ // Each step is measured from the anchor, so two P1M10D steps put the
+ // month on first and count the days off the month they landed on.
+ expect(
+ layerPeriodFor(
+ 'P1M10D',
+ '2025-03-01T00:00:00Z',
+ '2025-01-01T00:00:00Z',
+ ),
+ ).toEqual({
+ start: '2025-02-11T00:00:00.000Z',
+ end: '2025-03-21T00:00:00.000Z',
+ })
+ })
+
+ test('PT6H steps in sub-day periods', () => {
+ expect(
+ layerPeriodFor(
+ 'PT6H',
+ '2025-01-01T13:10:00Z',
+ '2025-01-01T00:00:00Z',
+ ),
+ ).toEqual({
+ start: '2025-01-01T12:00:00.000Z',
+ end: '2025-01-01T18:00:00.000Z',
+ })
+ })
+
+ test('a cursor sitting exactly on a boundary starts that period', () => {
+ expect(
+ layerPeriodFor(
+ 'P7D',
+ '2025-01-08T00:00:00Z',
+ '2025-01-01T00:00:00Z',
+ ),
+ ).toEqual({
+ start: '2025-01-08T00:00:00.000Z',
+ end: '2025-01-15T00:00:00.000Z',
+ })
+ })
+
+ test('a cursor inside the first period returns the anchor period', () => {
+ expect(
+ layerPeriodFor(
+ 'P7D',
+ '2025-01-02T00:00:00Z',
+ '2025-01-01T00:00:00Z',
+ ),
+ ).toEqual({
+ start: '2025-01-01T00:00:00.000Z',
+ end: '2025-01-08T00:00:00.000Z',
+ })
+ })
+
+ test('months step by calendar, not by a fixed number of days', () => {
+ // Six 2-month steps from 2024-01-01 land on 2025-01-01 by calendar.
+ // Stepping by a fixed 60 days would drift to 2024-12-26 and report a
+ // period that starts in December.
+ expect(
+ layerPeriodFor(
+ 'P2M',
+ '2024-12-15T00:00:00Z',
+ '2024-01-01T00:00:00Z',
+ ),
+ ).toEqual({
+ start: '2024-11-01T00:00:00.000Z',
+ end: '2025-01-01T00:00:00.000Z',
+ })
+ })
+
+ test('years step by calendar across a leap year', () => {
+ expect(
+ layerPeriodFor(
+ 'P2Y',
+ '2027-05-01T00:00:00Z',
+ '2020-01-01T00:00:00Z',
+ ),
+ ).toEqual({
+ start: '2026-01-01T00:00:00.000Z',
+ end: '2028-01-01T00:00:00.000Z',
+ })
+ })
+
+ test('is null with no anchor to step from', () => {
+ expect(layerPeriodFor('P7D', '2025-01-10T00:00:00Z', null)).toBeNull()
+ expect(
+ layerPeriodFor('P7D', '2025-01-10T00:00:00Z', 'not a date'),
+ ).toBeNull()
+ })
+
+ test('is null for a cursor earlier than the anchor', () => {
+ expect(
+ layerPeriodFor(
+ 'P7D',
+ '2019-01-10T00:00:00Z',
+ '2025-01-01T00:00:00Z',
+ ),
+ ).toBeNull()
+ })
+})
+
+describe('layerPeriodFor rejections', () => {
+ test('is null for an unparseable interval', () => {
+ expect(
+ layerPeriodFor(
+ 'every so often',
+ '2025-06-15T00:00:00Z',
+ '2020-01-01T00:00:00Z',
+ ),
+ ).toBeNull()
+ })
+
+ // A zero-length period contains nothing and would never advance.
+ test('is null for a zero-length interval', () => {
+ expect(
+ layerPeriodFor(
+ 'P0D',
+ '2025-06-15T00:00:00Z',
+ '2020-01-01T00:00:00Z',
+ ),
+ ).toBeNull()
+ })
+
+ // A cadence that needs more steps than any mission would run is a bad
+ // config, not a period: stop stepping rather than spin on it.
+ test('is null when stepping to the cursor would never end', () => {
+ expect(
+ layerPeriodFor(
+ 'P1M1D',
+ '9999-01-01T00:00:00Z',
+ '1000-01-01T00:00:00Z',
+ ),
+ ).toBeNull()
+ })
+
+ // An interval under an hour is not a cadence of periods but a run of
+ // individually timestamped scenes, so there is no period to name.
+ test('is null for an interval shorter than an hour', () => {
+ const cursor = '2025-01-01T05:30:00Z'
+ const anchor = '2025-01-01T00:00:00Z'
+ expect(layerPeriodFor('PT1S', cursor, anchor)).toBeNull()
+ expect(layerPeriodFor('PT30M', cursor, anchor)).toBeNull()
+ expect(layerPeriodFor('PT59M59S', cursor, anchor)).toBeNull()
+ })
+
+ test('an hour itself is still a period', () => {
+ expect(
+ layerPeriodFor(
+ 'PT1H',
+ '2025-01-01T05:30:00Z',
+ '2025-01-01T00:00:00Z',
+ ),
+ ).toEqual({
+ start: '2025-01-01T05:00:00.000Z',
+ end: '2025-01-01T06:00:00.000Z',
+ })
+ })
+
+ test('is null without a cursor to place', () => {
+ expect(layerPeriodFor('P1M', null, '2020-01-01T00:00:00Z')).toBeNull()
+ expect(
+ layerPeriodFor('P1M', 'not a date', '2020-01-01T00:00:00Z'),
+ ).toBeNull()
+ })
+})
diff --git a/src/essence/Tools/_shared/legend/__tests__/renderLegendBand.spec.js b/src/essence/Tools/_shared/legend/__tests__/renderLegendBand.spec.js
new file mode 100644
index 000000000..e34d12d08
--- /dev/null
+++ b/src/essence/Tools/_shared/legend/__tests__/renderLegendBand.spec.js
@@ -0,0 +1,639 @@
+import { describe, test, expect, vi } from 'vitest'
+import { measureLegendBand, drawLegendBand } from '../renderLegendBand.ts'
+
+const makeCtx = () => {
+ const fillRectCalls = []
+ const fillTextCalls = []
+ const gradients = []
+ const ctx = {
+ fillStyle: null,
+ font: '',
+ textBaseline: 'alphabetic',
+ // Recording `fillStyle` (and `font`, for fillText) at call time — not
+ // just the draw args — is what lets a test tell a real color from an
+ // all-white band: the call args alone never carry the paint color.
+ fillRect: (...args) =>
+ fillRectCalls.push({ args, fillStyle: ctx.fillStyle }),
+ fillText: (...args) =>
+ fillTextCalls.push({ args, fillStyle: ctx.fillStyle, font: ctx.font }),
+ measureText: (t) => ({ width: t.length * 6 }),
+ createLinearGradient: (...args) => {
+ const stops = []
+ const gradient = {
+ addColorStop: (offset, color) => stops.push({ offset, color }),
+ }
+ gradients.push({ args, stops, gradient })
+ return gradient
+ },
+ save: vi.fn(),
+ restore: vi.fn(),
+ }
+ return { ctx, fillRectCalls, fillTextCalls, gradients }
+}
+
+const emptyModel = { missionName: null, headerLines: [], rows: [] }
+
+const gradientRow = (overrides = {}) => ({
+ kind: 'gradient',
+ title: 'Displacement',
+ dateLine: null,
+ colors: ['#000', '#fff'],
+ min: 0,
+ max: 10,
+ unit: null,
+ ...overrides,
+})
+
+const categoricalRow = (stops, overrides = {}) => ({
+ kind: 'categorical',
+ title: 'Classes',
+ dateLine: null,
+ stops,
+ ...overrides,
+})
+
+const plainRow = (overrides = {}) => ({
+ kind: 'plain',
+ title: 'Basemap',
+ dateLine: null,
+ ...overrides,
+})
+
+describe('measureLegendBand', () => {
+ test('is 0 for a model with no rows', () => {
+ const { ctx } = makeCtx()
+ expect(measureLegendBand(ctx, emptyModel, 400, 1)).toBe(0)
+ })
+
+ test('grows as rows are added', () => {
+ const { ctx } = makeCtx()
+ const oneRow = {
+ missionName: null,
+ headerLines: [],
+ rows: [gradientRow()],
+ }
+ const twoRows = {
+ missionName: null,
+ headerLines: [],
+ rows: [gradientRow(), gradientRow({ title: 'Second' })],
+ }
+ const h1 = measureLegendBand(ctx, oneRow, 400, 1)
+ const h2 = measureLegendBand(ctx, twoRows, 400, 1)
+ expect(h1).toBeGreaterThan(0)
+ expect(h2).toBeGreaterThan(h1)
+ })
+
+ test('categorical wrapping adds height when labels exceed the width', () => {
+ const { ctx } = makeCtx()
+ const manyStops = Array.from({ length: 20 }, (_, i) => ({
+ color: '#abc',
+ label: `Category number ${i}`,
+ }))
+ const narrow = {
+ missionName: null,
+ headerLines: [],
+ rows: [categoricalRow(manyStops)],
+ }
+ const wide = {
+ missionName: null,
+ headerLines: [],
+ rows: [categoricalRow(manyStops.slice(0, 2))],
+ }
+ const hNarrow = measureLegendBand(ctx, narrow, 300, 1)
+ const hWide = measureLegendBand(ctx, wide, 300, 1)
+ expect(hNarrow).toBeGreaterThan(hWide)
+ })
+
+ test('a row with a date line is taller by exactly one label line', () => {
+ const { ctx } = makeCtx()
+ const without = {
+ missionName: null,
+ headerLines: [],
+ rows: [gradientRow()],
+ }
+ const with_ = {
+ missionName: null,
+ headerLines: [],
+ rows: [gradientRow({ dateLine: '2024-01-01 → 2024-02-01' })],
+ }
+ // LABEL_TEXT (11) + LINE_GAP (6), the same advance the draw pass makes
+ // before the row's body.
+ expect(measureLegendBand(ctx, with_, 400, 1)).toBe(
+ measureLegendBand(ctx, without, 400, 1) + 17,
+ )
+ expect(measureLegendBand(ctx, with_, 400, 2)).toBe(
+ measureLegendBand(ctx, without, 400, 2) + 34,
+ )
+ })
+
+ test('each header line adds one label line to the band', () => {
+ const { ctx } = makeCtx()
+ const nameOnly = {
+ missionName: 'M20',
+ headerLines: [],
+ rows: [gradientRow()],
+ }
+ const withLines = {
+ missionName: 'M20',
+ headerLines: ['Time cursor 2024-02-01', 'Exported 2024-03-04'],
+ rows: [gradientRow()],
+ }
+ // LINE_GAP (6) + LABEL_TEXT (11) apiece, the same advance the draw
+ // pass makes between header lines.
+ expect(measureLegendBand(ctx, withLines, 400, 1)).toBe(
+ measureLegendBand(ctx, nameOnly, 400, 1) + 34,
+ )
+ })
+
+ test('a plain row is shorter than a gradient row by its bar and bounds', () => {
+ const { ctx } = makeCtx()
+ const plain = { missionName: null, headerLines: [], rows: [plainRow()] }
+ const gradient = {
+ missionName: null,
+ headerLines: [],
+ rows: [gradientRow()],
+ }
+ expect(measureLegendBand(ctx, plain, 400, 1)).toBeLessThan(
+ measureLegendBand(ctx, gradient, 400, 1),
+ )
+ // TITLE_TEXT (13) + LINE_GAP (6) + BAR_HEIGHT (12) + LINE_GAP (6) +
+ // LABEL_TEXT (11) against a title line alone.
+ expect(measureLegendBand(ctx, gradient, 400, 1)).toBe(
+ measureLegendBand(ctx, plain, 400, 1) + 35,
+ )
+ })
+
+ test('a plain row with a date line is taller by exactly one label line', () => {
+ const { ctx } = makeCtx()
+ const bare = { missionName: null, headerLines: [], rows: [plainRow()] }
+ const dated = {
+ missionName: null,
+ headerLines: [],
+ rows: [plainRow({ dateLine: 'Collected from 2016-05-01' })],
+ }
+ expect(measureLegendBand(ctx, dated, 400, 1)).toBe(
+ measureLegendBand(ctx, bare, 400, 1) + 17,
+ )
+ })
+
+ test('scale 2 doubles the measured height of the same model', () => {
+ const { ctx } = makeCtx()
+ const model = {
+ missionName: 'Mission',
+ headerLines: [],
+ rows: [gradientRow()],
+ }
+ const h1 = measureLegendBand(ctx, model, 400, 1)
+ const h2 = measureLegendBand(ctx, model, 400, 2)
+ expect(h2).toBe(h1 * 2)
+ })
+})
+
+describe('drawLegendBand', () => {
+ test('paints the white band exactly at yTop covering width x bandHeight', () => {
+ const { ctx, fillRectCalls } = makeCtx()
+ const model = {
+ missionName: null,
+ headerLines: [],
+ rows: [gradientRow()],
+ }
+ drawLegendBand(ctx, model, 400, 600, 100, 1)
+ expect(fillRectCalls[0].args).toEqual([0, 600, 400, 100])
+ })
+
+ test('draws the mission name, then whatever header lines the model built', () => {
+ const { ctx, fillTextCalls } = makeCtx()
+ const model = {
+ missionName: 'M20',
+ headerLines: ['Time cursor 2024-02-01', 'Exported 2024-03-04'],
+ rows: [gradientRow()],
+ }
+ drawLegendBand(ctx, model, 400, 0, 300, 1)
+ const [mission, cursor, exported, title] = fillTextCalls
+ expect(mission.args[0]).toBe('M20')
+ expect(mission.font).toBe('bold 15px sans-serif')
+ expect(cursor.args[0]).toBe('Time cursor 2024-02-01')
+ expect(exported.args[0]).toBe('Exported 2024-03-04')
+ // Header lines are the model's words: the renderer prints them in
+ // order, in the label style, and only then starts the rows.
+ expect(cursor.font).toBe('11px sans-serif')
+ expect(cursor.args[1]).toBe(mission.args[1])
+ expect(exported.args[2]).toBeGreaterThan(cursor.args[2])
+ expect(title.args[0]).toBe('Displacement')
+ })
+
+ test('draws header lines even with no mission name', () => {
+ const { ctx, fillTextCalls } = makeCtx()
+ const model = {
+ missionName: null,
+ headerLines: ['Exported 2024-03-04'],
+ rows: [gradientRow()],
+ }
+ drawLegendBand(ctx, model, 400, 0, 300, 1)
+ expect(fillTextCalls[0].args[0]).toBe('Exported 2024-03-04')
+ })
+
+ test('clips a long header line rather than overflowing the band', () => {
+ const { ctx, fillTextCalls } = makeCtx()
+ const model = {
+ missionName: null,
+ headerLines: ['E'.repeat(200)],
+ rows: [gradientRow()],
+ }
+ drawLegendBand(ctx, model, 300, 0, 300, 1)
+ expect(fillTextCalls[0].args[0].length).toBeLessThan(200)
+ expect(fillTextCalls[0].args[0].endsWith('…')).toBe(true)
+ })
+
+ test('draws a row date line under its title in the smaller label style', () => {
+ const { ctx, fillTextCalls } = makeCtx()
+ const model = {
+ missionName: null,
+ headerLines: [],
+ rows: [gradientRow({ dateLine: '2024-01-01 → 2024-02-01' })],
+ }
+ drawLegendBand(ctx, model, 400, 0, 200, 1)
+ const [titleCall, timeCall] = fillTextCalls
+ expect(titleCall.args[0]).toBe('Displacement')
+ expect(timeCall.args[0]).toBe('2024-01-01 → 2024-02-01')
+ // Same column as the title, on the line below it, in the label font.
+ expect(timeCall.args[1]).toBe(titleCall.args[1])
+ expect(timeCall.args[2]).toBeGreaterThan(titleCall.args[2])
+ expect(timeCall.font).toBe('11px sans-serif')
+ })
+
+ test('draws no date line for a row without one', () => {
+ const { ctx, fillTextCalls } = makeCtx()
+ const model = {
+ missionName: null,
+ headerLines: [],
+ rows: [gradientRow()],
+ }
+ drawLegendBand(ctx, model, 400, 0, 200, 1)
+ // Title, then the two gradient bounds — nothing between them.
+ expect(fillTextCalls.map(({ args: [text] }) => text)).toEqual([
+ 'Displacement',
+ '0',
+ '10',
+ ])
+ })
+
+ test('clips a long date line rather than overflowing the band', () => {
+ const { ctx, fillTextCalls } = makeCtx()
+ const model = {
+ missionName: null,
+ headerLines: [],
+ rows: [gradientRow({ dateLine: 'T'.repeat(200) })],
+ }
+ drawLegendBand(ctx, model, 300, 0, 200, 1)
+ const timeCall = fillTextCalls[1]
+ expect(timeCall.args[0].length).toBeLessThan(200)
+ expect(timeCall.args[0].endsWith('…')).toBe(true)
+ })
+
+ test('draws a plain row as a name alone, with no graphic', () => {
+ const { ctx, fillTextCalls, fillRectCalls, gradients } = makeCtx()
+ const model = { missionName: null, headerLines: [], rows: [plainRow()] }
+ drawLegendBand(ctx, model, 400, 0, 200, 1)
+ expect(fillTextCalls.map(({ args: [text] }) => text)).toEqual([
+ 'Basemap',
+ ])
+ expect(gradients).toHaveLength(0)
+ // Only the band background and its top border are painted.
+ expect(fillRectCalls).toHaveLength(2)
+ })
+
+ test('draws a plain row date line under its name', () => {
+ const { ctx, fillTextCalls } = makeCtx()
+ const model = {
+ missionName: null,
+ headerLines: [],
+ rows: [plainRow({ dateLine: 'Collected from 2016-05-01' })],
+ }
+ drawLegendBand(ctx, model, 400, 0, 200, 1)
+ const [titleCall, dateCall] = fillTextCalls
+ expect(titleCall.args[0]).toBe('Basemap')
+ expect(dateCall.args[0]).toBe('Collected from 2016-05-01')
+ expect(dateCall.args[1]).toBe(titleCall.args[1])
+ expect(dateCall.args[2]).toBeGreaterThan(titleCall.args[2])
+ expect(dateCall.font).toBe('11px sans-serif')
+ })
+
+ test('draws min/max bounds with units via formatLegendBound', () => {
+ const { ctx, fillTextCalls } = makeCtx()
+ const model = {
+ missionName: null,
+ headerLines: [],
+ rows: [gradientRow({ min: 2, max: 8, unit: 'K' })],
+ }
+ drawLegendBand(ctx, model, 400, 0, 200, 1)
+ const texts = fillTextCalls.map(({ args: [text] }) => text)
+ expect(texts).toContain('2 K')
+ expect(texts).toContain('8 K')
+ })
+
+ test('null colors fall back to the neutral ramp without throwing', () => {
+ const { ctx, gradients } = makeCtx()
+ const model = {
+ missionName: null,
+ headerLines: [],
+ rows: [gradientRow({ colors: null })],
+ }
+ expect(() => drawLegendBand(ctx, model, 400, 0, 200, 1)).not.toThrow()
+ // Proof the ramp itself was painted, not just background/border
+ // rects: the gradient built for the bar carries the neutral colors.
+ expect(gradients).toHaveLength(1)
+ expect(gradients[0].stops.map((s) => s.color)).toEqual([
+ '#bdbdbd',
+ '#757575',
+ ])
+ })
+
+ test('a blank stop color in an otherwise-colored gradient still renders', () => {
+ const { ctx, gradients } = makeCtx()
+ const model = {
+ missionName: null,
+ headerLines: [],
+ rows: [gradientRow({ colors: ['#000', '', '#fff'] })],
+ }
+ expect(() => drawLegendBand(ctx, model, 400, 0, 200, 1)).not.toThrow()
+ expect(gradients).toHaveLength(1)
+ // The blank stop falls back to the neutral swatch; its neighbors keep
+ // their authored colors.
+ expect(gradients[0].stops.map((s) => s.color)).toEqual([
+ '#000',
+ '#bdbdbd',
+ '#fff',
+ ])
+ })
+
+ // A one-color ramp has no interpolation to build. addColorStop's offset
+ // would be i / (length - 1) = NaN, which a real canvas rejects outright,
+ // taking the whole band down with it.
+ test('a single-color ramp fills a solid bar and builds no gradient', () => {
+ const { ctx, fillRectCalls, gradients } = makeCtx()
+ const model = {
+ missionName: null,
+ headerLines: [],
+ rows: [gradientRow({ colors: ['#ff0000'] })],
+ }
+ expect(() => drawLegendBand(ctx, model, 400, 0, 200, 1)).not.toThrow()
+ expect(gradients).toHaveLength(0)
+ const bar = fillRectCalls.find((c) => c.fillStyle === '#ff0000')
+ expect(bar).toBeDefined()
+ expect(bar.args[2]).toBeGreaterThan(0)
+ expect(bar.args[3]).toBeGreaterThan(0)
+ })
+
+ test('an empty colors array falls back to the neutral ramp', () => {
+ const { ctx, gradients } = makeCtx()
+ const model = {
+ missionName: null,
+ headerLines: [],
+ rows: [gradientRow({ colors: [] })],
+ }
+ expect(() => drawLegendBand(ctx, model, 400, 0, 200, 1)).not.toThrow()
+ expect(gradients).toHaveLength(1)
+ expect(gradients[0].stops.map((s) => s.color)).toEqual([
+ '#bdbdbd',
+ '#757575',
+ ])
+ })
+
+ // Blank bounds (an unrescaled raster) leave the labels empty rather than
+ // printing an invented range.
+ test('null bounds draw as empty labels', () => {
+ const { ctx, fillTextCalls } = makeCtx()
+ const model = {
+ missionName: null,
+ headerLines: [],
+ rows: [gradientRow({ min: null, max: null, unit: 'm' })],
+ }
+ drawLegendBand(ctx, model, 400, 0, 200, 1)
+ const texts = fillTextCalls.map(({ args: [text] }) => text)
+ expect(texts).toEqual(['Displacement', '', ''])
+ })
+})
+
+describe('drawLegendBand gradient bounds', () => {
+ // Everything after the row title is a bound label: min first, then max.
+ const boundCalls = (fillTextCalls) => fillTextCalls.slice(1)
+
+ test('clips bounds so a long min and max cannot overlap', () => {
+ const { ctx, fillTextCalls } = makeCtx()
+ const model = {
+ missionName: null,
+ headerLines: [],
+ rows: [
+ gradientRow({
+ min: '0.000000000000123 microseconds per parsec',
+ max: '9.999999999999876 microseconds per parsec',
+ }),
+ ],
+ }
+ drawLegendBand(ctx, model, 400, 0, 400, 1)
+ const [minCall, maxCall] = boundCalls(fillTextCalls)
+ expect(minCall.args[0].endsWith('\u2026')).toBe(true)
+ expect(maxCall.args[0].endsWith('\u2026')).toBe(true)
+ const minRight =
+ minCall.args[1] + ctx.measureText(minCall.args[0]).width
+ expect(minRight).toBeLessThanOrEqual(maxCall.args[1])
+ })
+
+ test('never starts the max bound left of the bar', () => {
+ const { ctx, fillTextCalls } = makeCtx()
+ const model = {
+ missionName: null,
+ headerLines: [],
+ rows: [gradientRow({ min: 0, max: 'X'.repeat(300) })],
+ }
+ // A band narrower than the bar's nominal width, so the clamp is what
+ // keeps the right-aligned label on canvas.
+ drawLegendBand(ctx, model, 160, 0, 400, 1)
+ const [, maxCall] = boundCalls(fillTextCalls)
+ expect(maxCall.args[1]).toBeGreaterThanOrEqual(16)
+ })
+
+ test('leaves short bounds unclipped, at the bar edges', () => {
+ const { ctx, fillTextCalls } = makeCtx()
+ const model = {
+ missionName: null,
+ headerLines: [],
+ rows: [gradientRow({ min: 2, max: 8, unit: 'K' })],
+ }
+ drawLegendBand(ctx, model, 400, 0, 200, 1)
+ const [minCall, maxCall] = boundCalls(fillTextCalls)
+ expect(minCall.args[0]).toBe('2 K')
+ expect(maxCall.args[0]).toBe('8 K')
+ // PAD (16) for the min; PAD + BAR_WIDTH (260) less the label's own
+ // measured width for the right-aligned max.
+ expect(minCall.args[1]).toBe(16)
+ expect(maxCall.args[1]).toBe(16 + 260 - ctx.measureText('8 K').width)
+ })
+})
+
+describe('drawLegendBand text clipping', () => {
+ test('clips a long header to fit the band width, with an ellipsis', () => {
+ const { ctx, fillTextCalls } = makeCtx()
+ const model = {
+ missionName: 'A'.repeat(200),
+ headerLines: [],
+ rows: [gradientRow()],
+ }
+ drawLegendBand(ctx, model, 300, 0, 200, 1)
+ const headerText = fillTextCalls[0].args[0]
+ expect(headerText.length).toBeLessThan(200)
+ expect(headerText.endsWith('…')).toBe(true)
+ })
+
+ test('clips a long row title to fit the band width, with an ellipsis', () => {
+ const { ctx, fillTextCalls } = makeCtx()
+ const model = {
+ missionName: null,
+ headerLines: [],
+ rows: [gradientRow({ title: 'B'.repeat(200) })],
+ }
+ drawLegendBand(ctx, model, 300, 0, 200, 1)
+ const titleText = fillTextCalls[0].args[0]
+ expect(titleText.length).toBeLessThan(200)
+ expect(titleText.endsWith('…')).toBe(true)
+ })
+
+ test('clips an oversized category label rather than overflowing its line', () => {
+ const { ctx, fillTextCalls } = makeCtx()
+ const model = {
+ missionName: null,
+ headerLines: [],
+ rows: [categoricalRow([{ color: '#abc', label: 'C'.repeat(200) }])],
+ }
+ drawLegendBand(ctx, model, 300, 0, 200, 1)
+ // 'Classes' (the row's own title) also starts with 'C' — look past it
+ // for the long swatch label specifically.
+ const labelCall = fillTextCalls.find(
+ ({ args: [text] }) => text.startsWith('C') && text.length > 10,
+ )
+ expect(labelCall.args[0].length).toBeLessThan(200)
+ expect(labelCall.args[0].endsWith('…')).toBe(true)
+ })
+
+ test('a short header/title/label is left untouched', () => {
+ const { ctx, fillTextCalls } = makeCtx()
+ const model = {
+ missionName: 'M20',
+ headerLines: [],
+ rows: [gradientRow({ title: 'Short' })],
+ }
+ drawLegendBand(ctx, model, 400, 0, 200, 1)
+ expect(fillTextCalls[0].args[0]).toBe('M20')
+ expect(fillTextCalls[1].args[0]).toBe('Short')
+ })
+})
+
+describe('drawn primitives stay within the measured band (regression)', () => {
+ // Pins a property a reviewer probe confirmed already holds: nothing the
+ // draw pass paints — a fillRect or a line of text — extends past the
+ // band height the measure pass computed for the same model.
+ const fontPx = (font) => {
+ const match = font.match(/(\d+)px/)
+ return match ? Number(match[1]) : 0
+ }
+
+ // The deepest painted content, ignoring the two full-height rects the
+ // background and top border paint.
+ const assertNoSlack = (model, width, scale) => {
+ const { ctx, fillRectCalls } = makeCtx()
+ const bandHeight = measureLegendBand(ctx, model, width, scale)
+ const yTop = 50
+ drawLegendBand(ctx, model, width, yTop, bandHeight, scale)
+ const contentBottom = Math.max(
+ ...fillRectCalls.slice(2).map(({ args: [, y, , h] }) => y + h),
+ )
+ expect(contentBottom).toBe(yTop + bandHeight - 16 * scale)
+ }
+
+ const assertWithinBand = (model, width, scale) => {
+ const { ctx, fillRectCalls, fillTextCalls } = makeCtx()
+ const bandHeight = measureLegendBand(ctx, model, width, scale)
+ const yTop = 50
+ const bottomLimit = yTop + bandHeight
+ drawLegendBand(ctx, model, width, yTop, bandHeight, scale)
+
+ for (const { args } of fillRectCalls) {
+ const [, y, , h] = args
+ expect(y + h).toBeLessThanOrEqual(bottomLimit)
+ }
+ for (const { args, font } of fillTextCalls) {
+ const [, , y] = args
+ expect(y + fontPx(font)).toBeLessThanOrEqual(bottomLimit)
+ }
+ }
+
+ test('gradient-only model at scale 1 and 2', () => {
+ const model = {
+ missionName: 'M20',
+ headerLines: [],
+ rows: [gradientRow(), gradientRow({ title: 'Second' })],
+ }
+ assertWithinBand(model, 400, 1)
+ assertWithinBand(model, 400, 2)
+ })
+
+ test('wrapping categorical model at scale 1 and 2', () => {
+ const manyStops = Array.from({ length: 20 }, (_, i) => ({
+ color: '#abc',
+ label: `Category number ${i}`,
+ }))
+ const model = {
+ missionName: null,
+ headerLines: [],
+ rows: [categoricalRow(manyStops)],
+ }
+ assertWithinBand(model, 300, 1)
+ assertWithinBand(model, 300, 2)
+ })
+
+ test('model where only some rows carry a date line, at scale 1 and 2', () => {
+ const manyStops = Array.from({ length: 20 }, (_, i) => ({
+ color: '#abc',
+ label: `Category number ${i}`,
+ }))
+ const model = {
+ missionName: 'M20',
+ headerLines: [],
+ rows: [
+ gradientRow({ dateLine: '2015-03-13 → 2026-08-25' }),
+ gradientRow({ title: 'Fixed scene' }),
+ categoricalRow(manyStops, {
+ dateLine: '2015-03-13 → 2026-08-25',
+ }),
+ categoricalRow(manyStops.slice(0, 3)),
+ ],
+ }
+ assertWithinBand(model, 300, 1)
+ assertWithinBand(model, 300, 2)
+ // And no slack either: the band ends one PAD below the deepest thing
+ // drawn, so the extra line on the timed rows is counted exactly once.
+ assertNoSlack(model, 300, 1)
+ assertNoSlack(model, 300, 2)
+ })
+
+ test('mixed gradient, categorical and plain model at scale 1 and 2', () => {
+ const manyStops = Array.from({ length: 20 }, (_, i) => ({
+ color: '#abc',
+ label: `Category number ${i}`,
+ }))
+ const model = {
+ missionName: 'M20',
+ headerLines: ['Time cursor 2024-02-01', 'Exported 2024-03-04'],
+ rows: [
+ gradientRow(),
+ plainRow({ dateLine: 'Collected from 2016-05-01' }),
+ categoricalRow(manyStops),
+ ],
+ }
+ assertWithinBand(model, 300, 1)
+ assertWithinBand(model, 300, 2)
+ assertNoSlack(model, 300, 1)
+ assertNoSlack(model, 300, 2)
+ })
+})
diff --git a/src/essence/Tools/_shared/legend/__tests__/resolveColormapColors.spec.js b/src/essence/Tools/_shared/legend/__tests__/resolveColormapColors.spec.js
new file mode 100644
index 000000000..b05a9f5c0
--- /dev/null
+++ b/src/essence/Tools/_shared/legend/__tests__/resolveColormapColors.spec.js
@@ -0,0 +1,62 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+
+vi.mock('../colormapCache', () => ({ fetchColormapColors: vi.fn() }))
+import { fetchColormapColors } from '../colormapCache'
+import { resolveColormapColors } from '../resolveColormapColors'
+
+// A block body, not an implicit return: `mockReset()` returns the mock
+// itself, and vitest treats a function returned from `beforeEach` as an
+// implicit teardown — it would get invoked after each test, calling
+// whatever rejection a test configured with no one awaiting it.
+beforeEach(() => {
+ vi.mocked(fetchColormapColors).mockReset()
+})
+
+describe('resolveColormapColors', () => {
+ it('resolves a known name locally without touching TiTiler', async () => {
+ vi.mocked(fetchColormapColors).mockRejectedValue(
+ new Error('titiler is down'),
+ )
+ const colors = await resolveColormapColors('viridis', null)
+ // 256 samples, matching TiTiler's own granularity.
+ expect(colors).toHaveLength(256)
+ expect(colors[0]).toMatch(/^rgb\(/)
+ expect(fetchColormapColors).not.toHaveBeenCalled()
+ })
+ it('a _r name is the exact reverse of its forward ramp', async () => {
+ const fwd = await resolveColormapColors('viridis')
+ const rev = await resolveColormapColors('viridis_r')
+ expect(rev).toEqual([...fwd].reverse())
+ })
+ it('falls back to TiTiler for unknown names', async () => {
+ vi.mocked(fetchColormapColors).mockResolvedValue(['#000', '#fff'])
+ const colors = await resolveColormapColors('customramp', 'http://t')
+ expect(fetchColormapColors).toHaveBeenCalledWith(
+ 'customramp',
+ 'http://t',
+ )
+ expect(colors).toEqual(['#000', '#fff'])
+ })
+ it('reverses a TiTiler-resolved _r name locally', async () => {
+ vi.mocked(fetchColormapColors).mockResolvedValue(['#000', '#fff'])
+ expect(await resolveColormapColors('customramp_r', 'http://t')).toEqual(
+ ['#fff', '#000'],
+ )
+ })
+ it('falls back to the viridis ramp when the name is unknown and TiTiler resolves nothing', async () => {
+ // Matches colormapLUT's fallback so the export never disagrees with
+ // what deckRaster painted for the same unrecognized name.
+ vi.mocked(fetchColormapColors).mockResolvedValue(null)
+ const colors = await resolveColormapColors('customramp', null)
+ expect(colors).toHaveLength(256)
+ expect(colors[0]).toMatch(/^rgb\(/)
+ })
+ it('falls back to the viridis ramp — never throws — when fetchColormapColors rejects', async () => {
+ vi.mocked(fetchColormapColors).mockRejectedValue(
+ new Error('network down'),
+ )
+ const colors = await resolveColormapColors('customramp', 'http://t')
+ expect(colors).toHaveLength(256)
+ expect(colors[0]).toMatch(/^rgb\(/)
+ })
+})
diff --git a/src/essence/Tools/_shared/legend/buildLayerLegendData.ts b/src/essence/Tools/_shared/legend/buildLayerLegendData.ts
new file mode 100644
index 000000000..30b37d574
--- /dev/null
+++ b/src/essence/Tools/_shared/legend/buildLayerLegendData.ts
@@ -0,0 +1,190 @@
+import type { CogCapabilities } from '../adapters/mmgisAPI'
+import type { Layer, CategoricalStop, CogData } from './types'
+
+type MMGISLegendEntry = {
+ shape?: string
+ color?: string
+ value?: string | number
+ label?: string
+ hideFromLegend?: boolean
+}
+
+type MMGISLayerConfig = {
+ display_name?: string
+ description?: string
+ _legend?: MMGISLegendEntry[]
+ // Set by LayersTool.populateCogScale alongside `_legend` when it derives
+ // that legend from the live COG colormap/rescale, rather than a mission
+ // author's `variables.legend`/CSV. Lets this module tell the two apart —
+ // both live in the same `_legend` field.
+ _legendAutoGenerated?: boolean
+ currentCogColormap?: string
+ cogColormap?: string
+ currentCogMin?: number
+ cogMin?: number
+ currentCogMax?: number
+ cogMax?: number
+ cogUnits?: string | null
+}
+
+const SCALE_SHAPES = ['continuous', 'discreet']
+
+type NumericValue = { number: number; unit: string }
+
+const NUMERIC_PREFIX = /^[+-]?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/
+
+/**
+ * Reads a legend value as a number plus an optional trailing unit
+ * (`'0.5 ppm'` -> 0.5 + 'ppm'). Null for anything that is not a plain number:
+ * a word (`'example'`), or a binned label (`'0.5-1.0 m'`) whose remainder
+ * reads as another number rather than as a unit.
+ */
+const parseNumericValue = (
+ value: string | number | undefined,
+): NumericValue | null => {
+ const str = String(value ?? '').trim()
+ const match = str.match(NUMERIC_PREFIX)
+ if (!match) return null
+ const unit = str.slice(match[0].length).trim()
+ if (/^[+\-.\d]/.test(unit)) return null
+ return { number: parseFloat(match[0]), unit }
+}
+
+/**
+ * The numbers and shared unit behind a run of scale entries, or null when the
+ * run is not one honest numeric scale: some value is not a number, or the
+ * entries disagree on their unit (which is what a set of binned labels looks
+ * like once each bin's remainder is read as a unit).
+ */
+const readScaleValues = (
+ entries: MMGISLegendEntry[],
+): { numbers: number[]; unit: string | null } | null => {
+ const parsed = entries.map((entry) => parseNumericValue(entry.value))
+ if (parsed.some((value) => value === null)) return null
+ const values = parsed as NumericValue[]
+ const units = new Set(values.map((value) => value.unit))
+ if (units.size > 1) return null
+ const [unit] = [...units]
+ return { numbers: values.map((value) => value.number), unit: unit || null }
+}
+
+/**
+ * A gradient bar can only stand in for a legend that is one uninterrupted
+ * numeric scale. A legend that mixes scale runs with individually shaped
+ * entries — the form LegendTool renders as a mix of bars and swatches — or one
+ * whose scale values are words or bins, draws as labelled swatches instead of
+ * a ramp whose bounds would be read off non-numeric text.
+ */
+const readGradient = (entries: MMGISLegendEntry[]) => {
+ if (!entries.every((entry) => SCALE_SHAPES.includes(entry.shape ?? '')))
+ return null
+ const values = readScaleValues(entries)
+ if (!values) return null
+ return {
+ stops: entries.map((entry) => entry.color || ''),
+ min: Math.min(...values.numbers),
+ max: Math.max(...values.numbers),
+ unit: values.unit ? { label: values.unit } : null,
+ }
+}
+
+const hasSwatch = (entry: MMGISLegendEntry): boolean =>
+ Boolean(entry.color) &&
+ (entry.value !== undefined || entry.label !== undefined)
+
+const buildCategoricalFields = (
+ entries: MMGISLegendEntry[],
+): CategoricalStop[] =>
+ entries.map((entry) => ({
+ color: entry.color || '',
+ label: String(entry.value ?? entry.label ?? ''),
+ }))
+
+/**
+ * Shapes one layer's config into the legend model the UI renders.
+ *
+ * `cogCapabilities` comes from core over the request bus and carries two
+ * separate answers: `hasColormap` builds the COG block, so the legend draws
+ * the ramp and its bounds, while `canChangeColormap` decides whether that
+ * block is editable. A layer can have the first without the second.
+ *
+ * `titilerUrl` likewise comes from core, already resolved; null leaves the
+ * ramp swatches with nowhere to load from.
+ */
+export const buildLayerLegendData = (
+ layerName: string,
+ layerConfig: MMGISLayerConfig,
+ opacities: Record | null | undefined,
+ visible: boolean,
+ cogCapabilities: CogCapabilities | null | undefined,
+ titilerUrl: string | null = null,
+): Layer => {
+ const opacity = opacities?.[layerName] ?? 1
+
+ const cog: CogData | null = cogCapabilities?.hasColormap
+ ? {
+ isCog: true,
+ editable: cogCapabilities.canChangeColormap === true,
+ colormap: layerConfig.currentCogColormap || layerConfig.cogColormap || 'viridis',
+ // A bound nobody configured stays null: printing an
+ // invented 0/255 on an export reads as an authoritative
+ // range the raster was never rescaled to.
+ min: layerConfig.currentCogMin ?? layerConfig.cogMin ?? null,
+ max: layerConfig.currentCogMax ?? layerConfig.cogMax ?? null,
+ defaultMin: layerConfig.cogMin ?? 0,
+ defaultMax: layerConfig.cogMax ?? 255,
+ defaultColormap: layerConfig.cogColormap || 'viridis',
+ units: layerConfig.cogUnits ?? null,
+ titilerUrl,
+ }
+ : null
+
+ const base: Layer = {
+ id: layerName,
+ title: layerConfig.display_name || layerName,
+ description: layerConfig.description || null,
+ opacity,
+ visible,
+ type: 'none',
+ cog,
+ }
+
+ // A machine-derived `_legend` (populateCogScale's live colormap snapshot)
+ // is not an authored legend, so it is ignored in favour of live cog data:
+ // a consumer that prefers the cog block over an authored legend (the
+ // export) falls through to that instead of this stale snapshot. Only when
+ // there is a cog to prefer, though — populateCogScale also writes the
+ // marker for layers core reports no colormap for (velocity fields), where
+ // discarding it would throw away the only legend the layer has.
+ const legend =
+ layerConfig._legendAutoGenerated === true && cog
+ ? undefined
+ : layerConfig._legend
+ const entries = Array.isArray(legend)
+ ? legend.filter((entry) => entry.hideFromLegend !== true)
+ : []
+ if (entries.length === 0) {
+ if (cog) {
+ return {
+ ...base,
+ type: 'gradient',
+ min: cog.min,
+ max: cog.max,
+ stops: null,
+ unit: cog.units ? { label: cog.units } : null,
+ }
+ }
+ return base
+ }
+
+ const gradient = readGradient(entries)
+ if (gradient) return { ...base, type: 'gradient', ...gradient }
+ if (entries.some(hasSwatch)) {
+ return {
+ ...base,
+ type: 'categorical',
+ categoricalStops: buildCategoricalFields(entries),
+ }
+ }
+ return { ...base, type: 'text' }
+}
diff --git a/src/essence/Tools/LayerManager/lib/utils/colormapCache.ts b/src/essence/Tools/_shared/legend/colormapCache.ts
similarity index 100%
rename from src/essence/Tools/LayerManager/lib/utils/colormapCache.ts
rename to src/essence/Tools/_shared/legend/colormapCache.ts
diff --git a/src/essence/Tools/LayerManager/lib/utils/colormaps.ts b/src/essence/Tools/_shared/legend/colormaps.ts
similarity index 88%
rename from src/essence/Tools/LayerManager/lib/utils/colormaps.ts
rename to src/essence/Tools/_shared/legend/colormaps.ts
index 532f90cad..becf15e5b 100644
--- a/src/essence/Tools/LayerManager/lib/utils/colormaps.ts
+++ b/src/essence/Tools/_shared/legend/colormaps.ts
@@ -1,9 +1,21 @@
-// Colormap naming, list parsing, and rescale validation.
+// Colormap list parsing, gradient building, and rescale validation, on top of
+// the core-owned naming primitives.
//
// TiTiler encodes a ramp's direction in its name: `viridis` is the forward
// ramp, `viridis_r` the reversed one, and `/colorMaps` reports both. The UI
-// treats direction as a separate toggle rather than two list entries, so these
-// helpers split a name into (base, reversed) and recombine it on apply.
+// treats direction as a separate toggle rather than two list entries, so the
+// naming helpers split a name into (base, reversed) and this module recombines
+// it on apply. Those helpers live in core (Basics/Colormaps/colormapNaming),
+// which the raster renderer resolves its own names through; re-exporting them
+// here keeps one source of truth without core reaching into a plugin
+// directory for it.
+import {
+ isReversedColormap,
+ getBaseColormapName,
+ findColormapKey,
+} from '../../../Basics/Colormaps/colormapNaming'
+
+export { isReversedColormap, getBaseColormapName, findColormapKey }
export type ColormapListResponse = {
// TiTiler <= 0.22 reports a flat array of names under `colorMaps`.
@@ -13,16 +25,6 @@ export type ColormapListResponse = {
colormaps?: unknown
}
-const REVERSED_SUFFIX = /_r$/i
-
-export const isReversedColormap = (name: string | null | undefined): boolean =>
- typeof name === 'string' && REVERSED_SUFFIX.test(name)
-
-export const getBaseColormapName = (name: string | null | undefined): string => {
- if (!name) return ''
- return name.replace(REVERSED_SUFFIX, '')
-}
-
/** Recombine a base ramp name with a direction into the name TiTiler expects. */
export const applyColormapDirection = (base: string, reversed: boolean): string => {
const normalized = getBaseColormapName(base).toLowerCase()
diff --git a/src/essence/Tools/_shared/legend/composeExportImage.ts b/src/essence/Tools/_shared/legend/composeExportImage.ts
new file mode 100644
index 000000000..94dc39e13
--- /dev/null
+++ b/src/essence/Tools/_shared/legend/composeExportImage.ts
@@ -0,0 +1,64 @@
+import type { MapScreenshotResult } from '../adapters/mmgisAPI'
+import { measureLegendBand, drawLegendBand } from './renderLegendBand'
+import type { ExportLegendModel } from './getExportLegendModel'
+
+export type ComposeDeps = {
+ createBitmap?: (blob: Blob) => Promise
+ createCanvas?: () => HTMLCanvasElement
+ scale?: number
+}
+
+/**
+ * Appends the legend band below a captured map image. The screenshot blob is
+ * the plugin/core boundary — decode it, extend the canvas downward, re-encode
+ * in the same mime type. An empty model returns the screenshot untouched.
+ */
+export async function composeExportImage(
+ screenshot: MapScreenshotResult,
+ model: ExportLegendModel | null,
+ deps: ComposeDeps = {},
+): Promise {
+ if (!model || model.rows.length === 0) return screenshot
+ const {
+ createBitmap = (blob: Blob) => createImageBitmap(blob),
+ createCanvas = () => document.createElement('canvas'),
+ scale = Math.max(
+ 1,
+ (typeof window !== 'undefined' && window.devicePixelRatio) || 1,
+ ),
+ } = deps
+
+ const bitmap = await createBitmap(screenshot.blob)
+ try {
+ const canvas = createCanvas()
+ const ctx = canvas.getContext('2d')
+ if (!ctx) {
+ throw new Error('Legend compositing needs a 2D canvas context')
+ }
+ // Measure on this same context before resizing the canvas: setting
+ // canvas.width/height resets all context state (font, fillStyle,
+ // ...), and drawLegendBand sets its own fonts before it draws
+ // anyway, so nothing here depends on that state surviving the
+ // resize. That makes a second, throwaway canvas just for measuring
+ // unnecessary.
+ const bandHeight = measureLegendBand(ctx, model, screenshot.width, scale)
+ canvas.width = screenshot.width
+ canvas.height = screenshot.height + bandHeight
+ ctx.drawImage(bitmap, 0, 0)
+ drawLegendBand(
+ ctx,
+ model,
+ screenshot.width,
+ screenshot.height,
+ bandHeight,
+ scale,
+ )
+ const blob = await new Promise((resolve) =>
+ canvas.toBlob(resolve, screenshot.mimeType),
+ )
+ if (!blob) throw new Error('Legend compositing produced no image')
+ return { ...screenshot, blob, height: canvas.height }
+ } finally {
+ bitmap.close?.()
+ }
+}
diff --git a/src/essence/Tools/_shared/legend/coverageOverlap.ts b/src/essence/Tools/_shared/legend/coverageOverlap.ts
new file mode 100644
index 000000000..43a0a2f05
--- /dev/null
+++ b/src/essence/Tools/_shared/legend/coverageOverlap.ts
@@ -0,0 +1,113 @@
+/**
+ * What part of a layer's coverage a request could have returned.
+ *
+ * The map asks a server for a span — the slider's window start to its cursor
+ * — and the layer says, through its Data Time Extent, where its data exists
+ * at all. Only where the two meet can the pixels on screen be from, whatever
+ * scene the server picked inside it. Pure arithmetic on ISO instants: an
+ * absent bound is unbounded rather than zero, and a bound that will not parse
+ * is no bound at all, so it can never narrow a range it says nothing about.
+ */
+
+/** A layer's coverage. Either end may be absent: the data reaches past it. */
+export type Coverage = { start: string | null; end: string | null }
+
+/** The span the map requested. Its start is absent in the slider's Point
+ * mode, where nothing says how far back the request reached. */
+export type RequestSpan = { start: string | null; end: string }
+
+/** The covered part of a request. Its start is absent only when neither the
+ * request nor the coverage bounds the past. */
+export type Overlap = { start: string | null; end: string }
+
+/** An instant that parsed, carried with the text it was written as, so a
+ * range prints the dates it was given rather than a rewritten form. */
+type Bound = { text: string; ms: number } | null
+
+const bound = (instant: string | null | undefined): Bound => {
+ if (typeof instant !== 'string') return null
+ const ms = Date.parse(instant)
+ return Number.isNaN(ms) ? null : { text: instant, ms }
+}
+
+// Null is unbounded on both helpers: the other bound is then the only one
+// there is.
+const later = (a: Bound, b: Bound): Bound =>
+ a === null ? b : b === null ? a : a.ms >= b.ms ? a : b
+
+const earlier = (a: Bound, b: Bound): Bound =>
+ a === null ? b : b === null ? a : a.ms <= b.ms ? a : b
+
+/**
+ * The overlap of `request` and `coverage`, or null when they do not meet —
+ * a cursor sitting before the layer's first scene, say, where the server had
+ * nothing inside the span to draw. Null too without a readable cursor, since
+ * there is then no request to intersect.
+ */
+export const coverageOverlap = (
+ request: RequestSpan,
+ coverage: Coverage,
+): Overlap | null => {
+ const cursor = bound(request.end)
+ if (cursor === null) return null
+ const start = later(bound(request.start), bound(coverage.start))
+ const end = earlier(cursor, bound(coverage.end))
+ if (end === null) return null
+ if (start !== null && start.ms > end.ms) return null
+ return { start: start?.text ?? null, end: end.text }
+}
+
+/**
+ * Whether any of `coverage` falls inside `period` — the test for whether the
+ * period holding the cursor is a range the data can be from. A period runs up
+ * to but not including its end, so one ending on the coverage's first instant
+ * holds none of it.
+ */
+export const hasDataIn = (
+ coverage: Coverage,
+ period: { start: string; end: string },
+): boolean => {
+ const start = bound(period.start)
+ const end = bound(period.end)
+ if (start === null || end === null) return false
+ const coverageStart = bound(coverage.start)
+ const coverageEnd = bound(coverage.end)
+ if (coverageEnd !== null && start.ms > coverageEnd.ms) return false
+ if (coverageStart !== null && end.ms <= coverageStart.ms) return false
+ return true
+}
+
+/**
+ * `period` narrowed to the part of it the coverage fills. A period is a shape
+ * the cadence imposes, not a promise of data: the layer's coverage can begin
+ * partway into it or stop partway through, and printing the raw period would
+ * then name days the layer has nothing for. Either end that the coverage does
+ * not reach is replaced by the coverage's own bound. The request span is
+ * deliberately not clipped to: a monthly composite is the whole month even
+ * when the window opened mid-month.
+ *
+ * `endIsPeriodEnd` says which the end came from, because the two print
+ * differently — a period's end is the next period's start and prints
+ * inclusively, while a coverage end is an instant the data reaches and prints
+ * as it is.
+ */
+export const clipPeriodToCoverage = (
+ period: { start: string; end: string },
+ coverage: Coverage,
+): { start: string; end: string; endIsPeriodEnd: boolean } => {
+ const periodStart = bound(period.start)
+ const coverageStart = bound(coverage.start)
+ const start =
+ periodStart !== null &&
+ coverageStart !== null &&
+ coverageStart.ms > periodStart.ms
+ ? coverageStart.text
+ : period.start
+ const periodEnd = bound(period.end)
+ const coverageEnd = bound(coverage.end)
+ return periodEnd !== null &&
+ coverageEnd !== null &&
+ coverageEnd.ms < periodEnd.ms
+ ? { start, end: coverageEnd.text, endIsPeriodEnd: false }
+ : { start, end: period.end, endIsPeriodEnd: true }
+}
diff --git a/src/essence/Tools/_shared/legend/datePrecision.ts b/src/essence/Tools/_shared/legend/datePrecision.ts
new file mode 100644
index 000000000..b08d8cf38
--- /dev/null
+++ b/src/essence/Tools/_shared/legend/datePrecision.ts
@@ -0,0 +1,84 @@
+/**
+ * How precisely a row's dates print.
+ *
+ * A layer's `time.interval` decides it, not the mission's time format: a
+ * daily collection has no business printing seconds, and an hourly one is
+ * unreadable rounded to a day. The smallest unit in the interval is the
+ * finest thing a reader can tell apart, so it sets the precision. Pure
+ * string arithmetic on the ISO-duration vocabulary core owns.
+ */
+
+import { type Duration } from '../../../Basics/TimeControl_/layerTimePolicy'
+
+type Precision = 'year' | 'month' | 'day' | 'hour' | 'second'
+
+const precisionOf = (duration: Duration | null | undefined): Precision => {
+ if (!duration) return 'day'
+ if (duration.minutes > 0 || duration.seconds > 0) return 'second'
+ if (duration.hours > 0) return 'hour'
+ if (duration.days > 0 || duration.weeks > 0) return 'day'
+ if (duration.months > 0) return 'month'
+ if (duration.years > 0) return 'year'
+ return 'day'
+}
+
+const pad = (value: number): string => String(value).padStart(2, '0')
+
+/**
+ * An epoch moment written at the precision `duration` earns, or null when it
+ * is not a moment a date can hold.
+ */
+const formatEpochMs = (
+ duration: Duration | null | undefined,
+ ms: number,
+): string | null => {
+ const date = new Date(ms)
+ if (Number.isNaN(date.getTime())) return null
+ // Years before 1000 still print as four digits, so a date is the same
+ // width wherever it lands.
+ const year = `${date.getUTCFullYear()}`.padStart(4, '0')
+ const month = `${year}-${pad(date.getUTCMonth() + 1)}`
+ const day = `${month}-${pad(date.getUTCDate())}`
+ switch (precisionOf(duration)) {
+ case 'year':
+ return year
+ case 'month':
+ return month
+ case 'hour':
+ return `${day} ${pad(date.getUTCHours())}:00Z`
+ case 'second':
+ return `${day}T${pad(date.getUTCHours())}:${pad(
+ date.getUTCMinutes(),
+ )}:${pad(date.getUTCSeconds())}Z`
+ default:
+ return day
+ }
+}
+
+/**
+ * `instant` written at the precision `duration` earns, or null when it is
+ * not a time at all — the caller then drops the line rather than printing
+ * half a range.
+ */
+export const formatAtPrecision = (
+ duration: Duration | null | undefined,
+ instant: string | null | undefined,
+): string | null =>
+ formatEpochMs(
+ duration,
+ typeof instant === 'string' ? Date.parse(instant) : NaN,
+ )
+
+/**
+ * A period's end, printed inclusively. The end a period carries is the
+ * instant the next one starts on, so printing it raw would make a P7D period
+ * read as eight days; what prints is the last unit the period covers.
+ */
+export const formatPeriodEnd = (
+ duration: Duration | null | undefined,
+ exclusiveEnd: string | null | undefined,
+): string | null =>
+ formatEpochMs(
+ duration,
+ typeof exclusiveEnd === 'string' ? Date.parse(exclusiveEnd) - 1 : NaN,
+ )
diff --git a/src/essence/Tools/_shared/legend/filterLayersForExportView.ts b/src/essence/Tools/_shared/legend/filterLayersForExportView.ts
new file mode 100644
index 000000000..92f4723d1
--- /dev/null
+++ b/src/essence/Tools/_shared/legend/filterLayersForExportView.ts
@@ -0,0 +1,41 @@
+import type { Layer } from './types'
+
+// Which toggled-on layers earn a legend row in an export. The rule is simply
+// that a toggled-on layer gets a row — no viewport or zoom test narrows it
+// further, because neither can be answered honestly. A layer's configured
+// boundingBox is author-written metadata that routinely disagrees with where
+// the layer actually paints (live-observed: a bbox describing one granule
+// over Nicaragua on a collection mosaic painting over Uruguay), and it is all
+// that is on offer even for a vector layer, whose deck.gl path falls back to
+// that same configured box when its GeoJSON comes from a URL (Layers_.js).
+// Configured zoom ranges drift the same way: core lets a per-feature
+// style.minZoom/maxZoom replace the layer-level range, so gating on that
+// range can drop a layer that is plainly painted on screen.
+//
+// The one exclusion is opacity 0. That signal is local and exact, mirrors no
+// core logic, and means the layer provably paints nothing — so it gets no row.
+
+export const filterLayersForExportView = (layers: Layer[]): Layer[] => {
+ const dropped: string[] = []
+
+ const kept = layers.filter((layer) => {
+ if (layer.opacity === 0) {
+ dropped.push(`"${layer.title}" (opacity 0)`)
+ return false
+ }
+ return true
+ })
+
+ // The fail-open contract (getExportLegendModel just renders no band when
+ // rows end up empty) otherwise leaves no trace of why — log the one case
+ // that actually did the emptying: this filter itself dropped every
+ // candidate layer.
+ if (layers.length > 0 && kept.length === 0) {
+ console.info(
+ '[export legend] every layer was excluded from the legend:',
+ dropped,
+ )
+ }
+
+ return kept
+}
diff --git a/src/essence/Tools/_shared/legend/format.ts b/src/essence/Tools/_shared/legend/format.ts
new file mode 100644
index 000000000..731e8caa5
--- /dev/null
+++ b/src/essence/Tools/_shared/legend/format.ts
@@ -0,0 +1,35 @@
+// 3-significant-figure display with exponential fallback — the exact rules
+// the in-app gradient bar uses, shared so exports can never disagree.
+// Missing bounds (null/undefined, or an empty/blank string — which `Number`
+// would otherwise silently read as 0) render blank rather than '0'.
+export const formatLegendValue = (
+ val: number | string | null | undefined,
+): string => {
+ if (val == null) return ''
+ if (typeof val === 'string' && val.trim() === '') return ''
+ const num = Number(val)
+ if (isNaN(num)) return String(val)
+ if (num === 0) return '0'
+ if (Math.abs(num) < 9999 && Math.abs(num) > 0.0009) {
+ return String(parseFloat(num.toFixed(3)))
+ }
+ return num.toExponential(2)
+}
+
+const isNumericBound = (val: number | string | null | undefined): boolean => {
+ if (typeof val === 'number') return !isNaN(val)
+ if (typeof val === 'string' && val.trim() !== '') return !isNaN(Number(val))
+ return false
+}
+
+// A unit is appended only for a bound that actually rendered as a number —
+// a non-numeric bound (e.g. '<0.1 ppm') already carries its unit as part of
+// the string, so appending again would double it ('ppm ppm').
+export const formatLegendBound = (
+ val: number | string | null | undefined,
+ unit?: string | null,
+): string => {
+ const formatted = formatLegendValue(val)
+ if (!formatted) return ''
+ return unit && isNumericBound(val) ? `${formatted} ${unit}` : formatted
+}
diff --git a/src/essence/Tools/_shared/legend/getExportLegendModel.ts b/src/essence/Tools/_shared/legend/getExportLegendModel.ts
new file mode 100644
index 000000000..45c62ad7f
--- /dev/null
+++ b/src/essence/Tools/_shared/legend/getExportLegendModel.ts
@@ -0,0 +1,366 @@
+import { getVisibleLayersWithLegends } from './getVisibleLayersWithLegends'
+import { resolveColormapColors } from './resolveColormapColors'
+import { filterLayersForExportView } from './filterLayersForExportView'
+import { layerPeriodFor } from './layerPeriod'
+import {
+ coverageOverlap,
+ hasDataIn,
+ clipPeriodToCoverage,
+ type Coverage,
+ type RequestSpan,
+} from './coverageOverlap'
+import { formatAtPrecision, formatPeriodEnd } from './datePrecision'
+import {
+ parseISODuration,
+ type Duration,
+} from '../../../Basics/TimeControl_/layerTimePolicy'
+import {
+ mmgisGetViewState,
+ mmgisGetLayerConfigs,
+ mmgisGetTimeStart,
+ mmgisGetCurrentTime,
+ mmgisGetCurrentTimeFormatted,
+ mmgisGetTemporalExtents,
+ mmgisFormatTime,
+ type LayerConfig,
+ type TemporalExtent,
+} from '../adapters/mmgisAPI'
+import type { Layer, CategoricalStop } from './types'
+
+export type ExportLegendRow =
+ | {
+ kind: 'gradient'
+ title: string
+ dateLine: string | null
+ colors: string[] | null
+ min: number | string | null
+ max: number | string | null
+ unit: string | null
+ }
+ | {
+ kind: 'categorical'
+ title: string
+ dateLine: string | null
+ stops: CategoricalStop[]
+ }
+ /** A layer with nothing to draw: it is still on the map, so it is still
+ * on the band, as a name and its date line. */
+ | {
+ kind: 'plain'
+ title: string
+ dateLine: string | null
+ }
+
+export type ExportLegendModel = {
+ missionName: string | null
+ /** Lines printed under the mission name, already worded — the renderer
+ * prints them without knowing which is the cursor and which the export. */
+ headerLines: string[]
+ rows: ExportLegendRow[]
+}
+
+/** The cursor a layer's tiles were requested at, and the window start that
+ * request ran from. */
+type TimeCursor = { cursor: string | null; windowStart: string | null }
+
+const globalTimeCursor = async (): Promise => {
+ try {
+ const [cursor, windowStart] = await Promise.all([
+ mmgisGetCurrentTime(),
+ mmgisGetTimeStart(),
+ ])
+ return { cursor, windowStart }
+ } catch (err) {
+ console.warn('[export legend] core reported no time cursor', err)
+ return { cursor: null, windowStart: null }
+ }
+}
+
+const temporalExtents = async (): Promise | null> => {
+ try {
+ return await mmgisGetTemporalExtents()
+ } catch (err) {
+ console.warn('[export legend] core reported no layer extents', err)
+ return null
+ }
+}
+
+// Point mode on the slider sets the window start to the epoch, rebuilt from
+// local date components — so it arrives shifted by the browser's UTC offset,
+// at most ±14 hours either side of 1970-01-01. Nothing on the bus says which
+// mode is active, so only a start within a day of the epoch is read as "no
+// start was asked for"; a genuine window start decades ago must survive and
+// be printed.
+const isOpenEndedStart = (windowStart: string | null): boolean => {
+ if (!windowStart) return true
+ const ms = Date.parse(windowStart)
+ return Number.isNaN(ms) ? false : Math.abs(ms) < 86_400_000
+}
+
+/**
+ * A dated span, worded. Both ends of a span print at the same precision, so
+ * a span narrower than that precision reads the same at both ends; it says
+ * the label once, because `X → X` would only look like a mistake.
+ */
+const spanLine = (verb: string, start: string, end: string): string =>
+ start === end ? `${verb} ${start}` : `${verb} ${start} → ${end}`
+
+/**
+ * The span the map asked the server for, which runs from the window start to
+ * the cursor and never to the window's right edge. All the app can say about
+ * a layer that never told it where its data exists.
+ */
+const requestedDateLine = (
+ { start, end }: RequestSpan,
+ precision: Duration | null,
+): string | null => {
+ const cursorText = formatAtPrecision(precision, end)
+ if (!cursorText) return null
+ const startText = start ? formatAtPrecision(precision, start) : null
+ return startText
+ ? `Requested ${startText} → ${cursorText}`
+ : `Requested up to ${cursorText}`
+}
+
+/**
+ * The part of a layer's coverage the request could have returned — the only
+ * range the pixels on screen can be from — narrowed to a single period, itself
+ * clipped to the coverage, when the layer serves whole periods and the
+ * cursor's period holds data. Null
+ * when the request and the coverage never meet: the server had nothing inside
+ * the span to draw, so the caller falls back to naming the request alone.
+ */
+const collectedDateLine = (
+ interval: string | null,
+ request: RequestSpan,
+ coverage: Coverage,
+ precision: Duration | null,
+): string | null => {
+ const overlap = coverageOverlap(request, coverage)
+ if (!overlap) return null
+ const period = layerPeriodFor(interval, request.end, coverage.start)
+ if (period && hasDataIn(coverage, period)) {
+ const clipped = clipPeriodToCoverage(period, coverage)
+ const start = formatAtPrecision(precision, clipped.start)
+ // A period ends where the next one starts, so what prints is the last
+ // unit it covers; a coverage end inside the period is an instant the
+ // data reaches, and prints as it is.
+ const end = clipped.endIsPeriodEnd
+ ? formatPeriodEnd(precision, clipped.end)
+ : formatAtPrecision(precision, clipped.end)
+ if (start && end) return spanLine('Collected', start, end)
+ }
+ // An overlap's ends are instants the layer's data reaches, so they print
+ // as they are.
+ const end = formatAtPrecision(precision, overlap.end)
+ if (!end) return null
+ const start = overlap.start
+ ? formatAtPrecision(precision, overlap.start)
+ : null
+ return start ? spanLine('Collected', start, end) : `Collected until ${end}`
+}
+
+/**
+ * The date line for a layer that follows the slider. Its coverage says where
+ * the layer's data exists at all and the request says what the map asked for;
+ * with both, the row can name where the pixels came from, and with only the
+ * request it can name only the request.
+ */
+const slidingDateLine = (
+ interval: string | null,
+ { cursor, windowStart }: TimeCursor,
+ extent: TemporalExtent | undefined,
+ precision: Duration | null,
+): string | null => {
+ // A window with no cursor in it has no truthful wording: nothing says
+ // where in the window the map was asked to stop.
+ if (!cursor) return null
+ const request: RequestSpan = {
+ start: isOpenEndedStart(windowStart) ? null : windowStart,
+ end: cursor,
+ }
+ const coverage: Coverage = {
+ start: extent?.start ?? null,
+ end: extent?.end ?? null,
+ }
+ if (coverage.start !== null || coverage.end !== null) {
+ const collected = collectedDateLine(
+ interval,
+ request,
+ coverage,
+ precision,
+ )
+ if (collected) return collected
+ }
+ return requestedDateLine(request, precision)
+}
+
+/**
+ * The date line for a layer that ignores the slider: when its data was
+ * collected, as far as the mission authored it. A half-open extent stays
+ * half-open rather than being closed with a date nobody supplied.
+ */
+const extentDateLine = (
+ extent: TemporalExtent | undefined,
+ precision: Duration | null,
+): string | null => {
+ if (!extent) return null
+ const start = extent.start
+ ? formatAtPrecision(precision, extent.start)
+ : null
+ const end = extent.end ? formatAtPrecision(precision, extent.end) : null
+ if (start && end) return spanLine('Collected', start, end)
+ if (start) return `Collected from ${start}`
+ if (end) return `Collected until ${end}`
+ return null
+}
+
+/**
+ * Every date line opens with `Collected` or `Requested`, so a bare `A → B`
+ * can never be read as a claim about when the pixels were collected. How precisely its
+ * dates print is the layer's own `time.interval`'s business, whichever line
+ * it ends up on. Null when no date can be had, which is always safer than a
+ * borrowed one.
+ */
+const dateLineFor = (
+ cfg: LayerConfig | undefined,
+ extent: TemporalExtent | undefined,
+ globalCursor: TimeCursor,
+): string | null => {
+ const time = cfg?.time
+ try {
+ const interval =
+ typeof time?.interval === 'string' ? time.interval : null
+ const precision = interval ? parseISODuration(interval.trim()) : null
+ if (time?.enabled !== true) {
+ return extentDateLine(extent, precision)
+ }
+ // A 'local' layer keeps its own window and is not restamped when the
+ // slider moves; everything else follows the global cursor.
+ const cursor: TimeCursor =
+ time.type === 'local'
+ ? { cursor: time.end ?? null, windowStart: time.start ?? null }
+ : globalCursor
+ return slidingDateLine(interval, cursor, extent, precision)
+ } catch (err) {
+ console.warn('[export legend] could not build a layer date line', err)
+ return null
+ }
+}
+
+/**
+ * The band's own lines, under the mission name: where the slider sat, and
+ * when the picture was made. Both are instants rather than periods, so they
+ * go through core's own formatter and read the way the mission's Time
+ * Control writes a date. The export time is the one date always available,
+ * so an unformattable one prints raw rather than going missing.
+ */
+const buildHeaderLines = async (): Promise => {
+ const lines: string[] = []
+ try {
+ const cursor = await mmgisGetCurrentTimeFormatted()
+ if (cursor) lines.push(`Time cursor ${cursor}`)
+ } catch (err) {
+ console.warn(
+ '[export legend] core could not format the time cursor',
+ err,
+ )
+ }
+ const now = new Date().toISOString()
+ let exported: string | null = null
+ try {
+ exported = await mmgisFormatTime(now)
+ } catch (err) {
+ console.warn('[export legend] could not format the export time', err)
+ }
+ lines.push(`Exported ${exported ?? now}`)
+ return lines
+}
+
+// A manually authored legend (variables.legend / legend CSV) is the layer's
+// legend, full stop — even when the layer also carries a live cog colormap.
+// The auto-derived colormap/rescale bar only stands in when nothing was
+// authored. A layer with neither still gets a row, carrying its name and
+// date line alone.
+const toRow = async (
+ layer: Layer,
+ dateLine: string | null,
+): Promise => {
+ if (layer.type === 'categorical' && layer.categoricalStops?.length) {
+ return {
+ kind: 'categorical',
+ title: layer.title,
+ dateLine,
+ stops: layer.categoricalStops,
+ }
+ }
+ if (layer.type === 'gradient' && layer.stops?.length) {
+ return {
+ kind: 'gradient',
+ title: layer.title,
+ dateLine,
+ colors: layer.stops,
+ min: layer.min ?? null,
+ max: layer.max ?? null,
+ unit: layer.unit?.label ?? layer.cog?.units ?? null,
+ }
+ }
+ if (layer.type === 'gradient' && layer.cog) {
+ return {
+ kind: 'gradient',
+ title: layer.title,
+ dateLine,
+ colors: await resolveColormapColors(
+ layer.cog.colormap,
+ layer.cog.titilerUrl,
+ ),
+ min: layer.cog.min,
+ max: layer.cog.max,
+ unit: layer.cog.units ?? null,
+ }
+ }
+ return { kind: 'plain', title: layer.title, dateLine }
+}
+
+export const getExportLegendModel = async (): Promise => {
+ // layerConfigs is fetched once here and threaded into
+ // getVisibleLayersWithLegends below, rather than each independently
+ // requesting layers:getAllConfigs from core. The extents come in one
+ // no-arg sweep for the same reason.
+ const [layerConfigs, viewState, globalCursor, extents] = await Promise.all([
+ mmgisGetLayerConfigs(),
+ mmgisGetViewState(),
+ globalTimeCursor(),
+ temporalExtents(),
+ ])
+ const layers = await getVisibleLayersWithLegends({
+ showOnlyVisible: true,
+ layerConfigs,
+ })
+ // Drops the layers that paint nothing (opacity 0); panel/LayerManager
+ // listings stay unfiltered, so this is export-only.
+ const legendLayers = filterLayersForExportView(layers)
+ const [headerLines, rows] = await Promise.all([
+ buildHeaderLines(),
+ Promise.all(
+ legendLayers.map((layer) =>
+ toRow(
+ layer,
+ dateLineFor(
+ layerConfigs?.[layer.id],
+ extents?.[layer.id],
+ globalCursor,
+ ),
+ ),
+ ),
+ ),
+ ])
+ return {
+ missionName: viewState?.missionName ?? null,
+ headerLines,
+ rows,
+ }
+}
diff --git a/src/essence/Tools/LayerManager/adapters/getVisibleLayersWithLegends.ts b/src/essence/Tools/_shared/legend/getVisibleLayersWithLegends.ts
similarity index 52%
rename from src/essence/Tools/LayerManager/adapters/getVisibleLayersWithLegends.ts
rename to src/essence/Tools/_shared/legend/getVisibleLayersWithLegends.ts
index 0e8c4a030..f2d73a461 100644
--- a/src/essence/Tools/LayerManager/adapters/getVisibleLayersWithLegends.ts
+++ b/src/essence/Tools/_shared/legend/getVisibleLayersWithLegends.ts
@@ -4,16 +4,28 @@ import {
mmgisGetListedLayers,
mmgisGetTiTilerUrls,
type CogCapabilities,
-} from '../../_shared/adapters/mmgisAPI'
+} from '../adapters/mmgisAPI'
import { buildLayerLegendData } from './buildLayerLegendData'
-import type { Layer } from '../lib/types'
+import type { Layer } from './types'
-export type FetchOptions = { showOnlyVisible?: boolean }
+export type FetchOptions = {
+ showOnlyVisible?: boolean
+ // A caller that already fetched layers:getAllConfigs for its own
+ // purposes (e.g. the export model's view-aware filtering) can pass it
+ // through here instead of this module hitting the bus for it again.
+ layerConfigs?: Record> | null
+}
export const getVisibleLayersWithLegends = async ({
showOnlyVisible = false,
+ layerConfigs: providedLayerConfigs,
}: FetchOptions = {}): Promise => {
- const layerConfigs = await mmgisRequest>>('layers:getAllConfigs')
+ const layerConfigs =
+ providedLayerConfigs !== undefined
+ ? providedLayerConfigs
+ : await mmgisRequest>>(
+ 'layers:getAllConfigs',
+ )
if (!layerConfigs) return []
const [visibleLayers, opacities, listed, cogCapabilities, titilerUrls] = await Promise.all([
@@ -24,13 +36,26 @@ export const getVisibleLayersWithLegends = async ({
mmgisGetTiTilerUrls(),
])
+ // No visibility map means core answered nothing — not that every layer is
+ // off. Reading it as "all hidden" would drop every row and leave the
+ // export with no band at all, so visibility counts as unknown and nothing
+ // is filtered on it.
+ const visibilityKnown = visibleLayers != null
+ if (showOnlyVisible && !visibilityKnown) {
+ console.warn(
+ '[legend] core reported no layer visibility; keeping every layer',
+ )
+ }
+
const result: Layer[] = []
for (const layerName of Object.keys(layerConfigs)) {
const cfg = layerConfigs[layerName]
if (!cfg) continue
if (cfg.type === 'header') continue
if (listed?.[layerName] === false) continue
- const isVisible = visibleLayers?.[layerName] === true
+ const isVisible = visibilityKnown
+ ? visibleLayers?.[layerName] === true
+ : true
if (showOnlyVisible && !isVisible) continue
result.push(
buildLayerLegendData(
diff --git a/src/essence/Tools/_shared/legend/layerPeriod.ts b/src/essence/Tools/_shared/legend/layerPeriod.ts
new file mode 100644
index 000000000..264ab0d41
--- /dev/null
+++ b/src/essence/Tools/_shared/legend/layerPeriod.ts
@@ -0,0 +1,153 @@
+/**
+ * Where a periodic layer's cursor sits, as a period rather than an instant.
+ *
+ * A layer carrying `time.interval` serves one period at a time — a month, a
+ * week, six hours — so the honest thing to print is the period holding the
+ * cursor, not the cursor itself. Pure arithmetic on ISO strings: no bus, no
+ * formatting, so the rules are testable on their own. The ISO-duration
+ * vocabulary itself is core's, read through layerTimePolicy.
+ */
+
+import {
+ parseISODuration,
+ addDuration,
+ type Duration,
+} from '../../../Basics/TimeControl_/layerTimePolicy'
+
+/**
+ * A period, as ISO instants. `end` is the next period's start — the period
+ * runs up to but not including it — so consecutive periods read as a
+ * continuous line rather than leaving a gap between them.
+ */
+export type LayerPeriod = { start: string; end: string }
+
+// A period longer than this is not a mission cadence, it is a bug — stop
+// stepping rather than spin. ~830 years at a one-month step.
+const MAX_STEPS = 10000
+
+const DAY_MS = 86_400_000
+
+const toMs = (time: string | null | undefined): number | null => {
+ if (typeof time !== 'string' || time.trim() === '') return null
+ const ms = Date.parse(time)
+ return Number.isNaN(ms) ? null : ms
+}
+
+const span = (start: number, end: number): LayerPeriod => ({
+ start: new Date(start).toISOString(),
+ end: new Date(end).toISOString(),
+})
+
+// Built off the epoch so a year is written as given: new Date(Date.UTC(y, …))
+// folds years 0 to 99 into the twentieth century. An out-of-range month rolls
+// into the next year, so December's next boundary needs no special case.
+const monthStart = (year: number, month: number): number => {
+ const date = new Date(0)
+ date.setUTCFullYear(year, month, 1)
+ return date.getTime()
+}
+
+/**
+ * The period for a duration that is exactly one calendar unit, which needs no
+ * anchor: the calendar itself supplies the boundaries.
+ */
+const calendarPeriod = (
+ duration: Duration,
+ cursorMs: number,
+): LayerPeriod | null => {
+ const { years, months, weeks, days, hours, minutes, seconds } = duration
+ const subDay = weeks + hours + minutes + seconds
+ const cursor = new Date(cursorMs)
+ const year = cursor.getUTCFullYear()
+ const month = cursor.getUTCMonth()
+ if (years === 1 && months + days + subDay === 0) {
+ return span(monthStart(year, 0), monthStart(year + 1, 0))
+ }
+ if (months === 1 && years + days + subDay === 0) {
+ return span(monthStart(year, month), monthStart(year, month + 1))
+ }
+ if (days === 1 && years + months + subDay === 0) {
+ const start = Math.floor(cursorMs / DAY_MS) * DAY_MS
+ return span(start, start + DAY_MS)
+ }
+ return null
+}
+
+const scaled = (duration: Duration, times: number): Duration => ({
+ years: duration.years * times,
+ months: duration.months * times,
+ weeks: duration.weeks * times,
+ days: duration.days * times,
+ hours: duration.hours * times,
+ minutes: duration.minutes * times,
+ seconds: duration.seconds * times,
+})
+
+// Every step is measured from the anchor rather than from the previous step,
+// so month arithmetic cannot accumulate the end-of-month clamp (a Jan 31
+// anchor stepped by P1M twice reaches Mar 31, not Mar 28).
+const addTo = (anchor: Date, duration: Duration, times: number): Date =>
+ addDuration(anchor, scaled(duration, times), 1)
+
+const fixedLengthMs = (duration: Duration): number | null => {
+ if (duration.years > 0 || duration.months > 0) return null
+ return (
+ (((duration.days + duration.weeks * 7) * 24 + duration.hours) * 60 +
+ duration.minutes) *
+ 60000 +
+ duration.seconds * 1000
+ )
+}
+
+/**
+ * The period of length `interval` that holds `cursor`.
+ *
+ * A single calendar unit answers from the calendar alone. Anything else is
+ * stepped forward from `anchor` — the layer's resolved data start — because
+ * nothing else says where the layer's periods begin. Null whenever the
+ * interval, the cursor, or (for an anchored duration) the anchor is missing
+ * or unreadable, the interval is shorter than an hour, or the cursor sits
+ * before the anchor: the caller then falls back to a range it can stand
+ * behind without a cadence.
+ */
+export const layerPeriodFor = (
+ interval: string | null | undefined,
+ cursor: string | null | undefined,
+ anchor: string | null | undefined,
+): LayerPeriod | null => {
+ const duration =
+ typeof interval === 'string' ? parseISODuration(interval.trim()) : null
+ if (!duration) return null
+ // Anything under an hour is not a cadence of periods but a run of
+ // individually timestamped scenes, and naming a period around one would
+ // claim a coverage the layer never had.
+ const length = fixedLengthMs(duration)
+ if (length !== null && length < 3_600_000) return null
+ const cursorMs = toMs(cursor)
+ if (cursorMs === null) return null
+
+ const calendar = calendarPeriod(duration, cursorMs)
+ if (calendar) return calendar
+
+ const anchorMs = toMs(anchor)
+ if (anchorMs === null || cursorMs < anchorMs) return null
+
+ if (length !== null) {
+ const steps = Math.floor((cursorMs - anchorMs) / length)
+ const start = anchorMs + steps * length
+ return span(start, start + length)
+ }
+
+ const anchorDate = new Date(anchorMs)
+ let steps = 0
+ let end = addTo(anchorDate, duration, 1)
+ while (end.getTime() <= cursorMs) {
+ steps += 1
+ if (steps > MAX_STEPS) return null
+ end = addTo(anchorDate, duration, steps + 1)
+ }
+ return {
+ start: addTo(anchorDate, duration, steps).toISOString(),
+ end: end.toISOString(),
+ }
+}
diff --git a/src/essence/Tools/_shared/legend/renderLegendBand.ts b/src/essence/Tools/_shared/legend/renderLegendBand.ts
new file mode 100644
index 000000000..da01a3c24
--- /dev/null
+++ b/src/essence/Tools/_shared/legend/renderLegendBand.ts
@@ -0,0 +1,280 @@
+import { formatLegendBound } from './format'
+import type { ExportLegendModel, ExportLegendRow } from './getExportLegendModel'
+
+// All metrics are logical px, multiplied by `scale` at draw time so the band
+// stays proportionate to hi-DPI captures (capture size follows
+// devicePixelRatio).
+const PAD = 16
+const HEADER_TEXT = 15
+const TITLE_TEXT = 13
+const LABEL_TEXT = 11
+const BAR_HEIGHT = 12
+const BAR_WIDTH = 260
+const SWATCH = 12
+const LINE_GAP = 6
+const ROW_GAP = 14
+// Clear space kept between a gradient bar's two bound labels.
+const BOUND_GAP = 10
+const NEUTRAL_RAMP = ['#bdbdbd', '#757575']
+
+const FONT = (px: number, scale: number, weight = '') =>
+ `${weight ? `${weight} ` : ''}${Math.round(px * scale)}px sans-serif`
+
+/**
+ * Truncates `text` to fit `maxWidth`, appending an ellipsis, so a long
+ * header/title/category label can't overflow the band. A no-op when the text
+ * already fits.
+ */
+const clipText = (ctx: Ctx2D, text: string, maxWidth: number): string => {
+ if (maxWidth <= 0) return ''
+ if (ctx.measureText(text).width <= maxWidth) return text
+ const ellipsis = '…'
+ let clipped = text
+ while (
+ clipped.length > 0 &&
+ ctx.measureText(clipped + ellipsis).width > maxWidth
+ ) {
+ clipped = clipped.slice(0, -1)
+ }
+ return clipped.length > 0 ? clipped + ellipsis : ellipsis
+}
+
+type Ctx2D = Pick<
+ CanvasRenderingContext2D,
+ | 'fillRect'
+ | 'fillText'
+ | 'measureText'
+ | 'createLinearGradient'
+ | 'save'
+ | 'restore'
+> & { fillStyle: unknown; font: string; textBaseline: CanvasTextBaseline }
+
+// The header's lines in draw order: the mission name, then whatever the
+// model worded — the renderer prints them without knowing what any of them
+// says.
+type HeaderLine = { text: string; size: number; weight: string; color: string }
+
+const headerLinesOf = (model: ExportLegendModel): HeaderLine[] => {
+ const lines: HeaderLine[] = []
+ if (model.missionName) {
+ lines.push({
+ text: model.missionName,
+ size: HEADER_TEXT,
+ weight: 'bold',
+ color: '#111111',
+ })
+ }
+ for (const text of model.headerLines) {
+ lines.push({ text, size: LABEL_TEXT, weight: '', color: '#444444' })
+ }
+ return lines
+}
+
+const headerHeight = (lines: HeaderLine[], scale: number): number =>
+ lines.length === 0
+ ? 0
+ : lines.reduce(
+ (h, line, i) =>
+ h + (i > 0 ? LINE_GAP * scale : 0) + line.size * scale,
+ 0,
+ ) + ROW_GAP * scale
+
+// The line a row's date occupies under its title, for the rows that carry
+// one — the draw pass advances by exactly this much before the row's body,
+// so measure and draw agree on where the row ends.
+const dateLineHeight = (row: ExportLegendRow, scale: number): number =>
+ row.dateLine ? (LINE_GAP + LABEL_TEXT) * scale : 0
+
+const rowHeight = (row: ExportLegendRow, scale: number): number => {
+ const head = TITLE_TEXT * scale + dateLineHeight(row, scale)
+ if (row.kind === 'gradient') {
+ return head + (LINE_GAP + BAR_HEIGHT + LINE_GAP + LABEL_TEXT) * scale
+ }
+ // A plain row is its title and its date line: there is no graphic under
+ // them to leave room for.
+ if (row.kind === 'plain') return head
+ // Categorical: title line + one swatch line (swatches wrap at draw time;
+ // wrapping adds lines, so measure with the same wrap the draw pass uses).
+ return head + (LINE_GAP + SWATCH) * scale
+}
+
+// The widest a single category label can render, clipped so that even alone
+// on a fresh line it can't overflow the row — the same cap `wrapCategorical`
+// (measure) and the draw loop apply, so wrapping agrees between the two.
+const categoryLabelMaxWidth = (width: number, scale: number): number =>
+ width - 2 * PAD * scale - (SWATCH + 6) * scale - 16 * scale
+
+const wrapCategorical = (
+ ctx: Ctx2D,
+ row: Extract,
+ width: number,
+ scale: number,
+): { lines: number } => {
+ ctx.font = FONT(LABEL_TEXT, scale)
+ const maxX = width - PAD * scale
+ const labelMaxWidth = categoryLabelMaxWidth(width, scale)
+ let x = PAD * scale
+ let lines = 1
+ for (const stop of row.stops) {
+ const label = clipText(ctx, stop.label, labelMaxWidth)
+ const itemW =
+ (SWATCH + 6) * scale + ctx.measureText(label).width + 16 * scale
+ if (x + itemW > maxX && x > PAD * scale) {
+ lines += 1
+ x = PAD * scale
+ }
+ x += itemW
+ }
+ return { lines }
+}
+
+export const measureLegendBand = (
+ ctx: Ctx2D,
+ model: ExportLegendModel,
+ width: number,
+ scale: number,
+): number => {
+ if (model.rows.length === 0) return 0
+ let h = PAD * scale
+ h += headerHeight(headerLinesOf(model), scale)
+ for (const row of model.rows) {
+ h += rowHeight(row, scale)
+ if (row.kind === 'categorical') {
+ const { lines } = wrapCategorical(ctx, row, width, scale)
+ h += (lines - 1) * (SWATCH + LINE_GAP) * scale
+ }
+ h += ROW_GAP * scale
+ }
+ return Math.ceil(h + (PAD - ROW_GAP) * scale)
+}
+
+const paintRamp = (
+ ctx: Ctx2D,
+ colors: string[] | null,
+ x: number,
+ y: number,
+ w: number,
+ h: number,
+) => {
+ const ramp = colors && colors.length > 0 ? colors : NEUTRAL_RAMP
+ if (ramp.length === 1) {
+ ctx.fillStyle = ramp[0]
+ } else {
+ const grad = ctx.createLinearGradient(x, y, x + w, y)
+ // A blank stop color (a legend entry with no color) would throw here
+ // and fail the whole band away — fall back to the same neutral swatch
+ // the categorical path uses for a missing color.
+ ramp.forEach((color, i) =>
+ grad.addColorStop(i / (ramp.length - 1), color || '#bdbdbd'),
+ )
+ ctx.fillStyle = grad
+ }
+ ctx.fillRect(x, y, w, h)
+}
+
+export const drawLegendBand = (
+ ctx: Ctx2D,
+ model: ExportLegendModel,
+ width: number,
+ yTop: number,
+ bandHeight: number,
+ scale: number,
+): void => {
+ ctx.save()
+ ctx.textBaseline = 'top'
+ // Hardcoded white background with dark text, independent of the app
+ // theme: an exported PNG/PDF is a printable/shareable artifact, not a UI
+ // surface, so it stays legible and print-friendly regardless of which
+ // theme produced it.
+ ctx.fillStyle = '#ffffff'
+ ctx.fillRect(0, yTop, width, bandHeight)
+ ctx.fillStyle = '#d0d0d0'
+ ctx.fillRect(0, yTop, width, Math.max(1, Math.round(scale)))
+
+ const left = PAD * scale
+ const textMaxWidth = width - 2 * left
+ let y = yTop + PAD * scale
+
+ const header = headerLinesOf(model)
+ header.forEach((line, i) => {
+ if (i > 0) y += LINE_GAP * scale
+ ctx.fillStyle = line.color
+ ctx.font = FONT(line.size, scale, line.weight)
+ ctx.fillText(clipText(ctx, line.text, textMaxWidth), left, y)
+ y += line.size * scale
+ })
+ if (header.length > 0) y += ROW_GAP * scale
+
+ for (const row of model.rows) {
+ ctx.fillStyle = '#111111'
+ ctx.font = FONT(TITLE_TEXT, scale, 'bold')
+ ctx.fillText(clipText(ctx, row.title, textMaxWidth), left, y)
+ y += TITLE_TEXT * scale
+
+ if (row.dateLine) {
+ y += LINE_GAP * scale
+ ctx.fillStyle = '#444444'
+ ctx.font = FONT(LABEL_TEXT, scale)
+ ctx.fillText(clipText(ctx, row.dateLine, textMaxWidth), left, y)
+ y += LABEL_TEXT * scale
+ }
+
+ if (row.kind === 'plain') {
+ y += ROW_GAP * scale
+ continue
+ }
+ y += LINE_GAP * scale
+
+ if (row.kind === 'gradient') {
+ const barW = Math.min(BAR_WIDTH * scale, width - 2 * left)
+ paintRamp(ctx, row.colors, left, y, barW, BAR_HEIGHT * scale)
+ y += (BAR_HEIGHT + LINE_GAP) * scale
+ ctx.fillStyle = '#444444'
+ ctx.font = FONT(LABEL_TEXT, scale)
+ // Each bound is capped at half the bar less half the gap, so a
+ // long min and a long max are clipped rather than colliding, and
+ // the right-aligned max is clamped to the bar's left edge so it
+ // can never start off-canvas.
+ const boundMaxWidth = Math.max(
+ 0,
+ barW / 2 - (BOUND_GAP / 2) * scale,
+ )
+ const minLabel = clipText(
+ ctx,
+ formatLegendBound(row.min, row.unit),
+ boundMaxWidth,
+ )
+ const maxLabel = clipText(
+ ctx,
+ formatLegendBound(row.max, row.unit),
+ boundMaxWidth,
+ )
+ ctx.fillText(minLabel, left, y)
+ const maxW = ctx.measureText(maxLabel).width
+ ctx.fillText(maxLabel, Math.max(left, left + barW - maxW), y)
+ y += LABEL_TEXT * scale
+ } else {
+ ctx.font = FONT(LABEL_TEXT, scale)
+ const maxX = width - left
+ const labelMaxWidth = categoryLabelMaxWidth(width, scale)
+ let x = left
+ for (const stop of row.stops) {
+ const label = clipText(ctx, stop.label, labelMaxWidth)
+ const labelW = ctx.measureText(label).width
+ const itemW = (SWATCH + 6) * scale + labelW + 16 * scale
+ if (x + itemW > maxX && x > left) {
+ x = left
+ y += (SWATCH + LINE_GAP) * scale
+ }
+ ctx.fillStyle = stop.color || '#bdbdbd'
+ ctx.fillRect(x, y, SWATCH * scale, SWATCH * scale)
+ ctx.fillStyle = '#444444'
+ ctx.fillText(label, x + (SWATCH + 6) * scale, y + 1 * scale)
+ x += itemW
+ }
+ y += SWATCH * scale
+ }
+ y += ROW_GAP * scale
+ }
+ ctx.restore()
+}
diff --git a/src/essence/Tools/_shared/legend/resolveColormapColors.ts b/src/essence/Tools/_shared/legend/resolveColormapColors.ts
new file mode 100644
index 000000000..b1d9f247c
--- /dev/null
+++ b/src/essence/Tools/_shared/legend/resolveColormapColors.ts
@@ -0,0 +1,67 @@
+// Resolves a colormap name to ordered CSS colors for legend rendering.
+// The bundled js-colormaps evaluator is authoritative for every name it
+// knows — it is what the client-side deckRaster renderer paints from, and
+// it needs no network. TiTiler's /colorMaps/{name} is consulted only for
+// names the bundle lacks (server-defined custom ramps). A name neither
+// source recognizes, or a TiTiler lookup that fails outright, falls back to
+// the same viridis ramp deckRaster paints for an unknown colormap (see
+// colormapLUT's FALLBACK) — the export must never disagree with what's on
+// the map. This function never throws; the renderer's neutral gray ramp is
+// the last-ditch backstop for a genuinely missing/null colors list.
+import {
+ evaluate_cmap,
+ data as jsColormapData,
+} from '../../../../external/js-colormaps/js-colormaps.js'
+import { fetchColormapColors } from './colormapCache'
+import { getBaseColormapName, isReversedColormap, findColormapKey } from './colormaps'
+
+const LOCAL_SAMPLES = 256
+const FALLBACK_COLORMAP = 'viridis'
+
+const findLocalKey = (name: string | null | undefined): string | null =>
+ findColormapKey(name, Object.keys(jsColormapData))
+
+// Always samples forward; a reversed name reverses the resulting array
+// rather than passing `reverse: true` into evaluate_cmap. At LOCAL_SAMPLES
+// this granular, `1 - i/n` and `(n-i)/n` don't always land on the same
+// float, which would otherwise make the reversed ramp an approximation of
+// the forward one instead of its exact mirror.
+const localColormapColors = (key: string): string[] => {
+ const colors: string[] = []
+ for (let i = 0; i < LOCAL_SAMPLES; i++) {
+ const [r, g, b] = evaluate_cmap(i / (LOCAL_SAMPLES - 1), key, false)
+ colors.push(`rgb(${r}, ${g}, ${b})`)
+ }
+ return colors
+}
+
+export const resolveColormapColors = async (
+ name: string | null | undefined,
+ titilerUrl?: string | null,
+): Promise => {
+ if (!name) return null
+ const reversed = isReversedColormap(name)
+ const localKey = findLocalKey(name)
+ if (localKey) {
+ const colors = localColormapColors(localKey)
+ return reversed ? [...colors].reverse() : colors
+ }
+
+ let fetched: string[] | null = null
+ try {
+ fetched = await fetchColormapColors(
+ getBaseColormapName(name).toLowerCase(),
+ titilerUrl,
+ )
+ } catch (err) {
+ // fetchColormapColors already swallows its own failures; this guards
+ // the contract against a dependency (real or mocked) that rejects
+ // instead.
+ console.warn('resolveColormapColors: TiTiler lookup failed', err)
+ fetched = null
+ }
+ if (fetched) return reversed ? [...fetched].reverse() : fetched
+
+ const fallbackKey = findLocalKey(FALLBACK_COLORMAP)
+ return fallbackKey ? localColormapColors(fallbackKey) : null
+}
diff --git a/src/essence/Tools/_shared/legend/types.ts b/src/essence/Tools/_shared/legend/types.ts
new file mode 100644
index 000000000..168839afe
--- /dev/null
+++ b/src/essence/Tools/_shared/legend/types.ts
@@ -0,0 +1,45 @@
+export type LegendType = 'gradient' | 'categorical' | 'text' | 'none'
+
+export type CategoricalStop = { color: string; label: string }
+
+export type CogData = {
+ isCog: true
+ /**
+ * Whether the colormap and rescale can be changed, as opposed to only shown.
+ * False for a layer that paints from a COG colormap baked in at construction
+ * — an `image` layer — which gets the ramp and its bounds but no controls.
+ */
+ editable: boolean
+ colormap: string
+ /**
+ * The rescale bounds currently applied, or null where the mission
+ * configured none — nothing stands in for an unconfigured bound, so a
+ * legend renders it blank rather than inventing a range.
+ */
+ min: number | null
+ max: number | null
+ /** Bounds to reset to, and the seed for the rescale control's fields. */
+ defaultMin: number
+ defaultMax: number
+ defaultColormap: string
+ units: string | null
+ titilerUrl: string | null
+}
+
+export type Layer = {
+ id: string
+ title: string
+ description: string | null
+ opacity: number
+ visible: boolean
+ type: LegendType
+ // gradient fields
+ stops?: string[] | null
+ min?: number | null
+ max?: number | null
+ unit?: { label: string } | null
+ // categorical fields
+ categoricalStops?: CategoricalStop[]
+ // optional COG controls
+ cog: CogData | null
+}
diff --git a/src/essence/Tools/_shared/share/__tests__/resolveIncludeLegend.spec.js b/src/essence/Tools/_shared/share/__tests__/resolveIncludeLegend.spec.js
new file mode 100644
index 000000000..925bafb5b
--- /dev/null
+++ b/src/essence/Tools/_shared/share/__tests__/resolveIncludeLegend.spec.js
@@ -0,0 +1,35 @@
+import { test, expect } from 'vitest'
+import { resolveIncludeLegend } from '../resolveIncludeLegend.ts'
+
+// Shared by two consumers — MMGISShareExportAdapter.tsx and
+// MMGISMapControlAdapter.tsx both toggle the export legend band through this
+// one function, so a single suite here covers both call sites. The rule has
+// to cover every form Configure persists an unchecked checkbox as, or a saved
+// 'false'/0/'0' string leaves the band on.
+
+test.describe('resolveIncludeLegend', () => {
+ test('defaults on when no vars are set', () => {
+ expect(resolveIncludeLegend(undefined)).toBe(true)
+ expect(resolveIncludeLegend(null)).toBe(true)
+ expect(resolveIncludeLegend({})).toBe(true)
+ })
+
+ test('treats an explicit true as enabled', () => {
+ expect(resolveIncludeLegend({ includeLegend: true })).toBe(true)
+ })
+
+ test('disables on a real boolean false', () => {
+ expect(resolveIncludeLegend({ includeLegend: false })).toBe(false)
+ })
+
+ test('disables on the string/number forms Configure can persist', () => {
+ expect(resolveIncludeLegend({ includeLegend: 'false' })).toBe(false)
+ expect(resolveIncludeLegend({ includeLegend: 0 })).toBe(false)
+ expect(resolveIncludeLegend({ includeLegend: '0' })).toBe(false)
+ })
+
+ test('a truthy non-boolean value (e.g. "true") stays enabled', () => {
+ expect(resolveIncludeLegend({ includeLegend: 'true' })).toBe(true)
+ expect(resolveIncludeLegend({ includeLegend: 1 })).toBe(true)
+ })
+})
diff --git a/src/essence/Tools/_shared/share/index.ts b/src/essence/Tools/_shared/share/index.ts
index a04a27a57..90f96ba9c 100644
--- a/src/essence/Tools/_shared/share/index.ts
+++ b/src/essence/Tools/_shared/share/index.ts
@@ -6,6 +6,10 @@ export {
type ShareMenuHandlers,
} from './getShareMenuItems'
export type { ShareActionKind, ShareFormatFlags } from './types'
+export {
+ resolveIncludeLegend,
+ type IncludeLegendVars,
+} from './resolveIncludeLegend'
// Side-effect import of compiled styles
import './share-menu.scss'
diff --git a/src/essence/Tools/_shared/share/resolveIncludeLegend.ts b/src/essence/Tools/_shared/share/resolveIncludeLegend.ts
new file mode 100644
index 000000000..c6586af48
--- /dev/null
+++ b/src/essence/Tools/_shared/share/resolveIncludeLegend.ts
@@ -0,0 +1,14 @@
+// Whether the legend band is appended to a PNG/PDF export. Shared by the
+// ShareExport and MapControl adapters so the two can't disagree. Configure's
+// checkbox field persists an unchecked box as any of false, 'false', 0 or
+// '0', so all four read as off.
+
+export type IncludeLegendVars = { includeLegend?: unknown }
+
+const isFalsy = (v: unknown): boolean =>
+ v === false || v === 'false' || v === 0 || v === '0'
+
+/** The legend band defaults to on; an unset or non-falsy value is enabled. */
+export function resolveIncludeLegend(vars?: IncludeLegendVars | null): boolean {
+ return !isFalsy((vars || {}).includeLegend)
+}
diff --git a/tests/unit/ShareExport/composeExportImage.spec.js b/tests/unit/ShareExport/composeExportImage.spec.js
new file mode 100644
index 000000000..6b902d0b1
--- /dev/null
+++ b/tests/unit/ShareExport/composeExportImage.spec.js
@@ -0,0 +1,195 @@
+import { test, expect, vi } from 'vitest'
+import { composeExportImage } from '../../../src/essence/Tools/_shared/legend/composeExportImage.ts'
+import { measureLegendBand } from '../../../src/essence/Tools/_shared/legend/renderLegendBand.ts'
+
+// A minimal 2D-context stand-in: jsdom has no real canvas, so every drawing
+// op the compositor and the legend renderer call is recorded instead of
+// executed. Now a single context (composeExportImage measures and draws on
+// the same canvas — see its "measure before resize" comment), but calls
+// still record `fillStyle` at call time, not just the draw args, so a test
+// can tell a real paint color from an incidental empty fillRect.
+const makeCtx = (calls) => {
+ const ctx = {
+ fillStyle: null,
+ font: '',
+ textBaseline: 'alphabetic',
+ fillRect: (...args) =>
+ calls.push({ op: 'fillRect', args, fillStyle: ctx.fillStyle }),
+ fillText: (...args) =>
+ calls.push({ op: 'fillText', args, fillStyle: ctx.fillStyle }),
+ measureText: (t) => ({ width: t.length * 6 }),
+ createLinearGradient: (...args) => {
+ const stops = []
+ const gradient = {
+ addColorStop: (offset, color) => stops.push({ offset, color }),
+ }
+ calls.push({ op: 'createLinearGradient', args, stops, gradient })
+ return gradient
+ },
+ save: vi.fn(),
+ restore: vi.fn(),
+ drawImage: (...args) =>
+ calls.push({ op: 'drawImage', args, fillStyle: ctx.fillStyle }),
+ }
+ return ctx
+}
+
+const makeCanvas = (calls) => {
+ const canvas = {
+ _width: 0,
+ _height: 0,
+ get width() {
+ return this._width
+ },
+ set width(v) {
+ this._width = v
+ },
+ get height() {
+ return this._height
+ },
+ set height(v) {
+ this._height = v
+ },
+ getContext: () => makeCtx(calls),
+ toBlob: (cb, type) => cb(new Blob(['composed'], { type })),
+ }
+ return canvas
+}
+
+const screenshot = {
+ blob: new Blob(['png'], { type: 'image/png' }),
+ mimeType: 'image/png',
+ extension: 'png',
+ width: 640,
+ height: 480,
+}
+
+const gradientModel = {
+ missionName: 'M20',
+ headerLines: [],
+ rows: [
+ {
+ kind: 'gradient',
+ title: 'Displacement',
+ colors: ['#000', '#fff'],
+ min: 0,
+ max: 10,
+ unit: 'm',
+ },
+ ],
+}
+
+const emptyModel = { missionName: null, headerLines: [], rows: [] }
+
+test.describe('composeExportImage', () => {
+ test('an empty model returns the screenshot untouched', async () => {
+ const createBitmap = vi.fn()
+ const result = await composeExportImage(screenshot, emptyModel, {
+ createBitmap,
+ })
+ expect(result).toBe(screenshot)
+ expect(createBitmap).not.toHaveBeenCalled()
+ })
+
+ test('a null model returns the screenshot untouched', async () => {
+ const createBitmap = vi.fn()
+ const result = await composeExportImage(screenshot, null, {
+ createBitmap,
+ })
+ expect(result).toBe(screenshot)
+ expect(createBitmap).not.toHaveBeenCalled()
+ })
+
+ test('appends the band, preserving width/mimeType/extension', async () => {
+ const calls = []
+ const bitmap = { close: vi.fn() }
+ const result = await composeExportImage(screenshot, gradientModel, {
+ createBitmap: async () => bitmap,
+ createCanvas: () => makeCanvas(calls),
+ scale: 1,
+ })
+ expect(result.width).toBe(screenshot.width)
+ expect(result.height).toBeGreaterThan(screenshot.height)
+ expect(result.mimeType).toBe('image/png')
+ expect(result.extension).toBe('png')
+ expect(result.blob).toBeInstanceOf(Blob)
+ expect(bitmap.close).toHaveBeenCalled()
+
+ // drawImage (the screenshot) happens before the band's own draw ops.
+ const drawImageIndex = calls.findIndex((c) => c.op === 'drawImage')
+ const bandOpIndex = calls.findIndex(
+ (c) => c.op === 'fillRect' || c.op === 'fillText',
+ )
+ expect(drawImageIndex).toBeGreaterThanOrEqual(0)
+ expect(bandOpIndex).toBeGreaterThan(drawImageIndex)
+ expect(calls[drawImageIndex].args[1]).toBe(0)
+ expect(calls[drawImageIndex].args[2]).toBe(0)
+ })
+
+ // The canvas the band is drawn onto has to be sized explicitly: a canvas
+ // left at its 300x150 default silently crops the map, and one never
+ // grown by the band height crops the band. Both produce a plausible-
+ // looking file, so the exact dimensions are asserted here.
+ test('sizes the canvas to the screenshot plus the measured band', async () => {
+ const calls = []
+ const canvas = makeCanvas(calls)
+ const result = await composeExportImage(screenshot, gradientModel, {
+ createBitmap: async () => ({ close: vi.fn() }),
+ createCanvas: () => canvas,
+ scale: 1,
+ })
+ // Measured with the same fake context the compositor uses, so this is
+ // the height the band actually needs rather than a copied constant.
+ const bandHeight = measureLegendBand(
+ makeCtx([]),
+ gradientModel,
+ screenshot.width,
+ 1,
+ )
+ expect(bandHeight).toBeGreaterThan(0)
+ expect(canvas.width).toBe(screenshot.width)
+ expect(canvas.height).toBe(screenshot.height + bandHeight)
+ expect(result.height).toBe(screenshot.height + bandHeight)
+ })
+
+ // The band is drawn immediately below the map, not over it.
+ test('draws the band starting at the screenshot\'s bottom edge', async () => {
+ const calls = []
+ await composeExportImage(screenshot, gradientModel, {
+ createBitmap: async () => ({ close: vi.fn() }),
+ createCanvas: () => makeCanvas(calls),
+ scale: 1,
+ })
+ const band = calls.find(
+ (c) => c.op === 'fillRect' && c.fillStyle === '#ffffff',
+ )
+ expect(band.args[1]).toBe(screenshot.height)
+ })
+
+ test('paints the gradient bar with the model\'s actual colors', async () => {
+ const calls = []
+ const bitmap = { close: vi.fn() }
+ await composeExportImage(screenshot, gradientModel, {
+ createBitmap: async () => bitmap,
+ createCanvas: () => makeCanvas(calls),
+ scale: 1,
+ })
+ const gradients = calls.filter((c) => c.op === 'createLinearGradient')
+ expect(gradients).toHaveLength(1)
+ expect(gradients[0].stops.map((s) => s.color)).toEqual(['#000', '#fff'])
+ })
+
+ test('a null blob from toBlob rejects, and the bitmap is still closed', async () => {
+ const bitmap = { close: vi.fn() }
+ const canvas = makeCanvas([])
+ canvas.toBlob = (cb) => cb(null)
+ await expect(
+ composeExportImage(screenshot, gradientModel, {
+ createBitmap: async () => bitmap,
+ createCanvas: () => canvas,
+ scale: 1,
+ }),
+ ).rejects.toThrow('Legend compositing produced no image')
+ expect(bitmap.close).toHaveBeenCalled()
+ })
+})
diff --git a/tests/unit/ShareExport/shareActions.spec.js b/tests/unit/ShareExport/shareActions.spec.js
index e80b81218..43de17913 100644
--- a/tests/unit/ShareExport/shareActions.spec.js
+++ b/tests/unit/ShareExport/shareActions.spec.js
@@ -12,6 +12,7 @@ import {
mmgisWriteCoordinateURL,
mmgisGetMapScreenshot,
mmgisGetViewState,
+ mmgisGetCurrentTimeFormatted,
} from '../../../src/essence/Tools/_shared/adapters/mmgisAPI.ts'
// Issue #144 - the adapter must call the right plugin-API methods and package
@@ -83,6 +84,7 @@ test.describe('bus wiring of the shared-client wrappers', () => {
'map:getScreenshot': screenshot,
'map:getViewState': viewState,
'app:copyText': true,
+ 'time:getCurrentFormatted': '2024-01-01T00:00:00Z',
})
window.mmgisAPI = api
try {
@@ -92,11 +94,15 @@ test.describe('bus wiring of the shared-client wrappers', () => {
await expect(mmgisGetMapScreenshot()).resolves.toBe(screenshot)
await expect(mmgisGetViewState()).resolves.toBe(viewState)
await expect(mmgisCopyText('hello')).resolves.toBe(true)
+ await expect(mmgisGetCurrentTimeFormatted()).resolves.toBe(
+ '2024-01-01T00:00:00Z',
+ )
expect(requests.map((r) => r.name)).toEqual([
'map:writeCoordinateURL',
'map:getScreenshot',
'map:getViewState',
'app:copyText',
+ 'time:getCurrentFormatted',
])
expect(requests[3].params).toBe('hello')
} finally {
@@ -122,6 +128,7 @@ test.describe('bus wiring of the shared-client wrappers', () => {
await expect(mmgisWriteCoordinateURL()).resolves.toBe(null)
await expect(mmgisGetMapScreenshot()).resolves.toBe(null)
await expect(mmgisGetViewState()).resolves.toBe(null)
+ await expect(mmgisGetCurrentTimeFormatted()).resolves.toBe(null)
// hasHandler said no, so request() must never have been risked.
expect(requests).toEqual([])
} finally {
@@ -275,6 +282,117 @@ test.describe('buildExportFilename', () => {
})
})
+test.describe('legend compositing in downloadSharePng', () => {
+ const screenshot = {
+ blob: new Blob(['png'], { type: 'image/png' }),
+ mimeType: 'image/png',
+ extension: 'png',
+ width: 640,
+ height: 480,
+ }
+ const composedBlob = new Blob(['composed'], { type: 'image/png' })
+ const composed = { ...screenshot, blob: composedBlob, height: 700 }
+ const emptyModel = { missionName: null, headerLines: [], rows: [] }
+
+ test('composes by default and downloads the composed blob', async () => {
+ const composeCalls = []
+ const downloads = []
+ const result = await downloadSharePng({
+ getScreenshot: async () => screenshot,
+ download: (blob, filename) => downloads.push({ blob, filename }),
+ getLegendModel: async () => emptyModel,
+ compose: async (shot, model) => {
+ composeCalls.push({ shot, model })
+ return composed
+ },
+ })
+ expect(composeCalls).toEqual([{ shot: screenshot, model: emptyModel }])
+ expect(downloads).toEqual([
+ { blob: composedBlob, filename: PNG_FILENAME },
+ ])
+ expect(result).toBe(composed)
+ })
+
+ test('includeLegend: false skips the legend entirely', async () => {
+ const getLegendModel = () => {
+ throw new Error('should not be called')
+ }
+ const compose = () => {
+ throw new Error('should not be called')
+ }
+ const downloads = []
+ const result = await downloadSharePng({
+ getScreenshot: async () => screenshot,
+ download: (blob, filename) => downloads.push({ blob, filename }),
+ includeLegend: false,
+ getLegendModel,
+ compose,
+ })
+ expect(downloads).toEqual([
+ { blob: screenshot.blob, filename: PNG_FILENAME },
+ ])
+ expect(result).toBe(screenshot)
+ })
+
+ test('a legend model failure downloads the plain map instead of throwing', async () => {
+ const downloads = []
+ const result = await downloadSharePng({
+ getScreenshot: async () => screenshot,
+ download: (blob, filename) => downloads.push({ blob, filename }),
+ getLegendModel: async () => {
+ throw new Error('legend model blew up')
+ },
+ })
+ expect(downloads).toEqual([
+ { blob: screenshot.blob, filename: PNG_FILENAME },
+ ])
+ expect(result).toBe(screenshot)
+ })
+})
+
+test.describe('legend compositing in downloadSharePdf', () => {
+ const screenshot = {
+ blob: new Blob(['png'], { type: 'image/png' }),
+ mimeType: 'image/png',
+ extension: 'png',
+ width: 640,
+ height: 480,
+ }
+ const composedBlob = new Blob(['composed'], { type: 'image/png' })
+ const composed = { ...screenshot, blob: composedBlob, height: screenshot.height + 100 }
+ const emptyModel = { missionName: null, headerLines: [], rows: [] }
+
+ test('buildPdf receives the composed width/height, not the original', async () => {
+ const buildArgs = []
+ const doc = await downloadSharePdf({
+ getScreenshot: async () => screenshot,
+ blobToDataUrl: async () => 'data:image/png;base64,x',
+ buildPdf: (data, w, h) => {
+ buildArgs.push({ w, h })
+ return { save: () => {} }
+ },
+ getLegendModel: async () => emptyModel,
+ compose: async () => composed,
+ })
+ expect(buildArgs).toEqual([{ w: composed.width, h: composed.height }])
+ expect(doc).toBeTruthy()
+ })
+
+ test('includeLegend: false uses the original dimensions', async () => {
+ const buildArgs = []
+ await downloadSharePdf({
+ getScreenshot: async () => screenshot,
+ blobToDataUrl: async () => 'data:image/png;base64,x',
+ buildPdf: (data, w, h) => {
+ buildArgs.push({ w, h })
+ return { save: () => {} }
+ },
+ includeLegend: false,
+ })
+ expect(buildArgs).toEqual([{ w: screenshot.width, h: screenshot.height }])
+ })
+})
+
test.describe('provenance filenames in downloads', () => {
test('PNG download names the file from the injected view state', async () => {
const blob = new Blob(['png'], { type: 'image/png' })
diff --git a/tests/unit/ShareExport/shareConfig.spec.js b/tests/unit/ShareExport/shareConfig.spec.js
index 8ea3435ac..bc1f75117 100644
--- a/tests/unit/ShareExport/shareConfig.spec.js
+++ b/tests/unit/ShareExport/shareConfig.spec.js
@@ -31,3 +31,6 @@ test.describe('resolveShareFormats', () => {
).toEqual({ png: false, pdf: false })
})
})
+
+// includeLegend resolution moved to _shared/share/resolveIncludeLegend,
+// shared with the MapControl adapter — see that module's own spec.
diff --git a/tests/unit/coreBoundary.spec.js b/tests/unit/coreBoundary.spec.js
new file mode 100644
index 000000000..74c629301
--- /dev/null
+++ b/tests/unit/coreBoundary.spec.js
@@ -0,0 +1,86 @@
+import { describe, test, expect } from 'vitest'
+import { readdirSync, readFileSync, statSync } from 'node:fs'
+import { join, relative, resolve, dirname } from 'node:path'
+
+/**
+ * The other half of the plugin boundary.
+ *
+ * `Basics/` is the core: the map engines, the layer store, the bus. Tools are
+ * on their way to being plugin packages installed on top of it, so a core
+ * module that imports one inverts the dependency — the core stops being
+ * installable without that plugin's directory present, and extracting the
+ * plugin breaks the core path that reached into it.
+ *
+ * Sharing in the other direction is fine and expected: a plugin may import a
+ * core module, and core code that needs to agree with a plugin (colormap
+ * naming, say) owns the shared module itself.
+ */
+
+const CORE_ROOT = resolve(process.cwd(), 'src/essence/Basics')
+const TOOLS_ROOT = resolve(process.cwd(), 'src/essence/Tools')
+const SOURCE_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx']
+
+const sourceFilesUnder = (dir) => {
+ const found = []
+ for (const entry of readdirSync(dir)) {
+ const path = join(dir, entry)
+ if (statSync(path).isDirectory()) {
+ found.push(...sourceFilesUnder(path))
+ } else if (SOURCE_EXTENSIONS.some((ext) => entry.endsWith(ext))) {
+ found.push(path)
+ }
+ }
+ return found
+}
+
+/** Every module specifier in `import ... from 'x'`, `export ... from 'x'`, and `import('x')`. */
+const importedSpecifiers = (source) => {
+ const specifiers = []
+ const patterns = [
+ /(?:^|\n)\s*(?:import|export)[\s\S]*?\sfrom\s+['"]([^'"]+)['"]/g,
+ /(?:^|\n)\s*import\s+['"]([^'"]+)['"]/g,
+ /\bimport\(\s*['"]([^'"]+)['"]\s*\)/g,
+ ]
+ for (const pattern of patterns) {
+ let match
+ while ((match = pattern.exec(source)) !== null) specifiers.push(match[1])
+ }
+ return specifiers
+}
+
+const CORE_FILES = sourceFilesUnder(CORE_ROOT)
+
+describe('core is free of the plugins', () => {
+ test('there are core files to check', () => {
+ // Guards the walker: a move that emptied CORE_FILES would make the
+ // assertion below pass over nothing.
+ expect(CORE_FILES.length).toBeGreaterThan(10)
+ })
+
+ test('no module under Basics/ imports from Tools/', () => {
+ const reaching = []
+ for (const file of CORE_FILES) {
+ for (const specifier of importedSpecifiers(readFileSync(file, 'utf8'))) {
+ const target = specifier.startsWith('.')
+ ? resolve(dirname(file), specifier)
+ : null
+ const namesTools =
+ (target && target.startsWith(TOOLS_ROOT + '/')) ||
+ /(^|\/)essence\/Tools\//.test(specifier)
+ if (namesTools) {
+ reaching.push(`${relative(CORE_ROOT, file)} -> ${specifier}`)
+ }
+ }
+ }
+ expect(reaching).toEqual([])
+ })
+
+ test('the guard would catch a violation it is meant to catch', () => {
+ // Proves the matcher is live rather than vacuously passing.
+ const specifier = '../../../Tools/_shared/legend/colormaps'
+ const from = join(CORE_ROOT, 'MapEngines/Adapters/colormapLUT.ts')
+ expect(
+ resolve(dirname(from), specifier).startsWith(TOOLS_ROOT + '/'),
+ ).toBe(true)
+ })
+})
diff --git a/tests/unit/layerTimePolicy.spec.js b/tests/unit/layerTimePolicy.spec.js
new file mode 100644
index 000000000..0166143c6
--- /dev/null
+++ b/tests/unit/layerTimePolicy.spec.js
@@ -0,0 +1,94 @@
+import { describe, test, expect } from 'vitest'
+import {
+ resolveTimePolicy,
+ parseISODuration,
+} from '../../src/essence/Basics/TimeControl_/layerTimePolicy'
+
+// Injected "now" so results are exact: mid-afternoon UTC.
+const NOW = new Date('2026-08-25T15:42:31.500Z')
+
+describe('layer time policy', () => {
+ describe('parseISODuration', () => {
+ test.each([
+ ['P1D', { days: 1 }],
+ ['PT1H', { hours: 1 }],
+ ['P1M', { months: 1 }],
+ ['P2W', { weeks: 2 }],
+ ['P1DT12H', { days: 1, hours: 12 }],
+ ])('parses %s', (value, expected) => {
+ expect(parseISODuration(value)).toMatchObject(expected)
+ })
+
+ test('parses a full compound duration', () => {
+ expect(parseISODuration('P1Y2M3DT4H5M6S')).toEqual({
+ years: 1,
+ months: 2,
+ weeks: 0,
+ days: 3,
+ hours: 4,
+ minutes: 5,
+ seconds: 6,
+ })
+ })
+
+ // 'P0D' is well-formed and length zero: nothing to offset by, and no
+ // period it could ever contain.
+ test.each([['garbage'], ['P'], ['1D'], [''], ['P0D'], ['P0DT0H']])(
+ 'rejects %s',
+ (value) => {
+ expect(parseISODuration(value)).toBeNull()
+ },
+ )
+ })
+
+ describe('resolveTimePolicy', () => {
+ test('concrete ISO datetimes pass through, normalized', () => {
+ expect(
+ resolveTimePolicy('2025-01-12T23:59:59+00:00', { now: NOW }),
+ ).toBe('2025-01-12T23:59:59Z')
+ })
+
+ test('absent or unparseable values resolve to null', () => {
+ expect(resolveTimePolicy(null, { now: NOW })).toBeNull()
+ expect(resolveTimePolicy(undefined, { now: NOW })).toBeNull()
+ expect(resolveTimePolicy('', { now: NOW })).toBeNull()
+ expect(resolveTimePolicy('not-a-date', { now: NOW })).toBeNull()
+ expect(resolveTimePolicy('now - garbage', { now: NOW })).toBeNull()
+ })
+
+ test('"now" is the raw current moment — no rounding (veda-ui rule)', () => {
+ expect(resolveTimePolicy('now', { now: NOW })).toBe(
+ '2026-08-25T15:42:31Z',
+ )
+ })
+
+ test('offsets: "now - P1D" and forecast-style "now + P5D"', () => {
+ expect(resolveTimePolicy('now - P1D', { now: NOW })).toBe(
+ '2026-08-24T15:42:31Z',
+ )
+ expect(resolveTimePolicy('now + P5D', { now: NOW })).toBe(
+ '2026-08-30T15:42:31Z',
+ )
+ })
+
+ test('sub-day and calendar-unit offsets use date math, not ms math', () => {
+ expect(resolveTimePolicy('now - PT6H', { now: NOW })).toBe(
+ '2026-08-25T09:42:31Z',
+ )
+ // One month back from late August is late July — a fixed-ms
+ // implementation would drift.
+ expect(resolveTimePolicy('now - P1M', { now: NOW })).toBe(
+ '2026-07-25T15:42:31Z',
+ )
+ })
+
+ test('spacing around the sign is flexible', () => {
+ expect(resolveTimePolicy('now-P1D', { now: NOW })).toBe(
+ '2026-08-24T15:42:31Z',
+ )
+ expect(resolveTimePolicy('now + P1D', { now: NOW })).toBe(
+ '2026-08-26T15:42:31Z',
+ )
+ })
+ })
+})
diff --git a/tests/unit/timeControlProviders.spec.js b/tests/unit/timeControlProviders.spec.js
new file mode 100644
index 000000000..e45c763eb
--- /dev/null
+++ b/tests/unit/timeControlProviders.spec.js
@@ -0,0 +1,219 @@
+import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest'
+
+/**
+ * TimeControl.init() registers its bus providers (time:isEnabled,
+ * time:getCurrent, ...) before checking whether the mission has time
+ * enabled, so time:getCurrentFormatted — added alongside them to expose the
+ * current time through the mission's time.format rather than a raw ISO
+ * string — must always be registered, and must itself resolve null until
+ * time is both enabled and seeded.
+ *
+ * That time.format comes in two languages: d3 time-format specifiers, marked
+ * by a '%', and moment tokens. Both must render an actual date.
+ *
+ * time:formatTime applies the same mission format to a time the caller
+ * supplies, for a plugin displaying a time of its own (a per-layer window on
+ * an exported legend) rather than the cursor's.
+ */
+
+vi.mock('../../src/essence/Basics/Map_/Map_', () => ({ default: {} }))
+
+// A minimal stand-in for the mmgisAPI bus: captures whatever TimeControl
+// registers via `provide`, keyed by name, so a test can call the handler
+// directly the way mmgisRequestIfProvided would.
+const makeFakeBus = () => {
+ const handlers = {}
+ return {
+ handlers,
+ bus: {
+ on: () => () => {},
+ emit: () => {},
+ provide: (name, handler) => {
+ handlers[name] = handler
+ return () => {
+ delete handlers[name]
+ }
+ },
+ },
+ }
+}
+
+// Mocks Layers_ with the given mission configData, then imports and inits a
+// fresh TimeControl against a fake bus. Returns the captured handlers.
+const initTimeControl = async (configData) => {
+ const { bus, handlers } = makeFakeBus()
+ window.mmgisAPI = bus
+ vi.doMock('../../src/essence/Basics/Layers_/Layers_', () => ({
+ default: { configData, FUTURES: {}, layers: { data: {}, dataFlat: {} } },
+ }))
+
+ const TimeControl = (
+ await import('../../src/essence/Basics/TimeControl_/TimeControl')
+ ).default
+ TimeControl.init()
+
+ return { TimeControl, handlers }
+}
+
+// A mission with time on and seeded, formatted however the caller writes it.
+const enabledTimeConfig = (format) => ({
+ time: {
+ enabled: true,
+ ...(format === undefined ? {} : { format }),
+ initialend: '2026-08-20T19:24:39Z',
+ initialstart: '2026-07-20T19:24:39Z',
+ },
+})
+
+describe('TimeControl time:getCurrentFormatted provider', () => {
+ let originalMmgisAPI
+
+ beforeEach(() => {
+ originalMmgisAPI = window.mmgisAPI
+ vi.resetModules()
+ })
+
+ afterEach(() => {
+ window.mmgisAPI = originalMmgisAPI
+ })
+
+ test('is registered even when the mission has time disabled, and resolves null', async () => {
+ const { handlers } = await initTimeControl({})
+
+ expect(typeof handlers['time:getCurrentFormatted']).toBe('function')
+ expect(handlers['time:getCurrentFormatted']()).toBeNull()
+ })
+
+ test('formats the seeded current time through a moment-style mission format', async () => {
+ const { TimeControl, handlers } = await initTimeControl(
+ enabledTimeConfig('YYYY-MM-DDTHH:mm:ss[Z]')
+ )
+
+ // The actual formatted date, never the literal pattern string.
+ expect(handlers['time:getCurrentFormatted']()).toBe(
+ '2026-08-20T19:24:39Z'
+ )
+ // Same underlying time as the existing raw-ISO provider, just
+ // formatted differently.
+ expect(handlers['time:getCurrent']()).toBe(TimeControl.getTime())
+ })
+
+ test('formats the seeded current time through a d3-style mission format', async () => {
+ const { handlers } = await initTimeControl(
+ enabledTimeConfig('%Y-%m-%dT%H:%M:%SZ')
+ )
+
+ // Moment would leave the '%'s literal and read 'm' as minutes; d3
+ // renders the same date the layer-level time.format contract does.
+ expect(handlers['time:getCurrentFormatted']()).toBe(
+ '2026-08-20T19:24:39Z'
+ )
+ })
+
+ test('renders a d3 format whose tokens moment would misread', async () => {
+ const { handlers } = await initTimeControl(
+ enabledTimeConfig('%d %b %Y')
+ )
+
+ expect(handlers['time:getCurrentFormatted']()).toBe('20 Aug 2026')
+ })
+
+ test('falls back to the default moment format when the mission has no time.format', async () => {
+ const { handlers } = await initTimeControl(enabledTimeConfig())
+
+ expect(handlers['time:getCurrentFormatted']()).toBe(
+ '2026-08-20T19:24:39Z'
+ )
+ })
+
+ test('falls back to the default moment format when time.format is empty', async () => {
+ const { handlers } = await initTimeControl(enabledTimeConfig(''))
+
+ expect(handlers['time:getCurrentFormatted']()).toBe(
+ '2026-08-20T19:24:39Z'
+ )
+ })
+
+ test('falls back to the default rather than throwing on an unusable format', async () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ const { handlers } = await initTimeControl(enabledTimeConfig(42))
+
+ expect(handlers['time:getCurrentFormatted']()).toBe(
+ '2026-08-20T19:24:39Z'
+ )
+ expect(warn).toHaveBeenCalled()
+ warn.mockRestore()
+ })
+})
+
+describe('TimeControl time:formatTime provider', () => {
+ let originalMmgisAPI
+
+ beforeEach(() => {
+ originalMmgisAPI = window.mmgisAPI
+ vi.resetModules()
+ })
+
+ afterEach(() => {
+ window.mmgisAPI = originalMmgisAPI
+ })
+
+ test('is registered even when the mission has time disabled', async () => {
+ const { handlers } = await initTimeControl({})
+
+ expect(typeof handlers['time:formatTime']).toBe('function')
+ })
+
+ test('formats a caller-supplied time through a moment-style mission format', async () => {
+ const { handlers } = await initTimeControl(
+ enabledTimeConfig('YYYY-MM-DD')
+ )
+
+ expect(handlers['time:formatTime']('2015-03-13T00:00:00Z')).toBe(
+ '2015-03-13'
+ )
+ })
+
+ test('formats a caller-supplied time through a d3-style mission format', async () => {
+ const { handlers } = await initTimeControl(
+ enabledTimeConfig('%d %b %Y')
+ )
+
+ expect(handlers['time:formatTime']('2015-03-13T00:00:00Z')).toBe(
+ '13 Mar 2015'
+ )
+ })
+
+ // The time comes from the caller, so the cursor's own state is not what
+ // gates an answer — a layer with its own window still gets one.
+ test('answers for a mission whose time is disabled', async () => {
+ const { handlers } = await initTimeControl({
+ time: { format: 'YYYY-MM-DD' },
+ })
+
+ expect(handlers['time:formatTime']('2015-03-13T00:00:00Z')).toBe(
+ '2015-03-13'
+ )
+ })
+
+ test('is null for a missing or unparseable time', async () => {
+ const { handlers } = await initTimeControl(
+ enabledTimeConfig('YYYY-MM-DD')
+ )
+
+ expect(handlers['time:formatTime'](null)).toBeNull()
+ expect(handlers['time:formatTime'](undefined)).toBeNull()
+ expect(handlers['time:formatTime']('not a time')).toBeNull()
+ })
+
+ test('falls back to the default rather than throwing on an unusable format', async () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ const { handlers } = await initTimeControl(enabledTimeConfig(42))
+
+ expect(handlers['time:formatTime']('2015-03-13T00:00:00Z')).toBe(
+ '2015-03-13T00:00:00Z'
+ )
+ expect(warn).toHaveBeenCalled()
+ warn.mockRestore()
+ })
+})