Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
1235af6
Add a layer time-policy resolver: open-ended data times ('now', ISO-d…
BhattaraiSijan Aug 25, 2026
5e6ed2d
Timeline resolves layer time policies through the shared resolver
BhattaraiSijan Aug 25, 2026
800684a
Document the open-ended time vocabulary where admins read it
BhattaraiSijan Aug 26, 2026
ac4f09f
Drop the unused interval/isPeriodic declarations from the bus contract
BhattaraiSijan Aug 26, 2026
13807e0
Show the Timeline in the demo dashboard
CarsonDavis Aug 27, 2026
15eb80f
[328] Move the legend model and colormap helpers to Tools/_shared/legend
CarsonDavis Aug 19, 2026
861a2c3
[328] Add config-only colormap resolver with TiTiler fallback
CarsonDavis Aug 19, 2026
5f2e643
[328] Share legend value formatting between the gradient bar and exports
CarsonDavis Aug 19, 2026
d93bb7b
[328] Build the export legend model from the shared layer legend data
CarsonDavis Aug 19, 2026
851f09d
[328] Render the export legend band on a 2D canvas
CarsonDavis Aug 19, 2026
f32c6e4
[328] Compose the legend band onto exported screenshots
CarsonDavis Aug 19, 2026
26ec37f
[328] Append the legend band to PNG and PDF share exports
CarsonDavis Aug 19, 2026
e833ffe
[328] Add the includeLegend toggle to ShareExport and MapControl
CarsonDavis Aug 19, 2026
9d34175
[328] Guard blank gradient stops and clip overflowing legend text
CarsonDavis Aug 19, 2026
64e5a18
[328] Fix blank/non-numeric legend bound formatting, reuse it in Grad…
CarsonDavis Aug 19, 2026
68cb066
[328] Mark cog-derived legends as auto-generated so exports show live…
CarsonDavis Aug 19, 2026
0532352
[328] Narrow the lib-boundary exemption to the host-agnostic _shared/…
CarsonDavis Aug 19, 2026
2f5dbe8
[328] Never throw on a colormap lookup failure, and match the map's f…
CarsonDavis Aug 19, 2026
45b22c5
[328] Format the export header's time through the mission's time format
CarsonDavis Aug 19, 2026
3e9c5f7
[328] Share one includeLegend resolver between ShareExport and MapCon…
CarsonDavis Aug 19, 2026
f4448f7
[328] Move composeExportImage into _shared/legend; measure on one canvas
CarsonDavis Aug 20, 2026
d30ec4f
[328] Cleanups: 4-space indent, intentional-palette comment, Layer im…
CarsonDavis Aug 20, 2026
1cffa37
[328] Format time:getCurrentFormatted with moment, not d3 utcFormat
CarsonDavis Aug 20, 2026
7a51d06
[328] Stop inventing a legend unit from a negative decimal's digits
CarsonDavis Aug 20, 2026
ffcaca0
[328] Filter the export legend to layers actually visible in the view…
CarsonDavis Aug 20, 2026
b4fa235
[328] Stop dropping whole-world layers from the export legend
CarsonDavis Aug 20, 2026
87acc1a
[328] Update time.format docs from D3 to moment tokens
CarsonDavis Aug 20, 2026
3a2323a
[328] Dedupe the export model's layers:getAllConfigs fetch
CarsonDavis Aug 20, 2026
b78a8da
[328] Treat raster footprints as advisory, not a render clip
CarsonDavis Aug 20, 2026
2bff679
[328] Give every toggled-on layer a legend row
CarsonDavis Aug 27, 2026
9d69768
[328] Keep a derived legend when there is no cog to prefer
CarsonDavis Aug 27, 2026
5a67f5e
[328] Accept both d3 and moment mission time formats
CarsonDavis Aug 27, 2026
1f9fb81
[328] Regenerate the demo mission config for includeLegend
CarsonDavis Aug 27, 2026
cf22a29
[328] Document what is detectable about a layer at runtime
CarsonDavis Aug 27, 2026
38b6434
[328] Own the colormap naming primitives in core
CarsonDavis Aug 27, 2026
ee6cc6f
[328] Read a mixed legend as swatches, not a ramp
CarsonDavis Aug 27, 2026
9bed6ce
[328] Keep bound labels inside the bar and layers on an unknown signal
CarsonDavis Aug 27, 2026
ba727f6
[328] Pin the assertions that let the band break silently
CarsonDavis Aug 27, 2026
111be6a
Resolve layer time policies in core via layers:getTemporalExtent
BhattaraiSijan Aug 28, 2026
3d1fba5
Merge remote-tracking branch 'origin/feat/open-ended-layer-time' into…
CarsonDavis Aug 28, 2026
39400e2
[328] Show each time-enabled layer's window on the export legend band
CarsonDavis Sep 2, 2026
3bb0ead
[328] Document where every date in MMGIS comes from
CarsonDavis Sep 2, 2026
756c323
Merge remote-tracking branch 'origin/feat/veda-stac-layer-source' int…
CarsonDavis Sep 2, 2026
f7f8062
[328] Document the VEDA STAC source's acquisition and period fields
CarsonDavis Sep 2, 2026
c65fa10
[328] Export the time-policy duration helpers and reject zero-length …
CarsonDavis Sep 2, 2026
0e57045
[328] Date every export legend row and stamp the band with the cursor…
CarsonDavis Sep 2, 2026
bac0869
[328] Print each legend row's dates at the precision its interval imp…
CarsonDavis Sep 3, 2026
9fc6869
[328] Give the demo mission's layers their period and coverage fields
CarsonDavis Sep 3, 2026
1dbd525
[328] Print a Collected extent narrower than its precision as one label
CarsonDavis Sep 4, 2026
5efeecf
[328] Bound a time-enabled row's date by the layer's coverage
CarsonDavis Sep 4, 2026
a035a68
[328] Say where a legend row's coverage dates come from
CarsonDavis Sep 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions configure/src/metaconfigs/layer-tile-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
4 changes: 2 additions & 2 deletions configure/src/metaconfigs/layer-vector-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
2 changes: 1 addition & 1 deletion configure/src/metaconfigs/tab-time-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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: <a target='_blank' href='https://github.com/d3/d3-time-format'>https://github.com/d3/d3-time-format</a>. 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 (<a target='_blank' href='https://github.com/d3/d3-time-format'>https://github.com/d3/d3-time-format</a>) - for instance %Y-%m-%dT%H:%M:%SZ - otherwise the string is read as moment.js time format tokens (<a target='_blank' href='https://momentjs.com/docs/#/displaying/format/'>https://momentjs.com/docs/#/displaying/format/</a>) - for instance YYYY-MM-DDTHH:mm:ss[Z]. Default: YYYY-MM-DDTHH:mm:ss[Z]",
"type": "text",
"disableSwitch": "time.enabled",
"width": 3
Expand Down
119 changes: 119 additions & 0 deletions docs/DATES.md
Original file line number Diff line number Diff line change
@@ -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 <time>` — the cursor as `time:getCurrentFormatted` renders it, left out when that returns null — then `Exported <now>` — 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 <start> → <end>`, or `Collected from <start>` / `Collected until <end>` 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 <start> → <end>`, 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 <end>`, 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** `<window start> → <cursor>`, or `Requested up to <cursor>` 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.
Loading