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