Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
15 changes: 11 additions & 4 deletions configure/src/metaconfigs/layer-tile-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -478,16 +478,23 @@
{
"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
"width": 4
},
{
"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
"width": 4
},
{
"field": "time.interval",
"name": "Data Time Interval",
"description": "How often new data is added, as an ISO 8601 duration (e.g., P1D for daily, P7D for weekly, P1M for monthly). When set, the data end time snaps back to the most recent date that actually has data. Leave empty for continuous or irregular data. An unreadable value is ignored.",
"type": "text",
"width": 4
},
{
"field": "time.dataDates",
Expand Down
15 changes: 11 additions & 4 deletions configure/src/metaconfigs/layer-vector-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -508,16 +508,23 @@
{
"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
"width": 4
},
{
"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
"width": 4
},
{
"field": "time.interval",
"name": "Data Time Interval",
"description": "How often new data is added, as an ISO 8601 duration (e.g., P1D for daily, P7D for weekly, P1M for monthly). When set, the data end time snaps back to the most recent date that actually has data. Leave empty for continuous or irregular data. An unreadable value is ignored.",
"type": "text",
"width": 4
},
{
"field": "time.dataDates",
Expand Down
20 changes: 20 additions & 0 deletions src/essence/Basics/Layers_/Layers_.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import CursorInfo from '../../Ancillary/CursorInfo'
import ToolController_ from '../../Basics/ToolController_/ToolController_'
import LayerGeologic from './LayerGeologic/LayerGeologic'
import ServiceUrls from '../ServiceUrls/ServiceUrls'
import { resolveTemporalExtent } from '../TimeControl_/layerTimePolicy'
import {
isRasterTileLayerType,
MAP_ENGINE,
Expand All @@ -32,6 +33,10 @@ 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) =>
resolveTemporalExtent(L_.layers.data[uuid]?.time)

/**
* Canonical layer types whose deck.gl builders read the legend as a style
* specification. Others carry a legend purely for display. Membership is
Expand Down Expand Up @@ -458,6 +463,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
Expand Down
170 changes: 170 additions & 0 deletions src/essence/Basics/TimeControl_/layerTimePolicy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
/**
* 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.
*
* A periodic layer additionally declares `time.interval`, an ISO-8601
* duration ("P7D"): data exists only at start-anchored steps, so the
* extent's end floors to the last step at or before the resolved end —
* a 7-day cadence that began ten days ago ended three days ago, not now.
*
* 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+))?$/

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
if (![years, months, weeks, days, hours, minutes, seconds].some((v) => v))
return null
return {
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),
}
}

// Months and years are not fixed millisecond amounts — apply them with UTC
// date-component math, never ms arithmetic. Applying `factor × d` in one
// pass keeps a month cadence anchored to the start's day-of-month instead
// of drifting through short months.
function addDuration(date: Date, d: Duration, factor: number): Date {
const out = new Date(date)
out.setUTCFullYear(out.getUTCFullYear() + factor * d.years)
out.setUTCMonth(out.getUTCMonth() + factor * d.months)
out.setUTCDate(out.getUTCDate() + factor * (d.days + 7 * d.weeks))
out.setUTCHours(out.getUTCHours() + factor * d.hours)
out.setUTCMinutes(out.getUTCMinutes() + factor * d.minutes)
out.setUTCSeconds(out.getUTCSeconds() + factor * 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)
}

export interface TemporalExtent {
start: string | null
end: string | null
}

const MS_PER_DAY = 86400000

// Rough length of one cadence step, only to seed the step count — the
// exact landing is settled by calendar math below.
function approximateMs(d: Duration): number {
return (
(d.years * 365.2425 + d.months * 30.436875 + d.weeks * 7 + d.days) *
MS_PER_DAY +
d.hours * 3600000 +
d.minutes * 60000 +
d.seconds * 1000
)
}

// Last start-anchored step at or before `end`: start + N × cadence for the
// largest N ≥ 0 that fits. An end before start clamps to the start itself.
function floorToStep(start: Date, end: Date, cadence: Duration): Date {
const stepAt = (n: number) => addDuration(start, cadence, n)
let n = Math.max(
0,
Math.floor(
(end.getTime() - start.getTime()) / approximateMs(cadence)
)
)
while (stepAt(n + 1).getTime() <= end.getTime()) n++
while (n > 0 && stepAt(n).getTime() > end.getTime()) n--
return stepAt(n)
}

/**
* Resolves a layer's `time` block to its temporal extent: both data time
* policies resolved, and — when the layer declares a periodic `interval`
* (ISO-8601 duration) — the end floored to the last start-anchored step,
* since no data exists between steps. An unparseable interval, or one
* without a resolvable start to anchor to, leaves the extent unsnapped.
*
* @param time - The layer config's `time` block.
* @param options.now - Injectable current moment (tests).
*/
export function resolveTemporalExtent(
time:
| {
dataStartTime?: string | null
dataEndTime?: string | null
interval?: string | null
}
| null
| undefined,
options: { now?: Date } = {}
): TemporalExtent {
const start = resolveTimePolicy(time?.dataStartTime, options)
let end = resolveTimePolicy(time?.dataEndTime, options)

if (start != null && end != null && time?.interval != null) {
const cadence = parseISODuration(String(time.interval).trim())
// A zero cadence ("P0D") parses but cannot step anywhere.
if (cadence != null && approximateMs(cadence) > 0) {
end = toIso(floorToStep(new Date(start), new Date(end), cadence))
}
}
return { start, end }
}
14 changes: 5 additions & 9 deletions src/essence/Tools/Layers/LayersTool.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import Sortable from 'sortablejs'
import F_ from '../../Basics/Formulae_/Formulae_'
import L_ from '../../Basics/Layers_/Layers_'
import Map_ from '../../Basics/Map_/Map_'
import { resolveTemporalExtent } from '../../Basics/TimeControl_/layerTimePolicy'
import ServiceUrls from '../../Basics/ServiceUrls/ServiceUrls'

import DataShaders from '../../Ancillary/DataShaders'
Expand Down Expand Up @@ -1849,15 +1850,10 @@ function interfaceWithMMGIS(fromInit) {
const layerUUID = L_.asLayerUUID(layerName)
const layerData = L_.layers.data[layerUUID]

if (
layerData &&
layerData.time &&
layerData.time.dataStartTime &&
layerData.time.dataEndTime
) {
// Convert ISO strings to timestamps
const startTime = new Date(layerData.time.dataStartTime).getTime()
const endTime = new Date(layerData.time.dataEndTime).getTime()
const extent = resolveTemporalExtent(layerData?.time)
if (extent.start != null && extent.end != null) {
const startTime = new Date(extent.start).getTime()
const endTime = new Date(extent.end).getTime()

// Update TimeUI with the layer's data extent
TimeUI.updateTimes(startTime, endTime, endTime)
Expand Down
19 changes: 16 additions & 3 deletions src/essence/Tools/Timeline/TimelineAdapter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
mmgisGetTimeEnd,
mmgisGetTimeCurrent,
type LayerConfig,
mmgisGetTemporalExtents,
} from '../_shared/adapters/mmgisAPI'
import { useMMGISHandlerReady } from '../_shared/adapters/useMMGISHandlerReady'
import {
Expand Down Expand Up @@ -216,9 +217,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

Expand All @@ -234,19 +236,30 @@ export const TimelineAdapter: React.FC = () => {
? 'var(--theme-color-secondary, #c91b6e)'
: 'var(--theme-color-base, #71767a)'

// Core resolves the authored data times (open-ended "now",
// duration offsets, cadence flooring); the resolvers below
// read plain timestamps. A bound core could not read stays
// as written, so it falls back the way it always has.
const extent = extents?.[layerName]
const time = layer.time && {
...layer.time,
dataStartTime: extent?.start ?? layer.time.dataStartTime,
dataEndTime: extent?.end ?? layer.time.dataEndTime,
}

newLayers.push({
name: layerName,
displayName: layer.display_name || layer.name || layerName,
color: color,
timeRanges: resolveLayerTimeRanges(
layer.time,
time,
startTime,
endTime
),
// Same fallback bounds as the ranges above, so a row
// navigates the span it draws.
navigation: resolveLayerNavigation(
layer.time,
time,
startTime,
endTime,
layerName
Expand Down
Loading
Loading