From 8ca7fbe70f58d043633e28c8763c0affab7ad764 Mon Sep 17 00:00:00 2001 From: Sandesh Pandey Date: Tue, 2 Jun 2026 09:12:34 -0500 Subject: [PATCH 1/9] Extract helper functions to a separate file --- src/essence/Basics/Layers_/tileUrlUtils.ts | 187 +++++++++++++++++++ src/essence/Basics/TimeControl_/timeUtils.js | 66 +++++++ 2 files changed, 253 insertions(+) create mode 100644 src/essence/Basics/Layers_/tileUrlUtils.ts create mode 100644 src/essence/Basics/TimeControl_/timeUtils.js diff --git a/src/essence/Basics/Layers_/tileUrlUtils.ts b/src/essence/Basics/Layers_/tileUrlUtils.ts new file mode 100644 index 000000000..2fa325033 --- /dev/null +++ b/src/essence/Basics/Layers_/tileUrlUtils.ts @@ -0,0 +1,187 @@ +/** + * Shared utilities for building Tile URLs. + * Used by both the Leaflet per-tile middleware (getTileUrl) and the DeckGL + * static URL builder (makeTileLayer), so the same params are applied + * regardless of which engine is active. + */ + +/** + * Adds `asset_` prefix to bare band references (b1, B2, etc.) in a TiTiler + * expression string. No-ops if the expression is empty or already prefixed. + * + * @param {string} expression + * @returns {string} + */ +export function processExpression(expression) { + if (!expression || expression.trim() === '') return expression + return expression.replace(/(?): string { + if (!url) return url + const qIdx = url.indexOf('?') + const path = qIdx === -1 ? url : url.slice(0, qIdx) + const params = new URLSearchParams(qIdx === -1 ? '' : url.slice(qIdx + 1)) + + const expression = (layerObj.currentCogExpression || layerObj.cogExpression) as string | undefined + if (expression && expression.trim() !== '') { + params.delete('bidx') + params.set('expression', processExpression(expression)) + } + + if (layerObj.cogTransform === true) { + if (layerObj.cogColormap && !params.has('colormap_name')) + params.set('colormap_name', layerObj.cogColormap as string) + + const cogMin = layerObj.currentCogMin ?? layerObj.cogMin + const cogMax = layerObj.currentCogMax ?? layerObj.cogMax + if (cogMin != null && cogMax != null && !params.has('rescale')) + params.set('rescale', `${cogMin},${cogMax}`) + } + + if (layerObj.cogResampling && !params.has('resampling')) + params.set('resampling', layerObj.cogResampling as string) + + const qs = params.toString() + return qs ? `${path}?${qs}` : path +} + +/** + * Compiles a full tile URL based on time options and STAC parameters. + * Replaces placeholders like {time}, {starttime}, {endtime}, and custom times. + * Injects required query parameters based on layer type. + * + * @param {string} url - The base tile URL with placeholders + * @param {object} options - Layer options/config object containing time and STAC properties + * @returns {string} Fully compiled tile URL + */ +/** + * Format a Date object to a string using standard strftime tokens. + * Supports %Y, %m, %d, %H, %M, %S, %Z + */ +function formatTime(date: Date, format: string): string { + const pad = (n: number) => n.toString().padStart(2, '0') + return format + .replace(/%Y/g, date.getUTCFullYear().toString()) + .replace(/%m/g, pad(date.getUTCMonth() + 1)) + .replace(/%d/g, pad(date.getUTCDate())) + .replace(/%H/g, pad(date.getUTCHours())) + .replace(/%M/g, pad(date.getUTCMinutes())) + .replace(/%S/g, pad(date.getUTCSeconds())) + .replace(/%Z/g, 'Z') +} + +export function compileTileUrl(url: string, options: Record): string { + if (!url) return url + + console.log('compileTileUrl', url, options) + + let nextUrl = url + + // 1. Process STAC/COG datetime parameters + let timeStr = options.time + let startTimeStr = options.starttime + let endTimeStr = options.endtime + + if (options.timeFormat) { + if (timeStr) { + const d = new Date(timeStr) + if (!isNaN(d.getTime())) timeStr = formatTime(d, options.timeFormat) + } + if (startTimeStr) { + const d = new Date(startTimeStr) + if (!isNaN(d.getTime())) startTimeStr = formatTime(d, options.timeFormat) + } + if (endTimeStr) { + const d = new Date(endTimeStr) + if (!isNaN(d.getTime())) endTimeStr = formatTime(d, options.timeFormat) + } + } + + if ( + options.splitColonType === 'stac-collection' || + options.splitColonType === 'COG' || + options.splitColonType === 'titiler-url' + ) { + let datetime + if (endTimeStr != null) { + if (startTimeStr != null) { + datetime = `${startTimeStr}/${endTimeStr}` + } else { + datetime = `../${endTimeStr}` + } + } + if (datetime != null) { + nextUrl += `${nextUrl.indexOf('?') === -1 ? '?' : '&'}datetime=${datetime}` + } + + if (options.splitColonType === 'stac-collection') { + nextUrl += `${nextUrl.indexOf('?') === -1 ? '?' : '&'}exitwhenfull=false&skipcovered=false` + } + + nextUrl = applyCogFieldsToUrl(nextUrl, options) + + // @ts-ignore - mmgisglobal is a global variable injected at runtime + const globalStacOptions = window.mmgisglobal?.options?.stac + + if (globalStacOptions?.mosaicItemLimit != null) { + nextUrl += `${nextUrl.indexOf('?') === -1 ? '?' : '&'}items_limit=${globalStacOptions.mosaicItemLimit}` + } + if (globalStacOptions?.mosaicScanLimit != null) { + nextUrl += `${nextUrl.indexOf('?') === -1 ? '?' : '&'}scan_limit=${globalStacOptions.mosaicScanLimit}` + } + if (globalStacOptions?.mosaicTimeLimit != null) { + nextUrl += `${nextUrl.indexOf('?') === -1 ? '?' : '&'}time_limit=${globalStacOptions.mosaicTimeLimit}` + } + } + + // 2. Perform placeholder replacements + if (timeStr) nextUrl = nextUrl.replace(/{time}/g, timeStr) + if (startTimeStr) nextUrl = nextUrl.replace(/{starttime}/g, startTimeStr) + if (endTimeStr) nextUrl = nextUrl.replace(/{endtime}/g, endTimeStr) + + // 3. Custom time replacements + if (options.customTimes?.times && options.customTimes.times.length > 0) { + for (let i = 0; i < options.customTimes.times.length; i++) { + nextUrl = nextUrl.replace( + new RegExp(`{customtime.${i}}`, 'g'), + options.customTimes.times[i] + ) + } + } + + // 4. TMS specific options + if (timeStr && options.tileFormat === 'tms') { + let paramDelimiter = nextUrl.indexOf('?') === -1 ? '?' : '&' + const urlParams = nextUrl.indexOf('?') !== -1 ? new URLSearchParams(nextUrl.split('?')[1]) : null + + if (!urlParams || !urlParams.has('starttime')) { + nextUrl += `${paramDelimiter}starttime=${startTimeStr}` + paramDelimiter = '&' + } + if (!urlParams || !urlParams.has('time')) { + nextUrl += `${paramDelimiter}time=${endTimeStr}` + paramDelimiter = '&' + } + if ((!urlParams || !urlParams.has('composite')) && options.compositeTile === true) { + nextUrl += `${paramDelimiter}composite=true` + } + } + + console.log('nextUrl', nextUrl) + + return nextUrl +} diff --git a/src/essence/Basics/TimeControl_/timeUtils.js b/src/essence/Basics/TimeControl_/timeUtils.js new file mode 100644 index 000000000..497c40959 --- /dev/null +++ b/src/essence/Basics/TimeControl_/timeUtils.js @@ -0,0 +1,66 @@ +/** + * Time utility functions for TimeControl and TimeUI + * Shared utilities for parsing and manipulating time strings + */ + +/** + * Parses a date string with optional additional seconds offset + * Supports format: "2024-03-04T14:05:00Z + 10000000" or "2024-03-04T14:05:00Z - 5000" + * + * @param {string} dateString - Date string with optional " + N" or " - N" suffix + * @returns {Object} { dateString: string, additionalSeconds: number } + * + * @example + * parseTimeWithOffset("2024-01-01T00:00:00Z + 3600") + * // Returns: { dateString: "2024-01-01T00:00:00Z", additionalSeconds: 3600 } + * + * parseTimeWithOffset("2024-01-01T00:00:00Z - 1800") + * // Returns: { dateString: "2024-01-01T00:00:00Z", additionalSeconds: -1800 } + */ +export function parseTimeWithOffset(d) { + let dateString = d + let opMult = 1 + let additionalSeconds = 0 + if (typeof dateString === 'string') { + const indexPlus = dateString.indexOf(' + ') + const indexMinus = dateString.indexOf(' - ') + if (indexPlus > -1 || indexMinus > -1) { + if (indexMinus > indexPlus) opMult = -1 + const initialendSplit = dateString.split( + ` ${opMult === 1 ? '+' : '-'} ` + ) + dateString = initialendSplit[0] + additionalSeconds = parseInt(initialendSplit[1]) || 0 + additionalSeconds = isNaN(additionalSeconds) + ? 0 + : additionalSeconds + } + additionalSeconds *= opMult + } + return { dateString, additionalSeconds } +} + +/** + * Parses a time string and converts it to seconds + * Supports both "hh:mm:ss" format and plain seconds + * + * @param {string|number} t - Time string in "hh:mm:ss" format or number of seconds + * @returns {number} Total seconds (negative if prefixed with '-') + * + * @example + * parseTimeToSeconds("01:30:00") // Returns: 5400 (1.5 hours) + * parseTimeToSeconds("-02:00:00") // Returns: -7200 (-2 hours) + * parseTimeToSeconds("3600") // Returns: 3600 + * parseTimeToSeconds(3600) // Returns: 3600 + */ +export function parseTimeToSeconds(t) { + if (t.toString().indexOf(':') === -1) { + return parseInt(t) + } + var s = t.split(':') + var seconds = +s[0].replace('-', '') * 60 * 60 + +s[1] * 60 + +s[2] + if (t.charAt(0) === '-') { + seconds = seconds * -1 + } + return seconds +} From 643b6d872024677e854f2cb55852df5393cc09c2 Mon Sep 17 00:00:00 2001 From: Sandesh Pandey Date: Tue, 2 Jun 2026 09:13:50 -0500 Subject: [PATCH 2/9] refactor: replace URL generation logic with compileTileUrl helper function --- .../Layers_/leaflet-tilelayer-middleware.js | 86 +------------------ 1 file changed, 2 insertions(+), 84 deletions(-) diff --git a/src/essence/Basics/Layers_/leaflet-tilelayer-middleware.js b/src/essence/Basics/Layers_/leaflet-tilelayer-middleware.js index 0ec00d7f3..69ab80dae 100644 --- a/src/essence/Basics/Layers_/leaflet-tilelayer-middleware.js +++ b/src/essence/Basics/Layers_/leaflet-tilelayer-middleware.js @@ -12,7 +12,7 @@ */ import F_ from '../../Basics/Formulae_/Formulae_' -import { applyCogFieldsToUrl } from './cogUrlUtils' +import { compileTileUrl } from './tileUrlUtils' var colorFilterExtension = { intialize: function (url, options) { @@ -20,89 +20,7 @@ var colorFilterExtension = { }, getTileUrl: function (coords) { let url = L.TileLayer.prototype.getTileUrl.call(this, coords) - - if ( - this.options.splitColonType === 'stac-collection' || - this.options.splitColonType === 'COG' || - this.options.splitColonType === 'titiler-url' - ) { - let datetime - if (this.options.endtime != null) { - if (this.options.starttime != null) { - datetime = `${this.options.starttime}/${this.options.endtime}` - } else { - datetime = `../${this.options.endtime}` - } - } - if (datetime != null) - url += `${ - url.indexOf('?') === -1 ? '?' : '&' - }datetime=${datetime}` - - if (this.options.splitColonType === 'stac-collection') { - url += `${ - url.indexOf('?') === -1 ? '?' : '&' - }exitwhenfull=false&skipcovered=false` - } - - url = applyCogFieldsToUrl(url, this.options) - - if (mmgisglobal.options?.stac?.mosaicItemLimit != null) { - url += `${url.indexOf('?') === -1 ? '?' : '&'}items_limit=${ - mmgisglobal.options.stac.mosaicItemLimit - }` - } - if (mmgisglobal.options?.stac?.mosaicScanLimit != null) { - url += `${url.indexOf('?') === -1 ? '?' : '&'}scan_limit=${ - mmgisglobal.options.stac.mosaicScanLimit - }` - } - if (mmgisglobal.options?.stac?.mosaicTimeLimit != null) { - url += `${url.indexOf('?') === -1 ? '?' : '&'}time_limit=${ - mmgisglobal.options.stac.mosaicTimeLimit - }` - } - } - - url = url - .replace(/{time}/g, this.options.time) - .replace(/{starttime}/g, this.options.starttime) - .replace(/{endtime}/g, this.options.endtime) - - if ( - this.options.customTimes?.times && - this.options.customTimes?.times.length > 0 - ) { - for (let i = 0; i < this.options.customTimes.times.length; i++) { - url = url.replace( - new RegExp(`{customtime.${i}}`, 'g'), - this.options.customTimes.times[i] - ) - } - } - - if (this.options.time && this.options.tileFormat === 'tms') { - let paramDelimiter = '?' - let urlParams = false - if (url.indexOf('?') !== -1) { - urlParams = new URLSearchParams(url.split('?')[1]) - paramDelimiter = '&' - } - - if (urlParams == false || !urlParams.has('starttime')) { - url += `${paramDelimiter}starttime=${this.options.starttime}` - paramDelimiter = '&' - } - if (urlParams == false || !urlParams.has('time')) { - url += `${paramDelimiter}time=${this.options.endtime}` - paramDelimiter = '&' - } - if (urlParams == false || !urlParams.has('composite')) { - if (this.options.compositeTile === true) - url += `${paramDelimiter}composite=true` - } - } - return url + return compileTileUrl(url, this.options) }, colorFilter: function () { let VALIDFILTERS = [ From 1a1c0730300de5699756f8937e2a7ab5ec532e41 Mon Sep 17 00:00:00 2001 From: Sandesh Pandey Date: Tue, 2 Jun 2026 09:56:02 -0500 Subject: [PATCH 3/9] feat: update tile layer handling with compileTileUrl function that compiles full url replacing time field along with COG options --- src/essence/Basics/Map_/Map_.js | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/src/essence/Basics/Map_/Map_.js b/src/essence/Basics/Map_/Map_.js index b342739c0..8f0b7ad88 100644 --- a/src/essence/Basics/Map_/Map_.js +++ b/src/essence/Basics/Map_/Map_.js @@ -15,7 +15,7 @@ import CursorInfo from '../../Ancillary/CursorInfo' import Description from '../../Ancillary/Description' import QueryURL from '../../Ancillary/QueryURL' import MetadataCapturer from '../Layers_/MetadataCapturer.js' -import { applyCogFieldsToUrl } from '../Layers_/cogUrlUtils' +import { compileTileUrl } from '../Layers_/tileUrlUtils' import { Kinds } from '../../../pre/tools' import DataShaders from '../../Ancillary/DataShaders' import calls from '../../../pre/calls' @@ -1536,9 +1536,22 @@ async function makeTileLayer(layerObj, mapContext = null) { if (Map_.engine && Map_.engine.engineType === MAP_ENGINE.DECKGL) { // DeckGL needs a static URL upfront, so we bake in whatever params Leaflet // would normally add per-tile in getTileUrl. - if (splitColonType === 'COG' || splitColonType === 'stac-collection' || layerObj.cogTransform === true) { - layerUrl = applyCogFieldsToUrl(layerUrl, layerObj) + // Extract time config, defaulting to empty object + const timeConfig = layerObj.time || {}; + + const deckOptions = { + ...layerObj, + splitColonType: splitColonType, + timeEnabled: timeConfig.enabled === true, + time: timeConfig.end ?? '', + compositeTile: timeConfig.compositeTile ?? false, + starttime: timeConfig.start ?? '', + endtime: timeConfig.end ?? '', + customTimes: timeConfig.customTimes ?? '', + tileFormat: layerObj.tileformat || 'tms', + timeFormat: timeConfig.format, } + layerUrl = compileTileUrl(layerUrl, deckOptions) ctx.layerRegistry.layer[layerObj.name] = buildDeckLayer(layerObj.name, { type: layerObj.type || 'tile', @@ -1571,13 +1584,13 @@ async function makeTileLayer(layerObj, mapContext = null) { typeof layerObj.time === 'undefined' ? false : layerObj.time.compositeTile || false, - starttime: - typeof layerObj.time === 'undefined' ? '' : layerObj.time.start, + starttime: typeof layerObj.time === 'undefined' ? '' : layerObj.time.start, endtime: typeof layerObj.time === 'undefined' ? '' : layerObj.time.end, customTimes: typeof layerObj.time === 'undefined' ? null : layerObj.time.customTimes, + timeFormat: layerObj.time?.format, cogTransform: layerObj.cogTransform, cogMin: layerObj.cogMin, currentCogMin: layerObj.currentCogMin, From be389cef69b40a2daf65505ff0f79700127cc466 Mon Sep 17 00:00:00 2001 From: Sandesh Pandey Date: Tue, 2 Jun 2026 10:03:12 -0500 Subject: [PATCH 4/9] feat: add getRawConfigData method to get original Layer configuration --- src/essence/mmgisAPI/mmgisAPI.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/essence/mmgisAPI/mmgisAPI.js b/src/essence/mmgisAPI/mmgisAPI.js index 51d56e4c6..aeb6008c1 100644 --- a/src/essence/mmgisAPI/mmgisAPI.js +++ b/src/essence/mmgisAPI/mmgisAPI.js @@ -357,6 +357,9 @@ var mmgisAPI_ = { return matchedLayers } else return L_.layers.data }, + getRawConfigData: function () { + return L_.configData + }, getLayers: function () { return L_.layers.layer }, @@ -693,6 +696,10 @@ var mmgisAPI = { * @returns {object} - an object containing the visibility state of each layer */ getLayerConfigs: mmgisAPI_.getLayerConfigs, + /** getRawConfigData - returns the original unmutated configuration data + * @returns {object} - the original configData object + */ + getRawConfigData: mmgisAPI_.getRawConfigData, /** getLayers - returns an object with the visibility state of all layers * @returns {object} - an object containing the visibility state of each layer */ From 751cc85e124c888fc2df4970b8a1cdad2553399d Mon Sep 17 00:00:00 2001 From: Sandesh Pandey Date: Tue, 2 Jun 2026 10:06:11 -0500 Subject: [PATCH 5/9] refactor: remove unused cogUrlUtils utility functions --- src/essence/Basics/Layers_/cogUrlUtils.ts | 60 ----------------------- 1 file changed, 60 deletions(-) delete mode 100644 src/essence/Basics/Layers_/cogUrlUtils.ts diff --git a/src/essence/Basics/Layers_/cogUrlUtils.ts b/src/essence/Basics/Layers_/cogUrlUtils.ts deleted file mode 100644 index 7b505f8dc..000000000 --- a/src/essence/Basics/Layers_/cogUrlUtils.ts +++ /dev/null @@ -1,60 +0,0 @@ -/** - * Shared utilities for building COG tile URLs. - * Used by both the Leaflet per-tile middleware (getTileUrl) and the DeckGL - * static URL builder (makeTileLayer), so the same params are applied - * regardless of which engine is active. - */ - -/** - * Adds `asset_` prefix to bare band references (b1, B2, etc.) in a TiTiler - * expression string. No-ops if the expression is empty or already prefixed. - * - * @param {string} expression - * @returns {string} - */ -export function processExpression(expression) { - if (!expression || expression.trim() === '') return expression - return expression.replace(/(?): string { - if (!url) return url - const qIdx = url.indexOf('?') - const path = qIdx === -1 ? url : url.slice(0, qIdx) - const params = new URLSearchParams(qIdx === -1 ? '' : url.slice(qIdx + 1)) - - const expression = (layerObj.currentCogExpression || layerObj.cogExpression) as string | undefined - if (expression && expression.trim() !== '') { - params.delete('bidx') - params.set('expression', processExpression(expression)) - } - - if (layerObj.cogTransform === true) { - if (layerObj.cogColormap && !params.has('colormap_name')) - params.set('colormap_name', layerObj.cogColormap as string) - - const cogMin = layerObj.currentCogMin ?? layerObj.cogMin - const cogMax = layerObj.currentCogMax ?? layerObj.cogMax - if (cogMin != null && cogMax != null && !params.has('rescale')) - params.set('rescale', `${cogMin},${cogMax}`) - } - - if (layerObj.cogResampling && !params.has('resampling')) - params.set('resampling', layerObj.cogResampling as string) - - const qs = params.toString() - return qs ? `${path}?${qs}` : path -} From bf2c4f3d6945fd04ba55277a4db901473ed19bc7 Mon Sep 17 00:00:00 2001 From: Sandesh Pandey Date: Tue, 2 Jun 2026 10:12:09 -0500 Subject: [PATCH 6/9] feat: enhance layer update handling by adding URL support and ensuring proper re-rendering --- src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts b/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts index 961c97b71..1e330e933 100644 --- a/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts +++ b/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts @@ -646,6 +646,7 @@ export class DeckGLAdapter implements IMapEngine { const updated = existing.clone({ ...(options.opacity !== undefined ? { opacity: options.opacity } : {}), ...(options.visible !== undefined ? { visible: options.visible } : {}), + ...( options.url !== undefined ? { data: options.url } : {} ), }) as Layer this._layers.set(id, updated) this._syncLayers() @@ -947,7 +948,10 @@ export class DeckGLAdapter implements IMapEngine { * - Standalone mode: `_deck.setProps({ layers })` — direct deck.gl update. */ private _syncLayers(): void { - const layers = [...this._layers.values()] + // DeckGL expects new layer instances to trigger a proper re-render and re-order. + // Since we manage layers imperatively, we clone them here to ensure DeckGL + // correctly detects changes in the layers array and their drawing order. + const layers = [...this._layers.values()].map(layer => layer.clone({})) if (this._isOverlayMode) { this._overlay?.setProps({ layers }) } else { From 9f26990df46e5b1412bd30404b6b6ef568a040b2 Mon Sep 17 00:00:00 2001 From: Sandesh Pandey Date: Tue, 2 Jun 2026 11:40:17 -0500 Subject: [PATCH 7/9] Enable TimeUI in classic layout --- src/essence/essence.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/essence/essence.js b/src/essence/essence.js index 6f6f60588..2fb6b3241 100644 --- a/src/essence/essence.js +++ b/src/essence/essence.js @@ -41,6 +41,7 @@ import Attributions from './Ancillary/Attributions' //import Swap from './Ancillary/Swap' import QueryURL from './Ancillary/QueryURL' import TimeControl from './Basics/TimeControl_/TimeControl' +import TimeUI from './Basics/TimeControl_/TimeUI' import calls from '../pre/calls' import { mmgisAPI_, mmgisAPI } from './mmgisAPI/mmgisAPI' import { makeErrorScreen } from './LandingPage/LandingPage' @@ -386,6 +387,13 @@ var essence = { //Make the time control TimeControl.init() + // Initialize TimeUI for desktop/legacy mode (not mobile, not modern mode) + const isModernMode = $('#modern-content').length > 0 + if (!UserInterface_.isMobile && !isModernMode && L_.configData.time?.enabled === true) { + TimeUI.initialize() + TimeUI.make() + } + Map_.init(essence.fina) //Now that the map is made @@ -530,6 +538,11 @@ var essence = { Viewer_.fina(Map_) //Finalize the TimeControl TimeControl.fina() + //Finalize the TimeUI (if in legacy mode) + const isModernMode = $('#modern-content').length > 0 + if (!UserInterface_.isMobile && !isModernMode && L_.configData.time?.enabled === true) { + TimeUI.fina() + } // Finalize the mmgisAPI mmgisAPI_.fina(Map_) From 5123ed1bbdc4c7c375c685c38fbbbba11a008046 Mon Sep 17 00:00:00 2001 From: Sandesh Pandey Date: Tue, 2 Jun 2026 16:13:21 -0500 Subject: [PATCH 8/9] Decouple TimeControl and TimeUI and connect them with event-based communication --- .../Basics/TimeControl_/TimeControl.js | 212 ++++++++++++++---- src/essence/Basics/TimeControl_/TimeUI.js | 150 ++++++++----- 2 files changed, 265 insertions(+), 97 deletions(-) diff --git a/src/essence/Basics/TimeControl_/TimeControl.js b/src/essence/Basics/TimeControl_/TimeControl.js index bf9a3007a..c06d9a26a 100644 --- a/src/essence/Basics/TimeControl_/TimeControl.js +++ b/src/essence/Basics/TimeControl_/TimeControl.js @@ -5,9 +5,9 @@ import $ from 'jquery' import F_ from '../Formulae_/Formulae_' import L_ from '../Layers_/Layers_' import Map_ from '../Map_/Map_' -import TimeUI from './TimeUI' - -import './TimeControl.css' +import { parseTimeWithOffset, parseTimeToSeconds } from './timeUtils' +import { compileTileUrl } from '../Layers_/tileUrlUtils' +import ServiceUrls from '../ServiceUrls/ServiceUrls' // Provider cleanup functions for re-initialization let _providerCleanups = [] @@ -28,7 +28,6 @@ var TimeControl = { relativeEndTime: '00:00:00', globalTimeFormat: null, _updateLockedForAcceptingInput: false, - timeUI: null, customTimes: { times: [], }, @@ -39,27 +38,83 @@ var TimeControl = { L_.configData.time.format ) } else { - $('#toggleTimeUI').css({ display: 'none' }) - $('#CoordinatesDiv').css({ marginRight: '0px' }) return } - TimeControl.timeUI = TimeUI.init(timeInputChange, TimeControl.enabled) + let dateAddSec = null + let initialEnd = new Date() + if (L_.FUTURES.endTime != null) { + initialEnd = new Date(L_.FUTURES.endTime) + } else if (L_.configData.time.initialend != null && L_.configData.time.initialend !== 'now') { + dateAddSec = parseTimeWithOffset(L_.configData.time.initialend) + const dateStaged = new Date(dateAddSec.dateString) + if (dateStaged != 'Invalid Date') { + dateStaged.setSeconds(dateStaged.getSeconds() + dateAddSec.additionalSeconds) + initialEnd = dateStaged + } + } + + let initialStart = new Date(initialEnd) + initialStart.setUTCMonth(initialStart.getUTCMonth() - 1) + + if (L_.FUTURES.startTime != null) { + initialStart = new Date(L_.FUTURES.startTime) + } else if (L_.configData.time.initialstart != null) { + dateAddSec = parseTimeWithOffset(L_.configData.time.initialstart) + const dateStaged = new Date(dateAddSec.dateString) + if (dateStaged != 'Invalid Date') { + dateStaged.setSeconds(dateStaged.getSeconds() + dateAddSec.additionalSeconds) + if (dateStaged.getTime() <= initialEnd.getTime()) { + initialStart = dateStaged + } + } + } + + TimeControl.startTime = initialStart.toISOString() + TimeControl.endTime = initialEnd.toISOString() + TimeControl.currentTime = initialEnd.toISOString() + + // Subscribe to user-initiated time changes from UI elements via Event Bus + // and emit controlReady event for UI elements to initialize + if (window.mmgisAPI) { + const cleanup = window.mmgisAPI.on('time:userChanged', ({ startTime, endTime, currentTime }) => { + timeInputChange(startTime, endTime, currentTime) + }) + _providerCleanups.push(cleanup) + + // Notify that TimeControl is ready for UI elements to initialize + window.mmgisAPI.emit('time:controlReady', { + enabled: TimeControl.enabled, + startTime: TimeControl.startTime, + endTime: TimeControl.endTime, + currentTime: TimeControl.currentTime, + timeInputChangeCallback: timeInputChange, + }) + } //updateTime() initLayerTimes() initLayerDataTimes() + + // Ensure layers are updated and reloaded with initial time parameters, + // particularly when TimeUI is missing or skipped. + timeInputChange( + TimeControl.startTime, + TimeControl.endTime, + TimeControl.currentTime + ) }, fina: function () { - if ((TimeControl.enabled = true && TimeControl.timeUI != null)) - TimeControl.timeUI.fina() - // Register time providers for mmgisAPI Event Bus if (window.mmgisAPI) { // Clean up previous providers if re-initializing _providerCleanups.forEach((cleanup) => cleanup()) _providerCleanups = [ + // Re-register the time:userChanged listener that was registered in init() + window.mmgisAPI.on('time:userChanged', ({ startTime, endTime, currentTime }) => { + timeInputChange(startTime, endTime, currentTime) + }), window.mmgisAPI.provide('time:getCurrent', () => TimeControl.getTime()), window.mmgisAPI.provide('time:getStart', () => TimeControl.getStartTime()), window.mmgisAPI.provide('time:getEnd', () => TimeControl.getEndTime()), @@ -111,7 +166,7 @@ var TimeControl = { const now = new Date() let offset = 0 if (relativeTimeFormat.test(timeOffset)) { - offset = parseTime(timeOffset) + offset = parseTimeToSeconds(timeOffset) } else { // assume seconds otherwise offset = parseInt(timeOffset) @@ -128,8 +183,8 @@ var TimeControl = { } if (isRelative == true) { - const start = parseTime(startTime) - const end = parseTime(endTime) + const start = parseTimeToSeconds(startTime) + const end = parseTimeToSeconds(endTime) const startTimeM = new moment(currentTime).subtract( start, 'seconds' @@ -154,11 +209,16 @@ var TimeControl = { .split('.')[0] + 'Z' } - return TimeControl.timeUI.updateTimes( - TimeControl.startTime, - TimeControl.endTime, - TimeControl.currentTime - ) + // Emit event for UI elements to respond, instead of calling it directly + if (window.mmgisAPI) { + window.mmgisAPI.emit('time:setRequested', { + startTime: TimeControl.startTime, + endTime: TimeControl.endTime, + currentTime: TimeControl.currentTime, + }) + } + + return true }, setLayerTime: function (layer, startTime, endTime) { if (typeof layer == 'string') { @@ -176,7 +236,8 @@ var TimeControl = { layer.time.end ) - if (layer.type == 'tile') { + const isTileLayer = layer.type && (layer.type.toLowerCase() === 'tile' || layer.type.toLowerCase() === 'tilelayer') + if (isTileLayer) { TimeControl.setLayerWmsParams(layer) } } @@ -238,13 +299,65 @@ var TimeControl = { let changedUrl = null if (layer.url !== originalUrl) changedUrl = layer.url - if (layer.type === 'tile') { + // Check for both 'tile' and 'TileLayer' (case-insensitive comparison) + const isTileLayer = layer.type && (layer.type.toLowerCase() === 'tile' || layer.type.toLowerCase() === 'tilelayer') + + if (isTileLayer) { if (layer.time && layer.time.enabled === true) { TimeControl.setLayerWmsParams(layer) } if (evenIfControlled === true || layer.controlled !== true) { - if (L_.layers.on[layer.name] || evenIfOff) { - L_.layers.layer[layer.name].refresh(changedUrl) + const tileLayer = L_.layers.layer[layer.name] + // Check if layer is "on" in tracking OR if layer object exists (which means it's actually on the map) + const isLayerVisible = L_.layers.on[layer.name] || evenIfOff || (tileLayer && tileLayer !== false && tileLayer !== null) + if (isLayerVisible) { + const timeConfig = layer.time || {}; + const deckOptions = { + ...layer, + timeEnabled: timeConfig.enabled === true, + time: timeConfig.end ?? '', + compositeTile: timeConfig.compositeTile ?? false, + starttime: timeConfig.start ?? '', + endtime: timeConfig.end ?? '', + customTimes: timeConfig.customTimes ?? '', + tileFormat: layer.tileformat || 'tms', + timeFormat: timeConfig.format, + } + + // Build the new resolved URL (e.g. for COG or STAC) + let resolvedUrl = changedUrl != null && L_.getUrl ? L_.getUrl(layer.type, changedUrl, layer) : (changedUrl || layer.url) + const splitColonLayerUrl = (changedUrl || layer.url || '').split(':') + let splitColonType = undefined + if (splitColonLayerUrl[1] != null && ['stac-collection', 'COG', 'titiler-url'].includes(splitColonLayerUrl[0])) { + splitColonType = splitColonLayerUrl[0] + deckOptions.splitColonType = splitColonType + if (splitColonType === 'stac-collection') { + resolvedUrl = L_.transformStacUrl(resolvedUrl, layer, 'tile') + } else if (splitColonType === 'COG') { + resolvedUrl = ServiceUrls.buildTiTilerCogTilesUrl(resolvedUrl, layer, { + tileMatrixSet: layer.tileMatrixSet, + bands: (!layer.cogExpression || layer.cogExpression.trim() === '') ? layer.cogBands : null, + resampling: layer.cogResampling + }) + } else if (splitColonType === 'titiler-url') { + resolvedUrl = splitColonLayerUrl.slice(1).join(':') + if (!F_.isUrlAbsolute(resolvedUrl)) { + resolvedUrl = L_.missionPath + resolvedUrl + } + } + } + + // TODO: Refactor this to push URL compilation and refreshing into the map engine adapters + // so that TimeControl.js doesn't need to check Map_.engine.engineType === 'deckgl' + // or rely on tileLayer.refresh(). + if (tileLayer && typeof tileLayer.refresh === 'function') { + // Pass deckOptions to refresh so it updates this.options with new COG fields + tileLayer.refresh(resolvedUrl, true, deckOptions) + } else if (Map_.engine?.engineType === 'deckgl') { + const newUrl = compileTileUrl(resolvedUrl, deckOptions) + Map_.engine.updateLayer(layer.name, { url: newUrl }) + return true + } } } } else if (layer.type == 'velocity') { @@ -458,7 +571,7 @@ var TimeControl = { if ( layer.time && layer.time.enabled === true && - layer.variables?.dynamicExtent != true + layer.variables?.dynamicExtent !== true ) { TimeControl.reloadLayer(layer) reloadedLayers.push(layer.name) @@ -515,12 +628,11 @@ var TimeControl = { }, 500) } - // Pan to followed feature after layers reload - if (TimeUI.followEnabled && TimeUI.followedFeature) { - // Add a small delay to ensure layers have finished loading - setTimeout(() => { - TimeUI.panToFollowedFeature() - }, 500) + // Emit event for TimeUI to handle follow feature + if (window.mmgisAPI) { + window.mmgisAPI.emit('time:layersReloaded', { + reloadedLayers: reloadedLayers, + }) } return reloadedLayers @@ -540,7 +652,8 @@ var TimeControl = { layer.time.end ) updatedLayers.push(layer.name) - if (layer.type === 'tile') { + const isTileLayer = layer.type && (layer.type.toLowerCase() === 'tile' || layer.type.toLowerCase() === 'tilelayer') + if (isTileLayer) { TimeControl.setLayerWmsParams(layer) } } @@ -582,11 +695,20 @@ var TimeControl = { ? utcFormat('%Y-%m-%dT%H:%M:%SZ') : utcFormat(layer.time.format) const l = L_.layers.layer[layer.name] + const isTileLayer = layer.type && (layer.type.toLowerCase() === 'tile' || layer.type.toLowerCase() === 'tilelayer') + + if (l != null && isTileLayer) { + const formattedTime = layerTimeFormat(Date.parse(layer.time.end)) + const formattedStart = layerTimeFormat(Date.parse(layer.time.start)) - if (l != null && layer.type === 'tile') { - l.options.time = layerTimeFormat(Date.parse(layer.time.end)) - l.options.starttime = layerTimeFormat(Date.parse(layer.time.start)) - l.options.endtime = layerTimeFormat(Date.parse(layer.time.end)) + // Ensure options object exists (needed for middleware-based layers) + if (!l.options) { + l.options = {} + } + + l.options.time = formattedTime + l.options.starttime = formattedStart + l.options.endtime = formattedTime } }, } @@ -636,6 +758,16 @@ function timeInputChange(startTime, endTime, currentTime, skipUpdate) { TimeControl.currentTime = currentTime == null ? endTime : currentTime TimeControl.endTime = endTime + // Emit event for external listeners via Event Bus + if (window.mmgisAPI) { + window.mmgisAPI.emit('time:change', { + startTime: TimeControl.startTime, + endTime: TimeControl.endTime, + currentTime: TimeControl.currentTime, + }) + } + + // Keep existing subscriptions for backward compatibility if (L_?._timeChangeSubscriptions) Object.keys(L_._timeChangeSubscriptions).forEach((k) => { L_._timeChangeSubscriptions[k]({ startTime, currentTime, endTime }) @@ -656,16 +788,4 @@ function timeInputChange(startTime, endTime, currentTime, skipUpdate) { } } -function parseTime(t) { - if (t.toString().indexOf(':') == -1) { - return parseInt(t) - } - var s = t.split(':') - var seconds = +s[0].replace('-', '') * 60 * 60 + +s[1] * 60 + +s[2] - if (t.charAt(0) === '-') { - seconds = seconds * -1 - } - return seconds -} - export default TimeControl diff --git a/src/essence/Basics/TimeControl_/TimeUI.js b/src/essence/Basics/TimeControl_/TimeUI.js index 28b0f2b71..447334088 100644 --- a/src/essence/Basics/TimeControl_/TimeUI.js +++ b/src/essence/Basics/TimeControl_/TimeUI.js @@ -12,6 +12,7 @@ import L_ from '../Layers_/Layers_' import calls from '../../../pre/calls' import tippy from 'tippy.js' import Dropy from '../../../external/Dropy/dropy' +import { parseTimeWithOffset } from './timeUtils' import { TempusDominus, Namespace } from '@eonasdan/tempus-dominus' import '@eonasdan/tempus-dominus/dist/css/tempus-dominus.css' @@ -46,6 +47,12 @@ const TimeUI = { this.MMWebGISInterface = new interfaceWithMMWebGIS() }, destroy: function () { + // Clean up event subscriptions + if (TimeUI._cleanups) { + TimeUI._cleanups.forEach((cleanup) => cleanup()) + TimeUI._cleanups = [] + } + this.MMWebGISInterface.separateFromMMWebGIS() }, startTempus: null, @@ -88,7 +95,41 @@ const TimeUI = { init: function (timeChange, enabled) { TimeUI.timeChange = timeChange TimeUI.enabled = enabled + TimeUI._cleanups = [] // Track cleanup functions for event listeners + + // Subscribe to TimeControl events via Event Bus + if (window.mmgisAPI) { + TimeUI._cleanups.push( + // Listen for TimeControl ready event (new event-based initialization) + window.mmgisAPI.on('time:controlReady', (data) => { + // Use callback from event if provided (new pattern) + if (data.timeInputChangeCallback) { + TimeUI.timeChange = data.timeInputChangeCallback + } + // Update enabled state from TimeControl + if (data.enabled != null) { + TimeUI.enabled = data.enabled + } + }), + window.mmgisAPI.on('time:setRequested', (data) => { + TimeUI.updateTimes(data.startTime, data.endTime, data.currentTime) + }), + // Listen for time layer reloads to handle follow feature + window.mmgisAPI.on('time:layersReloaded', (data) => { + TimeUI._handleFollowAfterReload(data.reloadedLayers) + }) + ) + } + // Check if we're in modern mode - skip UI initialization if so + const isModernMode = $('#modern-content').length > 0 + if (isModernMode) { + // In modern mode, TimeUI doesn't render - modern timeline tool handles it + // But event listeners are still active for the API + return TimeUI + } + + // Legacy mode: Initialize TimeUI as normal // prettier-ignore let markup = [ `
`, @@ -323,28 +364,6 @@ const TimeUI = { return TimeUI }, getElement: function () {}, - getDateAdditionalSeconds: function (d) { - let dateString = d - let opMult = 1 - let additionalSeconds = 0 - if (typeof dateString === 'string') { - const indexPlus = dateString.indexOf(' + ') - const indexMinus = dateString.indexOf(' - ') - if (indexPlus > -1 || indexMinus > -1) { - if (indexMinus > indexPlus) opMult = -1 - const initialendSplit = dateString.split( - ` ${opMult === 1 ? '+' : '-'} ` - ) - dateString = initialendSplit[0] - additionalSeconds = parseInt(initialendSplit[1]) || 0 - additionalSeconds = isNaN(additionalSeconds) - ? 0 - : additionalSeconds - } - additionalSeconds *= opMult - } - return { dateString, additionalSeconds } - }, alignPopovers(e) { if (L_.UserInterface_?.isMobile === true) { return @@ -382,6 +401,12 @@ const TimeUI = { } }, attachEvents: function (timeChange) { + const startElm = document.getElementById('mmgisTimeUIStart') + if (!startElm) { + console.warn("TimeUI: No time element provided. Skipping UI initialization."); + return; + } + TimeUI._startingModeIndex = TimeUI.modeIndex // Set modeIndex to 1/Point if a deeplink had an endtime but no starttime if (L_.FUTURES.startTime == null && L_.FUTURES.endTime != null) @@ -736,7 +761,7 @@ const TimeUI = { promptTimeOnDateChangeTransitionDelay: 200, } - const startElm = document.getElementById('mmgisTimeUIStart') + // startElm is already defined at the top TimeUI.startTempus = new TempusDominus(startElm, options) TimeUI.startTempus.dates.formatInput = function (date) { return moment(date).format(FORMAT) @@ -999,7 +1024,7 @@ const TimeUI = { } // parse formats like "2024-03-04T14:05:00Z + 10000000" for relative times - dateAddSec = TimeUI.getDateAdditionalSeconds( + dateAddSec = parseTimeWithOffset( L_.configData.time.initialend ) if ( @@ -1023,7 +1048,7 @@ const TimeUI = { L_.configData.time.initialwindowend != 'now' ) { // parse formats like "2024-03-04T14:05:00Z + 10000000" for relative times - dateAddSec = TimeUI.getDateAdditionalSeconds( + dateAddSec = parseTimeWithOffset( L_.configData.time.initialwindowend ) const dateStaged = new Date(dateAddSec.dateString) @@ -1053,7 +1078,7 @@ const TimeUI = { ) else { // parse formats like "2024-03-04T14:05:00Z + 10000000" for relative times - dateAddSec = TimeUI.getDateAdditionalSeconds( + dateAddSec = parseTimeWithOffset( L_.configData.time.initialstart ) @@ -1083,7 +1108,7 @@ const TimeUI = { // Initial Timeline window start if (L_.configData.time.initialwindowstart != null) { // parse formats like "2024-03-04T14:05:00Z + 10000000" for relative times - dateAddSec = TimeUI.getDateAdditionalSeconds( + dateAddSec = parseTimeWithOffset( L_.configData.time.initialwindowstart ) @@ -1136,11 +1161,13 @@ const TimeUI = { date = new Date(TimeUI._initialEnd) const savedEndDate = new Date(date) - const offsetEndDate = TimeUI.addOffset(date.getTime()) - const parsedEnd = TimeUI.endTempus.dates.parseInput( - new Date(offsetEndDate) - ) - TimeUI.endTempus.dates.setValue(parsedEnd) + if (TimeUI.endTempus) { + const offsetEndDate = TimeUI.addOffset(date.getTime()) + const parsedEnd = TimeUI.endTempus.dates.parseInput( + new Date(offsetEndDate) + ) + TimeUI.endTempus.dates.setValue(parsedEnd) + } // Initial start // Start 1 month ago @@ -1163,10 +1190,12 @@ const TimeUI = { date = new Date(TimeUI._initialStart) const offsetStartDate = TimeUI.addOffset(date.getTime()) - const parsedStart = TimeUI.startTempus.dates.parseInput( - new Date(offsetStartDate) - ) - TimeUI.startTempus.dates.setValue(parsedStart) + if (TimeUI.startTempus) { + const parsedStart = TimeUI.startTempus.dates.parseInput( + new Date(offsetStartDate) + ) + TimeUI.startTempus.dates.setValue(parsedStart) + } $('#mmgisTimeUIPlay').on('click', TimeUI.togglePlay) $('#mmgisTimeUIBottomPrevious').on('click', TimeUI.stepPrevious) @@ -2610,6 +2639,15 @@ const TimeUI = { // Feature not found - it may have gone out of time range } }, + _handleFollowAfterReload: function (reloadedLayers) { + // Handle follow feature after time-enabled layers reload + if (TimeUI.followEnabled && TimeUI.followedFeature) { + // Add a small delay to ensure layers have finished loading + setTimeout(() => { + TimeUI.panToFollowedFeature() + }, 500) + } + }, _restoreFollowFromDeeplink: function () { // Check if we have an active point from the deeplink if (!L_.FUTURES.activePoint) { @@ -3177,25 +3215,35 @@ const TimeUI = { }, change() { if ( - typeof TimeUI.timeChange === 'function' && TimeUI._startTimestamp != null && TimeUI._endTimestamp != null ) { const mode = TimeUI.modes[TimeUI.modeIndex] - TimeUI.timeChange( - new Date( - mode === 'Range' - ? TimeUI.removeOffset(TimeUI._startTimestamp) - : 0 - ).toISOString(), - // use currentTime as endTime if in playmode - TimeUI.play === true - ? new Date(TimeUI.getCurrentTimestamp(true)).toISOString() - : new Date( - TimeUI.removeOffset(TimeUI._endTimestamp) - ).toISOString(), - new Date(TimeUI.getCurrentTimestamp(true)).toISOString() - ) + const startTime = new Date( + mode === 'Range' + ? TimeUI.removeOffset(TimeUI._startTimestamp) + : 0 + ).toISOString() + const endTime = TimeUI.play === true + ? new Date(TimeUI.getCurrentTimestamp(true)).toISOString() + : new Date( + TimeUI.removeOffset(TimeUI._endTimestamp) + ).toISOString() + const currentTime = new Date(TimeUI.getCurrentTimestamp(true)).toISOString() + + // Emit event via Event Bus for TimeControl to respond + if (window.mmgisAPI) { + window.mmgisAPI.emit('time:userChanged', { + startTime, + endTime, + currentTime, + }) + } + + // Keep callback for backward compatibility + if (typeof TimeUI.timeChange === 'function') { + TimeUI.timeChange(startTime, endTime, currentTime) + } } // Update expanded rows if expanded if (TimeUI.expanded) { From d7a00174ad080a41d342aba7b6d858a660ad78b7 Mon Sep 17 00:00:00 2001 From: Sandesh Pandey Date: Tue, 2 Jun 2026 21:25:07 -0500 Subject: [PATCH 9/9] Remove debug logging from compileTileUrl --- src/essence/Basics/Layers_/tileUrlUtils.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/essence/Basics/Layers_/tileUrlUtils.ts b/src/essence/Basics/Layers_/tileUrlUtils.ts index 2fa325033..a2a26c418 100644 --- a/src/essence/Basics/Layers_/tileUrlUtils.ts +++ b/src/essence/Basics/Layers_/tileUrlUtils.ts @@ -87,8 +87,6 @@ function formatTime(date: Date, format: string): string { export function compileTileUrl(url: string, options: Record): string { if (!url) return url - console.log('compileTileUrl', url, options) - let nextUrl = url // 1. Process STAC/COG datetime parameters @@ -181,7 +179,5 @@ export function compileTileUrl(url: string, options: Record): strin } } - console.log('nextUrl', nextUrl) - return nextUrl }