From 8163469623c806b1a0531fc2800637bdf529545f Mon Sep 17 00:00:00 2001 From: Sandesh Pandey Date: Fri, 31 Jul 2026 10:07:46 -0500 Subject: [PATCH 1/6] Add Timeline plugin shell with component placeholders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Base of the Timeline stack: the plugin entry point, config, MMGIS API adapter, shared types and time utilities, the FloatingPopover primitive, and the adapter that owns timeline state and wires the header, content and info popover together. The five UI pieces — timeline view, date selector, playback controls, playback speed and time mode — ship as placeholders that hold their prop contracts and layout slots. Each is implemented in its own follow-up PR in this stack. Also adds the horizon theme, exports the primary-lighter/lightest colour tokens, and points the layer manager hover tint at the new token. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01AgqjkZhQvfoBgdJtt9SSKS --- configure/src/metaconfigs/tab-ui-config.json | 2 +- .../APIs/JavaScript/Main/Event-Bus-API.md | 6 +- .../UserInterface_/UserInterfaceModern_.css | 1 + .../components-geo/colormap-control.scss | 2 +- .../components-geo/gradient-graphic.scss | 2 +- .../styles/components-geo/layer-legend.scss | 2 +- src/essence/Tools/Timeline/Timeline.css | 216 +++++++++ .../Tools/Timeline/TimelineAdapter.tsx | 438 ++++++++++++++++++ src/essence/Tools/Timeline/TimelineTool.tsx | 81 ++++ .../Tools/Timeline/adapters/mmgisAPI.ts | 61 +++ src/essence/Tools/Timeline/config.json | 50 ++ .../lib/FloatingPopover/FloatingPopover.tsx | 140 ++++++ .../Timeline/lib/FloatingPopover/index.ts | 1 + .../lib/geo/DateSelector/DateSelector.tsx | 26 ++ .../lib/geo/LayerTimeline/LayerTimeline.tsx | 18 + .../geo/PlaybackControls/PlaybackControls.tsx | 21 + .../PlaybackSpeedControl.tsx | 26 ++ .../geo/TimeModeControl/TimeModeControl.tsx | 18 + .../lib/geo/TimelineView/TimelineView.tsx | 24 + src/essence/Tools/Timeline/lib/index.ts | 11 + src/essence/Tools/Timeline/lib/types.ts | 16 + .../Tools/Timeline/lib/utils/timeUtils.ts | 132 ++++++ src/styles/_theme-export.scss | 7 + src/styles/disasters/theme-tokens.scss | 2 +- src/styles/horizon/index.scss | 11 + src/styles/horizon/theme-tokens.scss | 149 ++++++ 26 files changed, 1455 insertions(+), 8 deletions(-) create mode 100644 src/essence/Tools/Timeline/Timeline.css create mode 100644 src/essence/Tools/Timeline/TimelineAdapter.tsx create mode 100644 src/essence/Tools/Timeline/TimelineTool.tsx create mode 100644 src/essence/Tools/Timeline/adapters/mmgisAPI.ts create mode 100644 src/essence/Tools/Timeline/config.json create mode 100644 src/essence/Tools/Timeline/lib/FloatingPopover/FloatingPopover.tsx create mode 100644 src/essence/Tools/Timeline/lib/FloatingPopover/index.ts create mode 100644 src/essence/Tools/Timeline/lib/geo/DateSelector/DateSelector.tsx create mode 100644 src/essence/Tools/Timeline/lib/geo/LayerTimeline/LayerTimeline.tsx create mode 100644 src/essence/Tools/Timeline/lib/geo/PlaybackControls/PlaybackControls.tsx create mode 100644 src/essence/Tools/Timeline/lib/geo/PlaybackSpeedControl/PlaybackSpeedControl.tsx create mode 100644 src/essence/Tools/Timeline/lib/geo/TimeModeControl/TimeModeControl.tsx create mode 100644 src/essence/Tools/Timeline/lib/geo/TimelineView/TimelineView.tsx create mode 100644 src/essence/Tools/Timeline/lib/index.ts create mode 100644 src/essence/Tools/Timeline/lib/types.ts create mode 100644 src/essence/Tools/Timeline/lib/utils/timeUtils.ts create mode 100644 src/styles/horizon/index.scss create mode 100644 src/styles/horizon/theme-tokens.scss diff --git a/configure/src/metaconfigs/tab-ui-config.json b/configure/src/metaconfigs/tab-ui-config.json index 0a95c60ba..1c3065dc5 100644 --- a/configure/src/metaconfigs/tab-ui-config.json +++ b/configure/src/metaconfigs/tab-ui-config.json @@ -23,7 +23,7 @@ "name": "Theme", "description": "The USWDS theme to apply to the modern interface.", "type": "dropdown", - "options": ["default", "disasters", "earthgov"], + "options": ["default", "disasters", "earthgov", "horizon"], "default": "default", "width": 6 } diff --git a/docs/pages/APIs/JavaScript/Main/Event-Bus-API.md b/docs/pages/APIs/JavaScript/Main/Event-Bus-API.md index 48ba06499..b99145c56 100644 --- a/docs/pages/APIs/JavaScript/Main/Event-Bus-API.md +++ b/docs/pages/APIs/JavaScript/Main/Event-Bus-API.md @@ -299,11 +299,11 @@ window.mmgisAPI.on('feature:active', ({ layerName, feature }) => { | Event | Payload | Description | |-------|---------|-------------| -| `time:change` | `{ startTime, currentTime, endTime }` | Fired when time range changes | +| `time:changed` | `{ startTime, currentTime, endTime }` | Fired after a time change is committed, carrying the new committed state. Pairs with `time:changeRequested` | | `time:toggleUI` | `{ active }` | Fired when time UI is shown/hidden | ```javascript -window.mmgisAPI.on('time:change', ({ startTime, currentTime, endTime }) => { +window.mmgisAPI.on('time:changed', ({ startTime, currentTime, endTime }) => { console.log(`Time range: ${startTime} - ${endTime}`) }) ``` @@ -469,7 +469,7 @@ const MyPlugin = { init() { this._unsubscribers.push( window.mmgisAPI.on('layer:visibilityChange', this.handleLayerChange), - window.mmgisAPI.on('time:change', this.handleTimeChange) + window.mmgisAPI.on('time:changed', this.handleTimeChange) ) }, diff --git a/src/essence/Basics/UserInterface_/UserInterfaceModern_.css b/src/essence/Basics/UserInterface_/UserInterfaceModern_.css index 46c2a201c..699ea44b3 100644 --- a/src/essence/Basics/UserInterface_/UserInterfaceModern_.css +++ b/src/essence/Basics/UserInterface_/UserInterfaceModern_.css @@ -126,6 +126,7 @@ /* Tool Cards - Base Styles (shared by stacked and tabbed) */ .ui-tool-card { background-color: var(--theme-color-white, #ffffff); + width: 100%; } /* Stacked Tool Cards (default clickable cards in panel body) */ diff --git a/src/essence/Tools/LayerManager/lib/styles/components-geo/colormap-control.scss b/src/essence/Tools/LayerManager/lib/styles/components-geo/colormap-control.scss index 3bd22f0d5..fd32362c8 100644 --- a/src/essence/Tools/LayerManager/lib/styles/components-geo/colormap-control.scss +++ b/src/essence/Tools/LayerManager/lib/styles/components-geo/colormap-control.scss @@ -61,7 +61,7 @@ &:hover { border-color: var(--theme-color-base-dark, #565c65); color: var(--theme-color-base-darker, #3d4551); - background: var(--theme-color-base-light, #a9aeb1); + background: var(--theme-color-primary-lightest, #e6f4f6); } &--active { diff --git a/src/essence/Tools/LayerManager/lib/styles/components-geo/gradient-graphic.scss b/src/essence/Tools/LayerManager/lib/styles/components-geo/gradient-graphic.scss index 46be7f0de..f6a4634cb 100644 --- a/src/essence/Tools/LayerManager/lib/styles/components-geo/gradient-graphic.scss +++ b/src/essence/Tools/LayerManager/lib/styles/components-geo/gradient-graphic.scss @@ -45,7 +45,7 @@ transition: background-color 0.15s ease, color 0.15s ease; &:hover { - background: var(--theme-color-base-lighter, #dfe1e2); + background: var(--theme-color-primary-lightest, #e6f4f6); color: var(--theme-color-base-darker, #3d4551); } diff --git a/src/essence/Tools/LayerManager/lib/styles/components-geo/layer-legend.scss b/src/essence/Tools/LayerManager/lib/styles/components-geo/layer-legend.scss index 77950c3fe..92cfea01d 100644 --- a/src/essence/Tools/LayerManager/lib/styles/components-geo/layer-legend.scss +++ b/src/essence/Tools/LayerManager/lib/styles/components-geo/layer-legend.scss @@ -119,7 +119,7 @@ $legend-content-indent: 28px; &:hover { background: var(--theme-color-primary-lightest, #e6f4f6); - color: var(--theme-color-primary, #0e7482); + color: var(--theme-color-base-darker, #3d4551); } &--active { diff --git a/src/essence/Tools/Timeline/Timeline.css b/src/essence/Tools/Timeline/Timeline.css new file mode 100644 index 000000000..644dff722 --- /dev/null +++ b/src/essence/Tools/Timeline/Timeline.css @@ -0,0 +1,216 @@ +.timeline { + display: flex; + flex-direction: column; + height: 100%; + width: 100%; + background: var(--theme-color-white, #ffffff); + color: var(--theme-color-primary-dark, #0d313d); + font-family: var(--theme-font-ui, 'Public Sans', system-ui, -apple-system, sans-serif); + overflow: hidden; +} + +/* Collapsed: only the header remains, and the tool shrinks to fit it so the + host panel isn't left holding a blank timeline body. */ +.timeline--collapsed { + height: auto; +} + +.timeline--collapsed .timeline-content { + display: none; +} + +.timeline--collapsed .timeline-header { + border-bottom: none; +} + +.timeline-collapse-btn svg { + display: block; + transition: transform 0.2s ease; +} + +.timeline--collapsed .timeline-collapse-btn svg { + transform: rotate(180deg); +} + + +.timeline-loading { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + width: 100%; +} + +.timeline-loading .loading-message { + color: var(--theme-color-base, #71767a); + font-size: 14px; +} + +.timeline-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: .25rem .75rem; + border-bottom: 1px solid var(--theme-color-base-lighter, #dfe1e2); + background: var(--theme-color-white, #ffffff); + flex-shrink: 0; +} + +.timeline-header-left, +.timeline-header-center, +.timeline-header-right { + display: flex; + align-items: center; +} + +.timeline-header-center { + flex: 1; + justify-content: center; +} + +.timeline-header-right { + gap: 12px; +} + +.timeline-content { + flex: 1; + overflow: hidden; + position: relative; + display: flex; + flex-direction: column; + padding-bottom: 8px; +} + +/* Empty state shown when no layers are on the timeline */ +.timeline-empty { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + padding: 16px; + text-align: center; +} + +.timeline-empty-message { + font-size: 13px; + font-weight: 700; + color: var(--theme-color-base-darker, #3d4551); +} + +.timeline-empty-hint { + font-size: 12px; + color: var(--theme-color-base, #71767a); +} + +/* Timeline Toolbar */ +.timeline-toolbar { + display: flex; + align-items: center; + padding-left: 8px; + border-left: 1px solid var(--theme-color-base-lighter, #dfe1e2); +} + +.timeline-tool-btn { + background: transparent; + border: none; + padding: 4px; + cursor: pointer; + color: var(--theme-color-primary, #0e7482); + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + height: 24px; + width: 24px; +} + +.timeline-tool-btn:hover:not(:disabled) { + background: var(--theme-color-primary-lightest, #e6f4f6); + opacity: 0.8; +} + +.timeline-tool-btn:disabled { + opacity: 0.3; + cursor: not-allowed; +} + +/* Info Tooltip Portal */ +.timeline-info-tooltip-portal { + background: var(--theme-color-primary-dark, #0d313d); + border: none; + border-radius: 8px; + box-shadow: 0 4px 16px var(--theme-color-shadow, rgba(0, 0, 0, 0.15)); + padding: 12px 16px; + width: 280px; + z-index: 999999; + animation: tooltipFadeIn 0.15s ease; + pointer-events: none; +} + +@keyframes tooltipFadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +/* Tooltip arrow */ +.timeline-info-tooltip-arrow { + position: absolute; + top: 100%; + right: 12px; +} + +.timeline-info-tooltip-arrow::after { + content: ''; + position: absolute; + width: 0; + height: 0; + border-left: 8px solid transparent; + border-right: 8px solid transparent; + border-top: 8px solid var(--theme-color-primary-dark, #0d313d); +} + +.timeline-info-tooltip-arrow::before { + display: none; +} + +.timeline-info-tooltip-content { + color: var(--theme-color-base-lighter, #dfe1e2); +} + +.timeline-info-tooltip-content strong { + display: block; + font-size: 13px; + font-weight: 700; + color: var(--theme-color-white, #ffffff); + margin-bottom: 8px; +} + +.timeline-info-tooltip-content p { + margin: 0; + font-size: 12px; + line-height: 1.5; + color: var(--theme-color-base-light, #a9aeb1); +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .timeline-header { + flex-direction: column; + gap: 16px; + align-items: stretch; + } + + .timeline-header-center { + justify-content: center; + } + + .timeline-sidebar { + width: 100px; + } +} diff --git a/src/essence/Tools/Timeline/TimelineAdapter.tsx b/src/essence/Tools/Timeline/TimelineAdapter.tsx new file mode 100644 index 000000000..3483b2e6d --- /dev/null +++ b/src/essence/Tools/Timeline/TimelineAdapter.tsx @@ -0,0 +1,438 @@ +import React, { useState, useEffect, useCallback, useRef } from 'react' +import moment from 'moment' +import { FloatingPopover } from './lib/FloatingPopover' +import { mmgisRequest, mmgisOn, mmgisEmit, mmgisGetLayerConfigs, mmgisGetRawConfigData, mmgisGetVisibleLayers } from './adapters/mmgisAPI' +import { + TimelineView, + TimeModeControl, + DateSelector, + PlaybackControls, + PlaybackSpeedControl, + getNextPlaybackSpeed, + TIME_MODE_ORDER, + type TimeMode, + type LayerTimeData +} from './lib' +import { getTimeStep } from './lib/utils/timeUtils' +import './Timeline.css' + +interface TimeData { + startTime: string + endTime: string + currentTime: string +} + +export const TimelineAdapter: React.FC = () => { + const [startTime, setStartTime] = useState(() => { + const d = new Date() + d.setDate(d.getDate() - 30) + return d + }) + const [endTime, setEndTime] = useState(new Date()) + const [currentTime, setCurrentTime] = useState(() => { + const d = new Date() + d.setDate(d.getDate() - 15) + return d + }) + const [timeMode, setTimeMode] = useState('DAY') + const [shownTimeModes, setShownTimeModes] = useState(TIME_MODE_ORDER) + const [layers, setLayers] = useState([]) + const [isReady, setIsReady] = useState(false) + + const [isPlaying, setIsPlaying] = useState(false) + const [playbackSpeed, setPlaybackSpeed] = useState(1) + const [allowPlayback, setAllowPlayback] = useState(true) + const [layerVisibilityVersion, setLayerVisibilityVersion] = useState(0) + const [showInfoPopup, setShowInfoPopup] = useState(false) + // Collapsed hides the layer list / scrubber area, leaving just the header + const [isCollapsed, setIsCollapsed] = useState(false) + const [resetZoomFn, setResetZoomFn] = useState<(() => void) | null>(null) + const infoButtonRef = useRef(null) + + const handleResetZoomReady = useCallback((fn: () => void) => { + setResetZoomFn(() => fn) + }, []) + + // Fetch tool variables (e.g. allowPlayback) from the mission config + useEffect(() => { + let cancelled = false + const fetchVars = async () => { + try { + const vars = await mmgisRequest<{ + allowPlayback?: boolean + defaultTimeMode?: string + shownTimeModes?: string[] + }>('tool:getVars', 'timeline') + if (cancelled || !vars) return + + if (typeof vars.allowPlayback === 'boolean') { + setAllowPlayback(vars.allowPlayback) + } + + // Determine which mode buttons to show (canonical order, empty = all) + let effectiveModes = TIME_MODE_ORDER + if (Array.isArray(vars.shownTimeModes)) { + const requested = vars.shownTimeModes.map((m) => String(m).toUpperCase()) + const normalized = TIME_MODE_ORDER.filter((m) => requested.includes(m)) + if (normalized.length > 0) effectiveModes = normalized + } + setShownTimeModes(effectiveModes) + + // Determine the initial mode: configured default (if valid), + // otherwise the prior 'DAY' fallback; then clamp to a shown mode. + const requestedDefault = vars.defaultTimeMode?.toUpperCase() + let mode: TimeMode = + requestedDefault === 'YEAR' || + requestedDefault === 'MONTH' || + requestedDefault === 'DAY' || + requestedDefault === 'HOUR' + ? (requestedDefault as TimeMode) + : 'DAY' + if (!effectiveModes.includes(mode)) mode = effectiveModes[0] + setTimeMode(mode) + } catch (err) { + console.warn('[Timeline] Failed to fetch tool vars:', err) + } + } + fetchVars() + return () => { + cancelled = true + } + }, []) + + // Stop playback if it becomes disabled via config + useEffect(() => { + if (!allowPlayback && isPlaying) setIsPlaying(false) + }, [allowPlayback, isPlaying]) + + // Subscribe to layer visibility changes + useEffect(() => { + const cleanup = mmgisOn('layer:visibilityChange', () => { + console.log('[Timeline] Layer visibility changed, refetching layers') + setLayerVisibilityVersion(v => v + 1) + }) + return cleanup + }, []) + useEffect(() => { + const fetchLayers = () => { + const configs = mmgisGetLayerConfigs() + const rawConfig = mmgisGetRawConfigData() + const visibleLayers = mmgisGetVisibleLayers() + const rawLayers = rawConfig?.layers || [] + const newLayers: LayerTimeData[] = [] + + const findRawLayer = (layersArr: any[], name: string): any => { + for (let l of layersArr) { + if (l.name === name) return l + if (l.sublayers) { + const sub = findRawLayer(l.sublayers, name) + if (sub) return sub + } + } + return null + } + + Object.keys(configs).forEach(layerName => { + const layer = configs[layerName] + + // Filter to only visible layers + if (!visibleLayers?.[layerName]) { + return + } + + let start = startTime + let end = endTime + let color = 'var(--theme-color-base, #71767a)' // default grey + + if (layer.time && layer.time.enabled) { + let rawLayer = null + if (rawLayers.length > 0) { + rawLayer = findRawLayer(rawLayers, layerName) + } + const timeConfig = layer.time + + if (timeConfig.dataStartTime) { + const parsedStart = new Date(timeConfig.dataStartTime) + if (!isNaN(parsedStart.getTime())) { + start = parsedStart + } + } + if (timeConfig.dataEndTime) { + const parsedEnd = timeConfig.dataEndTime === 'now' ? new Date() : new Date(timeConfig.dataEndTime) + if (!isNaN(parsedEnd.getTime())) { + end = parsedEnd + } + } + + // Time-enabled layers stand out in the theme's secondary colour + color = 'var(--theme-color-secondary, #c91b6e)' + } + + newLayers.push({ + name: layerName, + displayName: layer.display_name || layer.name || layerName, + color: color, + timeRanges: [ + { start, end } + ] + }) + }) + + setLayers(newLayers) + } + + fetchLayers() + }, [startTime, endTime, layerVisibilityVersion]) + + // Fetch initial time data from TimeControl + useEffect(() => { + const fetchInitialTimeData = async () => { + try { + console.log('[Timeline] Fetching initial time data from TimeControl...') + const start = await mmgisRequest('time:getStart') + const end = await mmgisRequest('time:getEnd') + const current = await mmgisRequest('time:getCurrent') + + console.log('[Timeline] Received initial time data:', { start, end, current }) + + if (start && end && current) { + setStartTime(new Date(start)) + setEndTime(new Date(end)) + setCurrentTime(new Date(current)) + } + } catch (err) { + console.error('[Timeline] Failed to fetch initial time data:', err) + } finally { + setIsReady(true) + } + } + + fetchInitialTimeData() + + // Subscribe to time changes + const cleanup = mmgisOn('time:change', (payload: any) => { + console.log('[Timeline] Received time:change event:', payload) + if (payload?.startTime) setStartTime(new Date(payload.startTime)) + if (payload?.endTime) setEndTime(new Date(payload.endTime)) + if (payload?.currentTime) setCurrentTime(new Date(payload.currentTime)) + }) + + return cleanup + }, []) + + + // Handle current time change from scrubber + const handleCurrentTimeChange = useCallback( + (newTime: Date) => { + console.log('[Timeline] User changed current time:', newTime.toISOString()) + setCurrentTime(newTime) + + // Emit time:changeRequested event for TimeControl to respond + const payload = { + startTime: startTime.toISOString(), + endTime: endTime.toISOString(), + currentTime: newTime.toISOString(), + } + console.log('[Timeline] Emitting time:changeRequested:', payload) + mmgisEmit('time:changeRequested', payload) + }, + [startTime, endTime] + ) + + // Live time while the scrubber is dragged: the header date follows along, + // but nothing is emitted until the drag is released. + const handleCurrentTimePreview = useCallback((newTime: Date) => { + setCurrentTime(newTime) + }, []) + + // Handle current date change + const handleCurrentDateChange = useCallback( + (newCurrent: Date) => { + setCurrentTime(newCurrent) + + // Emit time:changeRequested event for TimeControl to respond + mmgisEmit('time:changeRequested', { + startTime: startTime.toISOString(), + endTime: endTime.toISOString(), + currentTime: newCurrent.toISOString(), + }) + }, + [startTime, endTime] + ) + + // Playback logic + const handleStepForward = useCallback(() => { + const { unit, value } = getTimeStep(timeMode) + const nextTime = moment(currentTime).add(value, unit as moment.unitOfTime.DurationConstructor).toDate() + if (nextTime <= endTime) { + handleCurrentTimeChange(nextTime) + } + }, [currentTime, endTime, timeMode, handleCurrentTimeChange]) + + const handleStepBackward = useCallback(() => { + const { unit, value } = getTimeStep(timeMode) + const prevTime = moment(currentTime).subtract(value, unit as moment.unitOfTime.DurationConstructor).toDate() + if (prevTime >= startTime) { + handleCurrentTimeChange(prevTime) + } + }, [currentTime, startTime, timeMode, handleCurrentTimeChange]) + + const handleGoToStart = useCallback(() => { + handleCurrentTimeChange(startTime) + }, [startTime, handleCurrentTimeChange]) + + const handleGoToEnd = useCallback(() => { + handleCurrentTimeChange(endTime) + }, [endTime, handleCurrentTimeChange]) + + useEffect(() => { + if (!isPlaying) return + + const { unit, value } = getTimeStep(timeMode) + // Base cadence is one step per second, divided by the playback speed + // multiplier (2x -> 500ms, 4x -> 250ms, 0.5x -> 2000ms). + const speed = 1000 / playbackSpeed + + const interval = setInterval(() => { + setCurrentTime(prev => { + const nextTime = moment(prev).add(value, unit as moment.unitOfTime.DurationConstructor).toDate() + if (nextTime <= endTime) { + // Emit time:changeRequested event to notify TimeControl + const payload = { + startTime: startTime.toISOString(), + endTime: endTime.toISOString(), + currentTime: nextTime.toISOString(), + } + console.log('[Timeline] Playback emitting time:changeRequested:', payload) + mmgisEmit('time:changeRequested', payload) + return nextTime + } else { + console.log('[Timeline] Playback reached end, stopping') + setIsPlaying(false) + return prev + } + }) + }, speed) + + return () => clearInterval(interval) + }, [isPlaying, timeMode, endTime, startTime, playbackSpeed]) + + if (!isReady) { + return ( +
+
Loading timeline...
+
+ ) + } + + return ( +
+
+
+ +
+
+ setIsPlaying(!isPlaying)} + onStepForward={handleStepForward} + onStepBackward={handleStepBackward} + onGoToStart={handleGoToStart} + onGoToEnd={handleGoToEnd} + /> + {allowPlayback && ( + setPlaybackSpeed((s) => getNextPlaybackSpeed(s))} + /> + )} +
+
+ +
+ + + +
+
+
+
+ {layers.length === 0 ? ( +
+
+ No visible layers on the map +
+
+ Enable the visibility of one or more map layers to display them here. +
+
+ ) : ( + + )} +
+ setShowInfoPopup(false)} + placement="top" + offset={8} + className="timeline-info-tooltip-portal" + > +
+ Timeline Controls +

Scroll to zoom • Drag scrubber to change time • Click to jump

+
+
+
+ ) +} + + diff --git a/src/essence/Tools/Timeline/TimelineTool.tsx b/src/essence/Tools/Timeline/TimelineTool.tsx new file mode 100644 index 000000000..5c1c17fa1 --- /dev/null +++ b/src/essence/Tools/Timeline/TimelineTool.tsx @@ -0,0 +1,81 @@ +import React from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { TimelineAdapter } from './TimelineAdapter' +import { mmgisRequest } from './adapters/mmgisAPI' + +type ToolVars = { + width?: number + height?: number +} + +let _root: Root | null = null + +const TimelineTool = { + height: 200, + width: 'full' as number | 'full', + vars: {} as ToolVars, + targetId: null as string | null, + made: false, + _cleanups: [] as Array<() => void>, + + initialize: async function () { + try { + this.vars = + (await mmgisRequest( + 'tool:getVars', + 'timeline', + )) || {} + if (this.vars.width) this.width = this.vars.width + if (this.vars.height) this.height = this.vars.height + } catch (err) { + console.warn( + '[TimelineTool] tool:getVars unavailable:', + err instanceof Error ? err.message : err, + ) + } + + try { + const isMobile = await mmgisRequest('app:isMobile') + if (isMobile) { + this.width = 'full' + this.height = 300 + } + } catch (err) { + console.warn( + '[TimelineTool] app:isMobile unavailable:', + err instanceof Error ? err.message : err, + ) + } + }, + + make: function (targetId?: string) { + this.targetId = typeof targetId === 'string' ? targetId : 'toolPanel' + const container = document.getElementById(this.targetId) + if (!container) { + console.error( + `TimelineTool: container ${this.targetId} not found`, + ) + return + } + _root = createRoot(container) + _root.render() + this.made = true + }, + + destroy: function () { + if (_root) { + _root.unmount() + _root = null + } + this._cleanups.forEach((cleanup) => cleanup()) + this._cleanups = [] + this.targetId = null + this.made = false + }, + + getUrlString: function () { + return '' + }, +} + +export default TimelineTool diff --git a/src/essence/Tools/Timeline/adapters/mmgisAPI.ts b/src/essence/Tools/Timeline/adapters/mmgisAPI.ts new file mode 100644 index 000000000..2676f160f --- /dev/null +++ b/src/essence/Tools/Timeline/adapters/mmgisAPI.ts @@ -0,0 +1,61 @@ +type EventCleanup = () => void + +type MMGISAPI = { + request: (name: string, params?: unknown) => Promise + on: (event: string, handler: (payload?: unknown) => void) => EventCleanup + emit: (event: string, payload?: unknown) => void + provide?: (name: string, handler: (...args: unknown[]) => unknown) => EventCleanup + hasHandler?: (name: string) => boolean + getLayerConfigs?: () => any + getRawConfigData?: () => any + getVisibleLayers?: () => Record +} + +declare global { + interface Window { + mmgisAPI?: MMGISAPI + } +} + +export const mmgisRequest = async (name: string, params?: unknown): Promise => { + if (window.mmgisAPI?.request) { + return (await window.mmgisAPI.request(name, params)) as T + } + return null +} + +export const mmgisOn = (event: string, handler: (payload?: unknown) => void): EventCleanup => { + if (!window.mmgisAPI?.on) return () => {} + return window.mmgisAPI.on(event, handler) +} + +export const mmgisEmit = (event: string, payload?: unknown): void => { + console.log('[mmgisAPI] mmgisEmit called:', event, 'window.mmgisAPI exists:', !!window.mmgisAPI, 'emit exists:', !!window.mmgisAPI?.emit) + if (window.mmgisAPI?.emit) { + window.mmgisAPI.emit(event, payload) + console.log('[mmgisAPI] Emitted event:', event) + } else { + console.warn('[mmgisAPI] Cannot emit - mmgisAPI or emit not available') + } +} + +export const mmgisProvide = (name: string, handler: (...args: unknown[]) => unknown): EventCleanup => { + if (!window.mmgisAPI?.provide) return () => {} + return window.mmgisAPI.provide(name, handler) +} + +export const mmgisHasHandler = (name: string): boolean => { + return window.mmgisAPI?.hasHandler?.(name) === true +} + +export const mmgisGetLayerConfigs = (): any => { + return window.mmgisAPI?.getLayerConfigs?.() || {} +} + +export const mmgisGetRawConfigData = (): any => { + return window.mmgisAPI?.getRawConfigData?.() || {} +} + +export const mmgisGetVisibleLayers = (): Record => { + return window.mmgisAPI?.getVisibleLayers?.() || {} +} diff --git a/src/essence/Tools/Timeline/config.json b/src/essence/Tools/Timeline/config.json new file mode 100644 index 000000000..34d217fd4 --- /dev/null +++ b/src/essence/Tools/Timeline/config.json @@ -0,0 +1,50 @@ +{ + "name": "Timeline", + "description": "Interactive timeline visualization for navigating temporal data with zoom and layer visibility controls", + "defaultIcon": "timeline", + "hasVars": true, + "paths": { + "TimelineTool": "essence/Tools/Timeline/TimelineTool" + }, + "metadata": { + "icon": "timeline", + "requiredOrientation": "horizontal", + "compatiblePositions": ["bottom"], + "preferredPosition": "bottom", + "modernLayoutSupport": true, + "minHeight": 150, + "recommendedHeight": 200 + }, + "config": { + "rows": [ + { + "components": [ + { + "field": "variables.allowPlayback", + "name": "Allow Playback", + "description": "When enabled, the timeline shows the play/pause button and the playback speed selector. When disabled, those two controls are removed; the step and skip-to-start/end buttons remain.", + "type": "checkbox", + "width": 6, + "defaultChecked": true + }, + { + "field": "variables.shownTimeModes", + "name": "Shown Time Modes", + "description": "Which time-granularity buttons appear on the timeline: YEAR, MONTH, DAY, HOUR. Leave empty to show all of them.", + "type": "multiselect", + "width": 6, + "options": ["YEAR", "MONTH", "DAY", "HOUR"] + }, + { + "field": "variables.defaultTimeMode", + "name": "Default Time Mode", + "description": "The time granularity the timeline uses when it first loads. Must be one of the shown time modes; users can still switch between them at runtime.", + "type": "dropdown", + "width": 6, + "options": ["DAY", "MONTH", "HOUR", "YEAR"] + } + ] + } + ] + } +} diff --git a/src/essence/Tools/Timeline/lib/FloatingPopover/FloatingPopover.tsx b/src/essence/Tools/Timeline/lib/FloatingPopover/FloatingPopover.tsx new file mode 100644 index 000000000..457d9631f --- /dev/null +++ b/src/essence/Tools/Timeline/lib/FloatingPopover/FloatingPopover.tsx @@ -0,0 +1,140 @@ +import React, { useLayoutEffect, useState, useRef, useEffect } from 'react' +import { createPortal } from 'react-dom' + +export interface FloatingPopoverProps { + anchorRef: React.RefObject + isOpen: boolean + onClose?: () => void + placement?: 'top' | 'bottom' | 'left' | 'right' + offset?: number + className?: string + children: React.ReactNode +} + +export const FloatingPopover: React.FC = ({ + anchorRef, + isOpen, + onClose, + placement = 'bottom', + offset = 8, + className = '', + children +}) => { + const popupRef = useRef(null) + const [pos, setPos] = useState({ top: 0, left: 0 }) + + // Close on outside click + useEffect(() => { + if (!isOpen || !onClose) return + + const handleClickOutside = (e: MouseEvent) => { + const target = e.target as HTMLElement + // If the anchor exists and the click is inside it, ignore (the button toggle will handle it) + const clickedInsideAnchor = anchorRef.current && anchorRef.current.contains(target) + // If the click is inside the popup itself, ignore + const clickedInsidePopup = popupRef.current && popupRef.current.contains(target) + + if (!clickedInsideAnchor && !clickedInsidePopup) { + onClose() + } + } + + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + }, [isOpen, onClose, anchorRef]) + + // Update position + useLayoutEffect(() => { + if (!isOpen) return + + const updatePosition = () => { + if (!anchorRef.current || !popupRef.current) return + + const anchorRect = anchorRef.current.getBoundingClientRect() + const popupRect = popupRef.current.getBoundingClientRect() + + let top = 0 + let left = 0 + + switch (placement) { + case 'top': + top = anchorRect.top - popupRect.height - offset + left = anchorRect.left + (anchorRect.width / 2) - (popupRect.width / 2) + break + case 'bottom': + top = anchorRect.bottom + offset + left = anchorRect.left + (anchorRect.width / 2) - (popupRect.width / 2) + break + case 'left': + top = anchorRect.top + (anchorRect.height / 2) - (popupRect.height / 2) + left = anchorRect.left - popupRect.width - offset + break + case 'right': + top = anchorRect.top + (anchorRect.height / 2) - (popupRect.height / 2) + left = anchorRect.right + offset + break + } + + // Simple viewport bounds checking + if (left < 8) left = 8 + if (top < 8) { + if (placement === 'top') { + top = anchorRect.bottom + offset + } else { + top = 8 + } + } + if (left + popupRect.width > window.innerWidth - 8) { + left = window.innerWidth - popupRect.width - 8 + } + if (top + popupRect.height > window.innerHeight - 8) { + if (placement === 'bottom') { + top = anchorRect.top - popupRect.height - offset + } else { + top = window.innerHeight - popupRect.height - 8 + } + } + + // Prevent React state updates if the position hasn't changed to avoid infinite loops + setPos(prev => { + if (Math.abs(prev.top - top) < 1 && Math.abs(prev.left - left) < 1) { + return prev + } + return { top, left } + }) + } + + updatePosition() + window.addEventListener('resize', updatePosition) + window.addEventListener('scroll', updatePosition, true) + + // Wait a tick and update again in case children render changed dimensions + const timeout = setTimeout(updatePosition, 0) + + return () => { + clearTimeout(timeout) + window.removeEventListener('resize', updatePosition) + window.removeEventListener('scroll', updatePosition, true) + } + }, [isOpen, placement, offset, anchorRef]) + + if (!isOpen) return null + + return createPortal( + , + document.body + ) +} diff --git a/src/essence/Tools/Timeline/lib/FloatingPopover/index.ts b/src/essence/Tools/Timeline/lib/FloatingPopover/index.ts new file mode 100644 index 000000000..f0c557299 --- /dev/null +++ b/src/essence/Tools/Timeline/lib/FloatingPopover/index.ts @@ -0,0 +1 @@ +export * from './FloatingPopover' diff --git a/src/essence/Tools/Timeline/lib/geo/DateSelector/DateSelector.tsx b/src/essence/Tools/Timeline/lib/geo/DateSelector/DateSelector.tsx new file mode 100644 index 000000000..932ba4780 --- /dev/null +++ b/src/essence/Tools/Timeline/lib/geo/DateSelector/DateSelector.tsx @@ -0,0 +1,26 @@ +import React from 'react' +import moment from 'moment' +import { TimeMode } from '../../types' + +export interface DateSelectorProps { + selectedDate: Date + startTime: Date + endTime: Date + timeMode?: TimeMode + onDateChange: (date: Date) => void +} + +/** + * Placeholder for the date selector. Shows the selected date as static text; + * the picker dropdown, month grid and day calendar arrive with the date + * selector PR of this stack. + */ +export const DateSelector: React.FC = ({ selectedDate }) => { + return ( +
+ + {moment(selectedDate).format('MMM D, YYYY')} + +
+ ) +} diff --git a/src/essence/Tools/Timeline/lib/geo/LayerTimeline/LayerTimeline.tsx b/src/essence/Tools/Timeline/lib/geo/LayerTimeline/LayerTimeline.tsx new file mode 100644 index 000000000..18ea02a85 --- /dev/null +++ b/src/essence/Tools/Timeline/lib/geo/LayerTimeline/LayerTimeline.tsx @@ -0,0 +1,18 @@ +import React from 'react' +import type { ScaleTime } from 'd3-scale' +import type { LayerTimeData } from '../../types' + +export interface LayerTimelineProps { + layer: LayerTimeData + xScale: ScaleTime + y: number + height: number +} + +/** + * Placeholder for a single layer's row of time-range bars. Renders an empty + * group; the bars arrive with the timeline view PR of this stack. + */ +export const LayerTimeline: React.FC = () => { + return +} diff --git a/src/essence/Tools/Timeline/lib/geo/PlaybackControls/PlaybackControls.tsx b/src/essence/Tools/Timeline/lib/geo/PlaybackControls/PlaybackControls.tsx new file mode 100644 index 000000000..21c15f9f0 --- /dev/null +++ b/src/essence/Tools/Timeline/lib/geo/PlaybackControls/PlaybackControls.tsx @@ -0,0 +1,21 @@ +import React from 'react' + +export interface PlaybackControlsProps { + onPlayToggle?: () => void + onStepForward?: () => void + onStepBackward?: () => void + onGoToStart?: () => void + onGoToEnd?: () => void + isPlaying?: boolean + /** When false, the play/pause button is hidden (step and skip buttons remain). */ + showPlayButton?: boolean +} + +/** + * Placeholder for the transport controls. Holds the slot and the prop contract + * the adapter already wires up; the play, step and skip buttons arrive with the + * playback controls PR of this stack. + */ +export const PlaybackControls: React.FC = () => { + return
+} diff --git a/src/essence/Tools/Timeline/lib/geo/PlaybackSpeedControl/PlaybackSpeedControl.tsx b/src/essence/Tools/Timeline/lib/geo/PlaybackSpeedControl/PlaybackSpeedControl.tsx new file mode 100644 index 000000000..892367c76 --- /dev/null +++ b/src/essence/Tools/Timeline/lib/geo/PlaybackSpeedControl/PlaybackSpeedControl.tsx @@ -0,0 +1,26 @@ +import React from 'react' + +// Ordered cycle of playback speed multipliers. +export const PLAYBACK_SPEEDS = [1, 2, 4, 0.5] as const + +export type PlaybackSpeed = (typeof PLAYBACK_SPEEDS)[number] + +/** Returns the next speed in the 1 → 2 → 4 → 0.5 → 1 cycle. */ +export const getNextPlaybackSpeed = (speed: number): PlaybackSpeed => { + const idx = PLAYBACK_SPEEDS.indexOf(speed as PlaybackSpeed) + const nextIdx = (idx + 1) % PLAYBACK_SPEEDS.length + return PLAYBACK_SPEEDS[nextIdx] +} + +export interface PlaybackSpeedControlProps { + speed: number + onCycleSpeed?: () => void +} + +/** + * Placeholder for the speed multiplier button. Renders nothing; the button + * arrives with the playback controls PR of this stack. + */ +export const PlaybackSpeedControl: React.FC = () => { + return null +} diff --git a/src/essence/Tools/Timeline/lib/geo/TimeModeControl/TimeModeControl.tsx b/src/essence/Tools/Timeline/lib/geo/TimeModeControl/TimeModeControl.tsx new file mode 100644 index 000000000..67b5fee54 --- /dev/null +++ b/src/essence/Tools/Timeline/lib/geo/TimeModeControl/TimeModeControl.tsx @@ -0,0 +1,18 @@ +import React from 'react' +import { type TimeMode } from '../../types' + +export interface TimeModeControlProps { + currentMode: TimeMode + onModeChange: (mode: TimeMode) => void + /** Which modes to show, in display order. Defaults to all modes. */ + modes?: TimeMode[] +} + +/** + * Placeholder for the granularity switcher. Renders an empty slot; the + * YEAR/MONTH/DAY/HOUR buttons arrive with the time mode control PR of this + * stack. + */ +export const TimeModeControl: React.FC = () => { + return
+} diff --git a/src/essence/Tools/Timeline/lib/geo/TimelineView/TimelineView.tsx b/src/essence/Tools/Timeline/lib/geo/TimelineView/TimelineView.tsx new file mode 100644 index 000000000..ba24aff28 --- /dev/null +++ b/src/essence/Tools/Timeline/lib/geo/TimelineView/TimelineView.tsx @@ -0,0 +1,24 @@ +import React from 'react' +import type { TimeMode, LayerTimeData } from '../../types' + +export interface TimelineViewProps { + startTime: Date + endTime: Date + currentTime: Date + timeMode: TimeMode + layers: LayerTimeData[] + /** Committed time change — fires once the scrubber is released. */ + onCurrentTimeChange: (time: Date) => void + /** Live time while the scrubber is being dragged, for display only. */ + onCurrentTimePreview?: (time: Date) => void + onResetZoomReady?: (resetZoomFn: () => void) => void +} + +/** + * Placeholder for the timeline body. Holds the slot the adapter renders into + * and the prop contract the real view implements; the d3 axes, layer bars and + * scrubber arrive with the timeline view PR of this stack. + */ +export const TimelineView: React.FC = () => { + return
+} diff --git a/src/essence/Tools/Timeline/lib/index.ts b/src/essence/Tools/Timeline/lib/index.ts new file mode 100644 index 000000000..692f71180 --- /dev/null +++ b/src/essence/Tools/Timeline/lib/index.ts @@ -0,0 +1,11 @@ +// Components +export { DateSelector, type DateSelectorProps } from './geo/DateSelector/DateSelector' +export { LayerTimeline, type LayerTimelineProps } from './geo/LayerTimeline/LayerTimeline' +export { PlaybackControls, type PlaybackControlsProps } from './geo/PlaybackControls/PlaybackControls' +export { PlaybackSpeedControl, getNextPlaybackSpeed, PLAYBACK_SPEEDS, type PlaybackSpeedControlProps, type PlaybackSpeed } from './geo/PlaybackSpeedControl/PlaybackSpeedControl' +export { TimeModeControl, type TimeModeControlProps } from './geo/TimeModeControl/TimeModeControl' +export { TimelineView, type TimelineViewProps } from './geo/TimelineView/TimelineView' + +// Shared domain types +export { TIME_MODE_ORDER } from './types' +export type { TimeMode, TimeRange, LayerTimeData } from './types' diff --git a/src/essence/Tools/Timeline/lib/types.ts b/src/essence/Tools/Timeline/lib/types.ts new file mode 100644 index 000000000..97b4fc491 --- /dev/null +++ b/src/essence/Tools/Timeline/lib/types.ts @@ -0,0 +1,16 @@ +export type TimeMode = 'YEAR' | 'MONTH' | 'DAY' | 'HOUR' + +// Canonical display order, largest granularity first. +export const TIME_MODE_ORDER: TimeMode[] = ['YEAR', 'MONTH', 'DAY', 'HOUR'] + +export interface TimeRange { + start: Date + end: Date +} + +export interface LayerTimeData { + name: string + displayName: string + timeRanges: TimeRange[] + color: string +} diff --git a/src/essence/Tools/Timeline/lib/utils/timeUtils.ts b/src/essence/Tools/Timeline/lib/utils/timeUtils.ts new file mode 100644 index 000000000..a7028d906 --- /dev/null +++ b/src/essence/Tools/Timeline/lib/utils/timeUtils.ts @@ -0,0 +1,132 @@ +import moment from 'moment' +import type { TimeMode } from '../types' + +/** + * Calculate the appropriate time step based on the time mode + */ +export function getTimeStep(mode: TimeMode): { + unit: moment.unitOfTime.DurationConstructor + value: number +} { + switch (mode) { + case 'YEAR': + return { unit: 'years', value: 1 } + case 'MONTH': + return { unit: 'months', value: 1 } + case 'DAY': + return { unit: 'days', value: 1 } + case 'HOUR': + return { unit: 'hours', value: 1 } + } +} + +/** + * Generate time ticks for the timeline axis + */ +export function generateTimeTicks( + startTime: Date, + endTime: Date, + mode: TimeMode, + maxTicks: number = 100 +): Date[] { + const ticks: Date[] = [] + const { unit, value } = getTimeStep(mode) + + let current = moment(startTime).startOf(unit as moment.unitOfTime.StartOf) + const end = moment(endTime) + + const totalSteps = end.diff(current, unit as moment.unitOfTime.Diff) / value + let stepMultiplier = 1 + if (totalSteps > maxTicks) { + stepMultiplier = Math.ceil(totalSteps / maxTicks) + + // Make the multiplier "nice" (e.g. multiples of 2, 5, 10) + if (unit === 'minutes' || unit === 'seconds') { + if (stepMultiplier <= 2) stepMultiplier = 2 + else if (stepMultiplier <= 5) stepMultiplier = 5 + else if (stepMultiplier <= 10) stepMultiplier = 10 + else if (stepMultiplier <= 15) stepMultiplier = 15 + else if (stepMultiplier <= 30) stepMultiplier = 30 + else stepMultiplier = Math.ceil(stepMultiplier / 60) * 60 + } else if (unit === 'hours') { + if (stepMultiplier <= 2) stepMultiplier = 2 + else if (stepMultiplier <= 3) stepMultiplier = 3 + else if (stepMultiplier <= 6) stepMultiplier = 6 + else if (stepMultiplier <= 12) stepMultiplier = 12 + else stepMultiplier = Math.ceil(stepMultiplier / 24) * 24 + } else if (unit === 'days') { + if (stepMultiplier <= 2) stepMultiplier = 2 + else if (stepMultiplier <= 7) stepMultiplier = 7 + else if (stepMultiplier <= 14) stepMultiplier = 14 + else stepMultiplier = Math.ceil(stepMultiplier / 30) * 30 + } + } + + let count = 0 + while (current.isBefore(end) && count < maxTicks) { + ticks.push(current.toDate()) + current = current.add(value * stepMultiplier, unit as moment.unitOfTime.DurationConstructor) + count++ + } + + // Always add the end tick + if (ticks.length === 0 || ticks[ticks.length - 1].getTime() !== endTime.getTime()) { + ticks.push(endTime) + } + + return ticks +} + +/** + * Format date based on time mode + */ +export function formatDateByMode(date: Date, mode: TimeMode): string { + const m = moment(date) + switch (mode) { + case 'YEAR': + return m.format('YYYY') + case 'MONTH': + return m.format('MMM YYYY') + case 'DAY': + return m.format('MMM D') + case 'HOUR': + return m.format('HH:mm') + } +} + +/** + * Calculate zoom extents for the timeline + */ +export function calculateZoomExtent( + totalDuration: number, + mode: TimeMode +): [number, number] { + // Minimum zoom shows at least 2 units + // Maximum zoom shows the entire range + const minUnits = 2 + const maxUnits = Math.ceil(totalDuration / getMillisecondsPerUnit(mode)) + + return [1, Math.max(maxUnits / minUnits, 1)] +} + +function getMillisecondsPerUnit(mode: TimeMode): number { + switch (mode) { + case 'YEAR': + return 365 * 24 * 60 * 60 * 1000 // Approximate + case 'MONTH': + return 30 * 24 * 60 * 60 * 1000 // Approximate + case 'DAY': + return 24 * 60 * 60 * 1000 + case 'HOUR': + return 60 * 60 * 1000 + } +} + +/** + * Clamp a date to be within a range + */ +export function clampDate(date: Date, min: Date, max: Date): Date { + if (date < min) return min + if (date > max) return max + return date +} diff --git a/src/styles/_theme-export.scss b/src/styles/_theme-export.scss index 9aef520c8..2668b7cdb 100644 --- a/src/styles/_theme-export.scss +++ b/src/styles/_theme-export.scss @@ -15,6 +15,13 @@ --theme-color-primary-dark: #{color("primary-dark")}; --theme-color-primary-darker: #{color("primary-darker")}; --theme-color-primary-light: #{color("primary-light")}; + --theme-color-primary-lighter: #{color("primary-lighter")}; + /* Tinted surface used for hover states across the UI. USWDS leaves the + lightest grade off by default, so only export it where a brand sets it; + components carry a literal fallback for the brands that don't. */ + @if $theme-color-primary-lightest { + --theme-color-primary-lightest: #{color("primary-lightest")}; + } /* USWDS leaves the outer primary grades off unless a theme opts in, and color() errors on a grade set to false — so only emit what exists. */ diff --git a/src/styles/disasters/theme-tokens.scss b/src/styles/disasters/theme-tokens.scss index ca3f0dabd..32bb37e77 100644 --- a/src/styles/disasters/theme-tokens.scss +++ b/src/styles/disasters/theme-tokens.scss @@ -19,7 +19,7 @@ $theme-color-base-ink: "gray-cool-90", $theme-color-primary-family: "blue-cool", - $theme-color-primary-lightest: #e7f1f3, + $theme-color-primary-lightest: #e6f4f6, $theme-color-primary-lighter: #c3dce0, $theme-color-primary-light: #87bac1, $theme-color-primary: #0e7482, diff --git a/src/styles/horizon/index.scss b/src/styles/horizon/index.scss new file mode 100644 index 000000000..a9e20553b --- /dev/null +++ b/src/styles/horizon/index.scss @@ -0,0 +1,11 @@ +// Horizon theme manifest + +// 1. Forward theme token configuration first (must be before uswds-core/@forward) +@forward "theme-tokens"; + +// 2. Then forward USWDS itself +@forward "uswds"; + +// 3. Export theme tokens as CSS custom properties +@use "../theme-export"; +@include theme-export.export-theme-tokens(); diff --git a/src/styles/horizon/theme-tokens.scss b/src/styles/horizon/theme-tokens.scss new file mode 100644 index 000000000..fee18afe1 --- /dev/null +++ b/src/styles/horizon/theme-tokens.scss @@ -0,0 +1,149 @@ +// Horizon USWDS theme configuration +// Values come from the "VEDA - USWDS" mode of the Figma Theme tokens collection, +// which aliases the stock USWDS system palette (blue-warm primary, gray-cool base) +// rather than a mission-specific ramp. +@use "uswds-core" with ( + // GENERAL SETTINGS + // https://designsystem.digital.gov/documentation/settings/#general-settings-2 + $theme-show-notifications: false, + $theme-show-compile-warnings: false, + $theme-image-path: "./img", + + // COLOR SETTINGS + // https: //designsystem.digital.gov/documentation/settings/#color-settings-2 + // Token color/base: lightest gray-cool-4, lighter gray-cool-10, dark gray-cool-60 + $theme-color-base-family: "gray-cool", + $theme-color-base-lightest: "gray-cool-4", + $theme-color-base-lighter: "gray-cool-10", + $theme-color-base-light: "gray-cool-30", + $theme-color-base: "gray-cool-50", + $theme-color-base-dark: "gray-cool-60", + $theme-color-base-darker: "gray-cool-70", + $theme-color-base-darkest: "gray-cool-80", + $theme-color-base-ink: "gray-cool-90", + + // Token color/primary: vivid blue-warm-60v (#0050d8), dark blue-warm-70v (#1a4480). + // The token set's vivid grade is the brand primary, so it fills both slots. The + // lighter grades round out the blue-warm ramp; lightest has no USWDS default, so + // blue-warm-5 supplies the tinted hover surface components expect. + $theme-color-primary-family: "blue-warm", + $theme-color-primary-lightest: "blue-warm-5", + $theme-color-primary-lighter: "blue-warm-10", + $theme-color-primary-light: "blue-warm-30", + $theme-color-primary: "blue-warm-60v", + $theme-color-primary-vivid: "blue-warm-60v", + $theme-color-primary-dark: "blue-warm-70v", + $theme-color-primary-darker: "blue-warm-80v", + + // Secondary and accent are not in the token set; these are the USWDS defaults. + $theme-color-secondary-family: "red", + $theme-color-secondary-lighter: "red-cool-10", + $theme-color-secondary-light: "red-30", + $theme-color-secondary: "red-50", + $theme-color-secondary-vivid: "red-cool-50v", + $theme-color-secondary-dark: "red-60v", + $theme-color-secondary-darker: "red-70v", + + $theme-color-accent-cool-family: "blue-cool", + $theme-color-accent-cool-lighter: "blue-cool-5v", + $theme-color-accent-cool-light: "blue-cool-20v", + $theme-color-accent-cool: "cyan-30v", + $theme-color-accent-cool-dark: "blue-cool-40v", + $theme-color-accent-cool-darker: "blue-cool-60v", + + $theme-link-color: "primary", + $theme-link-visited-color: "primary-darker", + $theme-link-hover-color: "primary-dark", + $theme-link-active-color: "primary-darker", + $theme-link-reverse-color: "base-light", + $theme-link-reverse-hover-color: "base-lighter", + // Token color/link/reverse-active: white + $theme-link-reverse-active-color: "white", + + // COMPONENT SETTINGS + // https://designsystem.digital.gov/documentation/settings/#component-settings-2 + // Token spacing/border-radius: sm 2px, md 4px + $theme-button-border-radius: "md", + $theme-card-border-radius: "md", + + $theme-banner-max-width: "widescreen", + $theme-footer-max-width: "widescreen", + $theme-header-max-width: "widescreen", + + $theme-card-font-family: "heading", + $theme-header-font-family: "heading", + $theme-navigation-font-family: "heading", + + // SPACING SETTINGS + // https://designsystem.digital.gov/documentation/settings/#spacing-settings-2 + // Token spacing/7: 56px site margins + $theme-grid-container-max-width: "widescreen", + $theme-site-margins-breakpoint: "widescreen", + $theme-site-margins-width: 7, + $theme-site-margins-mobile-width: 2, + + // TYPE SETTINGS + // https://designsystem.digital.gov/documentation/settings/#typography-settings-2 + // https://designsystem.digital.gov/design-tokens/typesetting/font-family/ + // Token typesetting/font-role: ui "Public Sans", display "Roboto Mono". + // Both ship with USWDS, so this theme needs no custom typeface tokens or webfonts. + $theme-font-path: "./fonts", + $theme-font-type-mono: "roboto-mono", + $theme-font-type-sans: "public-sans", + $theme-font-type-serif: "merriweather", + + $theme-font-role-ui: "sans", + $theme-font-role-heading: "sans", + $theme-font-role-body: "sans", + $theme-font-role-code: "mono", + $theme-font-role-alt: "mono", + + // Token typesetting/type-scale: 3xs 12, 2xs 14, sm 16, md 18, lg 22, xl 32, 2xl 40 + $theme-type-scale-3xs: 1, + $theme-type-scale-2xs: 3, + $theme-type-scale-xs: 4, + $theme-type-scale-sm: 5, + $theme-type-scale-md: 7, + $theme-type-scale-lg: 9, + $theme-type-scale-xl: 12, + $theme-type-scale-2xl: 14, + $theme-type-scale-3xl: 15, + + // Token typesetting/font-weight: regular 400, semibold 600, bold 700 + $theme-font-weight-thin: false, + $theme-font-weight-light: 300, + $theme-font-weight-normal: 400, + $theme-font-weight-medium: false, + $theme-font-weight-semibold: 600, + $theme-font-weight-bold: 700, + $theme-font-weight-heavy: 900, + + // UTILITY SETTINGS + // https://designsystem.digital.gov/documentation/settings/#utilities-settings-2 + $utilities-use-important: false, + $theme-utility-breakpoints: ( + "card": false, + "card-lg": false, + "mobile": true, + "mobile-lg": false, + "tablet": true, + "tablet-lg": false, + "desktop": true, + "desktop-lg": true, + "widescreen": true + ), + + $overflow-settings: (responsive: true), + $position-settings: (responsive: true), + $top-settings: (responsive: true), + $right-settings: (responsive: true), + $left-settings: (responsive: true), + $bottom-settings: (responsive: true), + $z-index-settings: (responsive: true), + $background-color-settings: (responsive: true), + $font-style-settings: (responsive: true), + $color-settings: (responsive: true, hover: true), + $flex-direction-settings: (responsive: true), + $flex-settings: (responsive: true), + $order-settings: (responsive: true) +); From 9fd6b3671efad02018fe1fc256ffeaeb391bd130 Mon Sep 17 00:00:00 2001 From: Sandesh Pandey Date: Fri, 31 Jul 2026 10:23:06 -0500 Subject: [PATCH 2/6] Refactor TimelineAdapter and mmgisAPI: remove unused functions and streamline API access --- .../Tools/Timeline/TimelineAdapter.tsx | 19 +--- .../Tools/Timeline/adapters/mmgisAPI.ts | 90 ++++++------------- .../Tools/_shared/adapters/mmgisAPI.ts | 2 +- 3 files changed, 31 insertions(+), 80 deletions(-) diff --git a/src/essence/Tools/Timeline/TimelineAdapter.tsx b/src/essence/Tools/Timeline/TimelineAdapter.tsx index 3483b2e6d..b1f7e068c 100644 --- a/src/essence/Tools/Timeline/TimelineAdapter.tsx +++ b/src/essence/Tools/Timeline/TimelineAdapter.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect, useCallback, useRef } from 'react' import moment from 'moment' import { FloatingPopover } from './lib/FloatingPopover' -import { mmgisRequest, mmgisOn, mmgisEmit, mmgisGetLayerConfigs, mmgisGetRawConfigData, mmgisGetVisibleLayers } from './adapters/mmgisAPI' +import { mmgisRequest, mmgisOn, mmgisEmit, mmgisGetLayerConfigs, mmgisGetVisibleLayers } from './adapters/mmgisAPI' import { TimelineView, TimeModeControl, @@ -116,22 +116,9 @@ export const TimelineAdapter: React.FC = () => { useEffect(() => { const fetchLayers = () => { const configs = mmgisGetLayerConfigs() - const rawConfig = mmgisGetRawConfigData() const visibleLayers = mmgisGetVisibleLayers() - const rawLayers = rawConfig?.layers || [] const newLayers: LayerTimeData[] = [] - const findRawLayer = (layersArr: any[], name: string): any => { - for (let l of layersArr) { - if (l.name === name) return l - if (l.sublayers) { - const sub = findRawLayer(l.sublayers, name) - if (sub) return sub - } - } - return null - } - Object.keys(configs).forEach(layerName => { const layer = configs[layerName] @@ -145,10 +132,6 @@ export const TimelineAdapter: React.FC = () => { let color = 'var(--theme-color-base, #71767a)' // default grey if (layer.time && layer.time.enabled) { - let rawLayer = null - if (rawLayers.length > 0) { - rawLayer = findRawLayer(rawLayers, layerName) - } const timeConfig = layer.time if (timeConfig.dataStartTime) { diff --git a/src/essence/Tools/Timeline/adapters/mmgisAPI.ts b/src/essence/Tools/Timeline/adapters/mmgisAPI.ts index 2676f160f..ca377ce5b 100644 --- a/src/essence/Tools/Timeline/adapters/mmgisAPI.ts +++ b/src/essence/Tools/Timeline/adapters/mmgisAPI.ts @@ -1,61 +1,29 @@ -type EventCleanup = () => void - -type MMGISAPI = { - request: (name: string, params?: unknown) => Promise - on: (event: string, handler: (payload?: unknown) => void) => EventCleanup - emit: (event: string, payload?: unknown) => void - provide?: (name: string, handler: (...args: unknown[]) => unknown) => EventCleanup - hasHandler?: (name: string) => boolean - getLayerConfigs?: () => any - getRawConfigData?: () => any - getVisibleLayers?: () => Record -} - -declare global { - interface Window { - mmgisAPI?: MMGISAPI - } -} - -export const mmgisRequest = async (name: string, params?: unknown): Promise => { - if (window.mmgisAPI?.request) { - return (await window.mmgisAPI.request(name, params)) as T - } - return null -} - -export const mmgisOn = (event: string, handler: (payload?: unknown) => void): EventCleanup => { - if (!window.mmgisAPI?.on) return () => {} - return window.mmgisAPI.on(event, handler) -} - -export const mmgisEmit = (event: string, payload?: unknown): void => { - console.log('[mmgisAPI] mmgisEmit called:', event, 'window.mmgisAPI exists:', !!window.mmgisAPI, 'emit exists:', !!window.mmgisAPI?.emit) - if (window.mmgisAPI?.emit) { - window.mmgisAPI.emit(event, payload) - console.log('[mmgisAPI] Emitted event:', event) - } else { - console.warn('[mmgisAPI] Cannot emit - mmgisAPI or emit not available') - } -} - -export const mmgisProvide = (name: string, handler: (...args: unknown[]) => unknown): EventCleanup => { - if (!window.mmgisAPI?.provide) return () => {} - return window.mmgisAPI.provide(name, handler) -} - -export const mmgisHasHandler = (name: string): boolean => { - return window.mmgisAPI?.hasHandler?.(name) === true -} - -export const mmgisGetLayerConfigs = (): any => { - return window.mmgisAPI?.getLayerConfigs?.() || {} -} - -export const mmgisGetRawConfigData = (): any => { - return window.mmgisAPI?.getRawConfigData?.() || {} -} - -export const mmgisGetVisibleLayers = (): Record => { - return window.mmgisAPI?.getVisibleLayers?.() || {} -} +import type { MMGISAPI } from '../../_shared/adapters/mmgisAPI' + +export { + mmgisRequest, + mmgisOn, + mmgisEmit, + mmgisProvide, + mmgisHasHandler, +} from '../../_shared/adapters/mmgisAPI' + +// window.mmgisAPI is typed once, by the shared client. Config reads below go +// through direct methods core exposes outside the request/provide bus, so they +// are described by a Timeline-local widening of that type rather than a second +// global declaration (two global declarations of the same property collide). +type MMGISAPIWithConfigMethods = MMGISAPI & { + getLayerConfigs?: () => any + getVisibleLayers?: () => Record +} + +const configAPI = (): MMGISAPIWithConfigMethods | undefined => + window.mmgisAPI as MMGISAPIWithConfigMethods | undefined + +export const mmgisGetLayerConfigs = (): any => { + return configAPI()?.getLayerConfigs?.() || {} +} + +export const mmgisGetVisibleLayers = (): Record => { + return configAPI()?.getVisibleLayers?.() || {} +} diff --git a/src/essence/Tools/_shared/adapters/mmgisAPI.ts b/src/essence/Tools/_shared/adapters/mmgisAPI.ts index 9ca8bcf47..926ec76b1 100644 --- a/src/essence/Tools/_shared/adapters/mmgisAPI.ts +++ b/src/essence/Tools/_shared/adapters/mmgisAPI.ts @@ -1,6 +1,6 @@ type EventCleanup = () => void -type MMGISAPI = { +export type MMGISAPI = { request: (name: string, params?: unknown) => Promise on: (event: string, handler: (payload?: unknown) => void) => EventCleanup emit: (event: string, payload?: unknown) => void From ab10b455d73842fc9493110476344c86785ad708 Mon Sep 17 00:00:00 2001 From: Sandesh Pandey Date: Fri, 31 Jul 2026 10:36:01 -0500 Subject: [PATCH 3/6] Move the horizon theme out of the Timeline stack The horizon brand's tokens and theme entry point, along with its option in the configure page's UI tab, live on feat/horizon-theme and land in their own PR. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01AgqjkZhQvfoBgdJtt9SSKS --- configure/src/metaconfigs/tab-ui-config.json | 2 +- src/styles/horizon/index.scss | 11 -- src/styles/horizon/theme-tokens.scss | 149 ------------------- 3 files changed, 1 insertion(+), 161 deletions(-) delete mode 100644 src/styles/horizon/index.scss delete mode 100644 src/styles/horizon/theme-tokens.scss diff --git a/configure/src/metaconfigs/tab-ui-config.json b/configure/src/metaconfigs/tab-ui-config.json index 1c3065dc5..0a95c60ba 100644 --- a/configure/src/metaconfigs/tab-ui-config.json +++ b/configure/src/metaconfigs/tab-ui-config.json @@ -23,7 +23,7 @@ "name": "Theme", "description": "The USWDS theme to apply to the modern interface.", "type": "dropdown", - "options": ["default", "disasters", "earthgov", "horizon"], + "options": ["default", "disasters", "earthgov"], "default": "default", "width": 6 } diff --git a/src/styles/horizon/index.scss b/src/styles/horizon/index.scss deleted file mode 100644 index a9e20553b..000000000 --- a/src/styles/horizon/index.scss +++ /dev/null @@ -1,11 +0,0 @@ -// Horizon theme manifest - -// 1. Forward theme token configuration first (must be before uswds-core/@forward) -@forward "theme-tokens"; - -// 2. Then forward USWDS itself -@forward "uswds"; - -// 3. Export theme tokens as CSS custom properties -@use "../theme-export"; -@include theme-export.export-theme-tokens(); diff --git a/src/styles/horizon/theme-tokens.scss b/src/styles/horizon/theme-tokens.scss deleted file mode 100644 index fee18afe1..000000000 --- a/src/styles/horizon/theme-tokens.scss +++ /dev/null @@ -1,149 +0,0 @@ -// Horizon USWDS theme configuration -// Values come from the "VEDA - USWDS" mode of the Figma Theme tokens collection, -// which aliases the stock USWDS system palette (blue-warm primary, gray-cool base) -// rather than a mission-specific ramp. -@use "uswds-core" with ( - // GENERAL SETTINGS - // https://designsystem.digital.gov/documentation/settings/#general-settings-2 - $theme-show-notifications: false, - $theme-show-compile-warnings: false, - $theme-image-path: "./img", - - // COLOR SETTINGS - // https: //designsystem.digital.gov/documentation/settings/#color-settings-2 - // Token color/base: lightest gray-cool-4, lighter gray-cool-10, dark gray-cool-60 - $theme-color-base-family: "gray-cool", - $theme-color-base-lightest: "gray-cool-4", - $theme-color-base-lighter: "gray-cool-10", - $theme-color-base-light: "gray-cool-30", - $theme-color-base: "gray-cool-50", - $theme-color-base-dark: "gray-cool-60", - $theme-color-base-darker: "gray-cool-70", - $theme-color-base-darkest: "gray-cool-80", - $theme-color-base-ink: "gray-cool-90", - - // Token color/primary: vivid blue-warm-60v (#0050d8), dark blue-warm-70v (#1a4480). - // The token set's vivid grade is the brand primary, so it fills both slots. The - // lighter grades round out the blue-warm ramp; lightest has no USWDS default, so - // blue-warm-5 supplies the tinted hover surface components expect. - $theme-color-primary-family: "blue-warm", - $theme-color-primary-lightest: "blue-warm-5", - $theme-color-primary-lighter: "blue-warm-10", - $theme-color-primary-light: "blue-warm-30", - $theme-color-primary: "blue-warm-60v", - $theme-color-primary-vivid: "blue-warm-60v", - $theme-color-primary-dark: "blue-warm-70v", - $theme-color-primary-darker: "blue-warm-80v", - - // Secondary and accent are not in the token set; these are the USWDS defaults. - $theme-color-secondary-family: "red", - $theme-color-secondary-lighter: "red-cool-10", - $theme-color-secondary-light: "red-30", - $theme-color-secondary: "red-50", - $theme-color-secondary-vivid: "red-cool-50v", - $theme-color-secondary-dark: "red-60v", - $theme-color-secondary-darker: "red-70v", - - $theme-color-accent-cool-family: "blue-cool", - $theme-color-accent-cool-lighter: "blue-cool-5v", - $theme-color-accent-cool-light: "blue-cool-20v", - $theme-color-accent-cool: "cyan-30v", - $theme-color-accent-cool-dark: "blue-cool-40v", - $theme-color-accent-cool-darker: "blue-cool-60v", - - $theme-link-color: "primary", - $theme-link-visited-color: "primary-darker", - $theme-link-hover-color: "primary-dark", - $theme-link-active-color: "primary-darker", - $theme-link-reverse-color: "base-light", - $theme-link-reverse-hover-color: "base-lighter", - // Token color/link/reverse-active: white - $theme-link-reverse-active-color: "white", - - // COMPONENT SETTINGS - // https://designsystem.digital.gov/documentation/settings/#component-settings-2 - // Token spacing/border-radius: sm 2px, md 4px - $theme-button-border-radius: "md", - $theme-card-border-radius: "md", - - $theme-banner-max-width: "widescreen", - $theme-footer-max-width: "widescreen", - $theme-header-max-width: "widescreen", - - $theme-card-font-family: "heading", - $theme-header-font-family: "heading", - $theme-navigation-font-family: "heading", - - // SPACING SETTINGS - // https://designsystem.digital.gov/documentation/settings/#spacing-settings-2 - // Token spacing/7: 56px site margins - $theme-grid-container-max-width: "widescreen", - $theme-site-margins-breakpoint: "widescreen", - $theme-site-margins-width: 7, - $theme-site-margins-mobile-width: 2, - - // TYPE SETTINGS - // https://designsystem.digital.gov/documentation/settings/#typography-settings-2 - // https://designsystem.digital.gov/design-tokens/typesetting/font-family/ - // Token typesetting/font-role: ui "Public Sans", display "Roboto Mono". - // Both ship with USWDS, so this theme needs no custom typeface tokens or webfonts. - $theme-font-path: "./fonts", - $theme-font-type-mono: "roboto-mono", - $theme-font-type-sans: "public-sans", - $theme-font-type-serif: "merriweather", - - $theme-font-role-ui: "sans", - $theme-font-role-heading: "sans", - $theme-font-role-body: "sans", - $theme-font-role-code: "mono", - $theme-font-role-alt: "mono", - - // Token typesetting/type-scale: 3xs 12, 2xs 14, sm 16, md 18, lg 22, xl 32, 2xl 40 - $theme-type-scale-3xs: 1, - $theme-type-scale-2xs: 3, - $theme-type-scale-xs: 4, - $theme-type-scale-sm: 5, - $theme-type-scale-md: 7, - $theme-type-scale-lg: 9, - $theme-type-scale-xl: 12, - $theme-type-scale-2xl: 14, - $theme-type-scale-3xl: 15, - - // Token typesetting/font-weight: regular 400, semibold 600, bold 700 - $theme-font-weight-thin: false, - $theme-font-weight-light: 300, - $theme-font-weight-normal: 400, - $theme-font-weight-medium: false, - $theme-font-weight-semibold: 600, - $theme-font-weight-bold: 700, - $theme-font-weight-heavy: 900, - - // UTILITY SETTINGS - // https://designsystem.digital.gov/documentation/settings/#utilities-settings-2 - $utilities-use-important: false, - $theme-utility-breakpoints: ( - "card": false, - "card-lg": false, - "mobile": true, - "mobile-lg": false, - "tablet": true, - "tablet-lg": false, - "desktop": true, - "desktop-lg": true, - "widescreen": true - ), - - $overflow-settings: (responsive: true), - $position-settings: (responsive: true), - $top-settings: (responsive: true), - $right-settings: (responsive: true), - $left-settings: (responsive: true), - $bottom-settings: (responsive: true), - $z-index-settings: (responsive: true), - $background-color-settings: (responsive: true), - $font-style-settings: (responsive: true), - $color-settings: (responsive: true, hover: true), - $flex-direction-settings: (responsive: true), - $flex-settings: (responsive: true), - $order-settings: (responsive: true) -); From e5c119ec0da473c15639e8acc23e2208963edb36 Mon Sep 17 00:00:00 2001 From: Sandesh Pandey Date: Fri, 31 Jul 2026 12:05:34 -0500 Subject: [PATCH 4/6] Fix Timeline core wiring, config plumbing and shared-CSS regressions Core integration: - Subscribe to 'time:changed' (the event TimeControl actually broadcasts) and ignore this plugin's own committed echo, so external time changes reach the timeline and a stale window is never pushed back to core. - Gate 'tool:getVars' and the layer reads on useMMGISHandlerReady, matching the sibling modern tools; these handlers are registered in Layers_.fina() after the tool mounts, so the previous mount-time request always rejected. - Read layer configs and visibility over the request/provide bus ('layers:getAllConfigs', 'layers:getVisible') instead of direct window.mmgisAPI methods, and drop the Timeline-local type widening. - Add a 'time:isEnabled' provider to TimeControl and render an explicit unavailable state, replacing the fabricated now-30d/now-15d window that rendered a fully interactive timeline core silently discarded. Playback and stepping: - Keep the setCurrentTime updater pure; the emit and the stop now run in the interval body against refs, which also stops the interval re-arming per tick. - Clamp step targets to the range instead of dropping the step, expose canStepForward/canStepBackward, and restart from the start when play is pressed at the end of the range. Accessibility and styling: - Restore a focus-visible ring for every control; core's global `*:focus { outline: none }` removes the native one. - Give FloatingPopover dialog semantics, Escape-to-close and focus restore, and wire aria-expanded/aria-controls on the triggers. - Scope `.ui-tool-card { width: 100% }` to vertical and tabbed regions so a top/bottom panel holding more than one tool still lays them out in a row. - Export --theme-color-primary-lightest for every brand, falling back to primary-lighter, so the three LayerManager hover rules resolve per-brand instead of inheriting the disasters teal literal. - Set the info popover's font; it portals outside the .timeline subtree. Also: keep generateTimeTicks inside its domain and terminate on degenerate ranges, unmount a prior root before make() creates a new one, drop the eight debug console.log calls and the never-populated _cleanups array, and add unit tests for the tick and clamp helpers. --- .../Basics/TimeControl_/TimeControl.js | 3 + .../UserInterface_/UserInterfaceModern_.css | 13 + src/essence/Tools/Timeline/Timeline.css | 69 +- .../Tools/Timeline/TimelineAdapter.tsx | 919 ++++++++++-------- src/essence/Tools/Timeline/TimelineTool.tsx | 9 +- .../Tools/Timeline/adapters/mmgisAPI.ts | 27 +- .../lib/FloatingPopover/FloatingPopover.tsx | 60 ++ .../lib/geo/DateSelector/DateSelector.tsx | 2 +- .../geo/PlaybackControls/PlaybackControls.tsx | 4 + .../Tools/Timeline/lib/utils/timeUtils.ts | 33 +- .../Tools/_shared/adapters/mmgisAPI.ts | 38 +- src/styles/_theme-export.scss | 13 +- tests/unit/timelineTimeUtils.spec.js | 88 ++ 13 files changed, 790 insertions(+), 488 deletions(-) create mode 100644 tests/unit/timelineTimeUtils.spec.js diff --git a/src/essence/Basics/TimeControl_/TimeControl.js b/src/essence/Basics/TimeControl_/TimeControl.js index 995c92267..f4eca7d9f 100644 --- a/src/essence/Basics/TimeControl_/TimeControl.js +++ b/src/essence/Basics/TimeControl_/TimeControl.js @@ -55,6 +55,9 @@ var TimeControl = { timeInputChange(startTime, endTime, currentTime) } ), + // Separates "time is off for this mission" from "on but not yet + // seeded"; the getters below return null for both. + window.mmgisAPI.provide('time:isEnabled', () => TimeControl.enabled === true), window.mmgisAPI.provide('time:getCurrent', () => TimeControl.getTime()), window.mmgisAPI.provide('time:getStart', () => TimeControl.getStartTime()), window.mmgisAPI.provide('time:getEnd', () => TimeControl.getEndTime()), diff --git a/src/essence/Basics/UserInterface_/UserInterfaceModern_.css b/src/essence/Basics/UserInterface_/UserInterfaceModern_.css index 699ea44b3..02e32b787 100644 --- a/src/essence/Basics/UserInterface_/UserInterfaceModern_.css +++ b/src/essence/Basics/UserInterface_/UserInterfaceModern_.css @@ -126,6 +126,19 @@ /* Tool Cards - Base Styles (shared by stacked and tabbed) */ .ui-tool-card { background-color: var(--theme-color-white, #ffffff); +} + +/* Cards fill the panel where they stack vertically. Top/bottom regions lay + them out in a row, so there they keep their intrinsic width. */ +.ui-region-left .ui-tool-card, +.ui-region-right .ui-tool-card, +.ui-panel-body-tabbed .ui-tool-card { + width: 100%; +} + +/* A lone tool in a horizontal region still fills it. */ +.ui-region-top .ui-panel-body > .ui-tool-card:only-child, +.ui-region-bottom .ui-panel-body > .ui-tool-card:only-child { width: 100%; } diff --git a/src/essence/Tools/Timeline/Timeline.css b/src/essence/Tools/Timeline/Timeline.css index 644dff722..1ec53d383 100644 --- a/src/essence/Tools/Timeline/Timeline.css +++ b/src/essence/Tools/Timeline/Timeline.css @@ -9,8 +9,20 @@ overflow: hidden; } -/* Collapsed: only the header remains, and the tool shrinks to fit it so the - host panel isn't left holding a blank timeline body. */ +/* Core's global `*:focus { outline: none }` strips the native ring, so every + focusable control in the plugin restores one here. */ +.timeline button:focus-visible, +.timeline input:focus-visible, +.timeline [tabindex]:focus-visible, +.floating-popover-portal button:focus-visible, +.floating-popover-portal input:focus-visible, +.floating-popover-portal [tabindex]:focus-visible { + outline: 2px solid var(--theme-color-primary, #0e7482); + outline-offset: 2px; +} + +/* Collapsed: only the header remains. The host panel keeps its configured + height, so the timeline stays top-aligned within it. */ .timeline--collapsed { height: auto; } @@ -46,6 +58,32 @@ font-size: 14px; } +/* Shown when the mission has no time enabled and there is nothing to scrub. */ +.timeline-unavailable { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + height: 100%; + width: 100%; + padding: 16px; + text-align: center; + background: var(--theme-color-white, #ffffff); + font-family: var(--theme-font-ui, 'Public Sans', system-ui, -apple-system, sans-serif); +} + +.timeline-unavailable-message { + font-size: 13px; + font-weight: 700; + color: var(--theme-color-base-darker, #3d4551); +} + +.timeline-unavailable-hint { + font-size: 12px; + color: var(--theme-color-base, #71767a); +} + .timeline-header { display: flex; justify-content: space-between; @@ -136,7 +174,8 @@ cursor: not-allowed; } -/* Info Tooltip Portal */ +/* Info Tooltip Portal. Portalled to document.body, so it carries the plugin's + font rather than inheriting core's. */ .timeline-info-tooltip-portal { background: var(--theme-color-primary-dark, #0d313d); border: none; @@ -147,6 +186,7 @@ z-index: 999999; animation: tooltipFadeIn 0.15s ease; pointer-events: none; + font-family: var(--theme-font-ui, 'Public Sans', system-ui, -apple-system, sans-serif); } @keyframes tooltipFadeIn { @@ -158,25 +198,10 @@ } } -/* Tooltip arrow */ -.timeline-info-tooltip-arrow { - position: absolute; - top: 100%; - right: 12px; -} - -.timeline-info-tooltip-arrow::after { - content: ''; - position: absolute; - width: 0; - height: 0; - border-left: 8px solid transparent; - border-right: 8px solid transparent; - border-top: 8px solid var(--theme-color-primary-dark, #0d313d); -} - -.timeline-info-tooltip-arrow::before { - display: none; +@media (prefers-reduced-motion: reduce) { + .timeline-info-tooltip-portal { + animation: none; + } } .timeline-info-tooltip-content { diff --git a/src/essence/Tools/Timeline/TimelineAdapter.tsx b/src/essence/Tools/Timeline/TimelineAdapter.tsx index b1f7e068c..c12959b34 100644 --- a/src/essence/Tools/Timeline/TimelineAdapter.tsx +++ b/src/essence/Tools/Timeline/TimelineAdapter.tsx @@ -1,421 +1,498 @@ -import React, { useState, useEffect, useCallback, useRef } from 'react' -import moment from 'moment' -import { FloatingPopover } from './lib/FloatingPopover' -import { mmgisRequest, mmgisOn, mmgisEmit, mmgisGetLayerConfigs, mmgisGetVisibleLayers } from './adapters/mmgisAPI' -import { - TimelineView, - TimeModeControl, - DateSelector, - PlaybackControls, - PlaybackSpeedControl, - getNextPlaybackSpeed, - TIME_MODE_ORDER, - type TimeMode, - type LayerTimeData -} from './lib' -import { getTimeStep } from './lib/utils/timeUtils' -import './Timeline.css' - -interface TimeData { - startTime: string - endTime: string - currentTime: string -} - -export const TimelineAdapter: React.FC = () => { - const [startTime, setStartTime] = useState(() => { - const d = new Date() - d.setDate(d.getDate() - 30) - return d - }) - const [endTime, setEndTime] = useState(new Date()) - const [currentTime, setCurrentTime] = useState(() => { - const d = new Date() - d.setDate(d.getDate() - 15) - return d - }) - const [timeMode, setTimeMode] = useState('DAY') - const [shownTimeModes, setShownTimeModes] = useState(TIME_MODE_ORDER) - const [layers, setLayers] = useState([]) - const [isReady, setIsReady] = useState(false) - - const [isPlaying, setIsPlaying] = useState(false) - const [playbackSpeed, setPlaybackSpeed] = useState(1) - const [allowPlayback, setAllowPlayback] = useState(true) - const [layerVisibilityVersion, setLayerVisibilityVersion] = useState(0) - const [showInfoPopup, setShowInfoPopup] = useState(false) - // Collapsed hides the layer list / scrubber area, leaving just the header - const [isCollapsed, setIsCollapsed] = useState(false) - const [resetZoomFn, setResetZoomFn] = useState<(() => void) | null>(null) - const infoButtonRef = useRef(null) - - const handleResetZoomReady = useCallback((fn: () => void) => { - setResetZoomFn(() => fn) - }, []) - - // Fetch tool variables (e.g. allowPlayback) from the mission config - useEffect(() => { - let cancelled = false - const fetchVars = async () => { - try { - const vars = await mmgisRequest<{ - allowPlayback?: boolean - defaultTimeMode?: string - shownTimeModes?: string[] - }>('tool:getVars', 'timeline') - if (cancelled || !vars) return - - if (typeof vars.allowPlayback === 'boolean') { - setAllowPlayback(vars.allowPlayback) - } - - // Determine which mode buttons to show (canonical order, empty = all) - let effectiveModes = TIME_MODE_ORDER - if (Array.isArray(vars.shownTimeModes)) { - const requested = vars.shownTimeModes.map((m) => String(m).toUpperCase()) - const normalized = TIME_MODE_ORDER.filter((m) => requested.includes(m)) - if (normalized.length > 0) effectiveModes = normalized - } - setShownTimeModes(effectiveModes) - - // Determine the initial mode: configured default (if valid), - // otherwise the prior 'DAY' fallback; then clamp to a shown mode. - const requestedDefault = vars.defaultTimeMode?.toUpperCase() - let mode: TimeMode = - requestedDefault === 'YEAR' || - requestedDefault === 'MONTH' || - requestedDefault === 'DAY' || - requestedDefault === 'HOUR' - ? (requestedDefault as TimeMode) - : 'DAY' - if (!effectiveModes.includes(mode)) mode = effectiveModes[0] - setTimeMode(mode) - } catch (err) { - console.warn('[Timeline] Failed to fetch tool vars:', err) - } - } - fetchVars() - return () => { - cancelled = true - } - }, []) - - // Stop playback if it becomes disabled via config - useEffect(() => { - if (!allowPlayback && isPlaying) setIsPlaying(false) - }, [allowPlayback, isPlaying]) - - // Subscribe to layer visibility changes - useEffect(() => { - const cleanup = mmgisOn('layer:visibilityChange', () => { - console.log('[Timeline] Layer visibility changed, refetching layers') - setLayerVisibilityVersion(v => v + 1) - }) - return cleanup - }, []) - useEffect(() => { - const fetchLayers = () => { - const configs = mmgisGetLayerConfigs() - const visibleLayers = mmgisGetVisibleLayers() - const newLayers: LayerTimeData[] = [] - - Object.keys(configs).forEach(layerName => { - const layer = configs[layerName] - - // Filter to only visible layers - if (!visibleLayers?.[layerName]) { - return - } - - let start = startTime - let end = endTime - let color = 'var(--theme-color-base, #71767a)' // default grey - - if (layer.time && layer.time.enabled) { - const timeConfig = layer.time - - if (timeConfig.dataStartTime) { - const parsedStart = new Date(timeConfig.dataStartTime) - if (!isNaN(parsedStart.getTime())) { - start = parsedStart - } - } - if (timeConfig.dataEndTime) { - const parsedEnd = timeConfig.dataEndTime === 'now' ? new Date() : new Date(timeConfig.dataEndTime) - if (!isNaN(parsedEnd.getTime())) { - end = parsedEnd - } - } - - // Time-enabled layers stand out in the theme's secondary colour - color = 'var(--theme-color-secondary, #c91b6e)' - } - - newLayers.push({ - name: layerName, - displayName: layer.display_name || layer.name || layerName, - color: color, - timeRanges: [ - { start, end } - ] - }) - }) - - setLayers(newLayers) - } - - fetchLayers() - }, [startTime, endTime, layerVisibilityVersion]) - - // Fetch initial time data from TimeControl - useEffect(() => { - const fetchInitialTimeData = async () => { - try { - console.log('[Timeline] Fetching initial time data from TimeControl...') - const start = await mmgisRequest('time:getStart') - const end = await mmgisRequest('time:getEnd') - const current = await mmgisRequest('time:getCurrent') - - console.log('[Timeline] Received initial time data:', { start, end, current }) - - if (start && end && current) { - setStartTime(new Date(start)) - setEndTime(new Date(end)) - setCurrentTime(new Date(current)) - } - } catch (err) { - console.error('[Timeline] Failed to fetch initial time data:', err) - } finally { - setIsReady(true) - } - } - - fetchInitialTimeData() - - // Subscribe to time changes - const cleanup = mmgisOn('time:change', (payload: any) => { - console.log('[Timeline] Received time:change event:', payload) - if (payload?.startTime) setStartTime(new Date(payload.startTime)) - if (payload?.endTime) setEndTime(new Date(payload.endTime)) - if (payload?.currentTime) setCurrentTime(new Date(payload.currentTime)) - }) - - return cleanup - }, []) - - - // Handle current time change from scrubber - const handleCurrentTimeChange = useCallback( - (newTime: Date) => { - console.log('[Timeline] User changed current time:', newTime.toISOString()) - setCurrentTime(newTime) - - // Emit time:changeRequested event for TimeControl to respond - const payload = { - startTime: startTime.toISOString(), - endTime: endTime.toISOString(), - currentTime: newTime.toISOString(), - } - console.log('[Timeline] Emitting time:changeRequested:', payload) - mmgisEmit('time:changeRequested', payload) - }, - [startTime, endTime] - ) - - // Live time while the scrubber is dragged: the header date follows along, - // but nothing is emitted until the drag is released. - const handleCurrentTimePreview = useCallback((newTime: Date) => { - setCurrentTime(newTime) - }, []) - - // Handle current date change - const handleCurrentDateChange = useCallback( - (newCurrent: Date) => { - setCurrentTime(newCurrent) - - // Emit time:changeRequested event for TimeControl to respond - mmgisEmit('time:changeRequested', { - startTime: startTime.toISOString(), - endTime: endTime.toISOString(), - currentTime: newCurrent.toISOString(), - }) - }, - [startTime, endTime] - ) - - // Playback logic - const handleStepForward = useCallback(() => { - const { unit, value } = getTimeStep(timeMode) - const nextTime = moment(currentTime).add(value, unit as moment.unitOfTime.DurationConstructor).toDate() - if (nextTime <= endTime) { - handleCurrentTimeChange(nextTime) - } - }, [currentTime, endTime, timeMode, handleCurrentTimeChange]) - - const handleStepBackward = useCallback(() => { - const { unit, value } = getTimeStep(timeMode) - const prevTime = moment(currentTime).subtract(value, unit as moment.unitOfTime.DurationConstructor).toDate() - if (prevTime >= startTime) { - handleCurrentTimeChange(prevTime) - } - }, [currentTime, startTime, timeMode, handleCurrentTimeChange]) - - const handleGoToStart = useCallback(() => { - handleCurrentTimeChange(startTime) - }, [startTime, handleCurrentTimeChange]) - - const handleGoToEnd = useCallback(() => { - handleCurrentTimeChange(endTime) - }, [endTime, handleCurrentTimeChange]) - - useEffect(() => { - if (!isPlaying) return - - const { unit, value } = getTimeStep(timeMode) - // Base cadence is one step per second, divided by the playback speed - // multiplier (2x -> 500ms, 4x -> 250ms, 0.5x -> 2000ms). - const speed = 1000 / playbackSpeed - - const interval = setInterval(() => { - setCurrentTime(prev => { - const nextTime = moment(prev).add(value, unit as moment.unitOfTime.DurationConstructor).toDate() - if (nextTime <= endTime) { - // Emit time:changeRequested event to notify TimeControl - const payload = { - startTime: startTime.toISOString(), - endTime: endTime.toISOString(), - currentTime: nextTime.toISOString(), - } - console.log('[Timeline] Playback emitting time:changeRequested:', payload) - mmgisEmit('time:changeRequested', payload) - return nextTime - } else { - console.log('[Timeline] Playback reached end, stopping') - setIsPlaying(false) - return prev - } - }) - }, speed) - - return () => clearInterval(interval) - }, [isPlaying, timeMode, endTime, startTime, playbackSpeed]) - - if (!isReady) { - return ( -
-
Loading timeline...
-
- ) - } - - return ( -
-
-
- -
-
- setIsPlaying(!isPlaying)} - onStepForward={handleStepForward} - onStepBackward={handleStepBackward} - onGoToStart={handleGoToStart} - onGoToEnd={handleGoToEnd} - /> - {allowPlayback && ( - setPlaybackSpeed((s) => getNextPlaybackSpeed(s))} - /> - )} -
-
- -
- - - -
-
-
-
- {layers.length === 0 ? ( -
-
- No visible layers on the map -
-
- Enable the visibility of one or more map layers to display them here. -
-
- ) : ( - - )} -
- setShowInfoPopup(false)} - placement="top" - offset={8} - className="timeline-info-tooltip-portal" - > -
- Timeline Controls -

Scroll to zoom • Drag scrubber to change time • Click to jump

-
-
-
- ) -} - - +import React, { useState, useEffect, useCallback, useRef } from 'react' +import moment from 'moment' +import { FloatingPopover } from './lib/FloatingPopover' +import { + mmgisRequest, + mmgisOn, + mmgisEmit, + mmgisGetLayerConfigs, + mmgisGetVisibleLayers, + mmgisIsTimeEnabled, + type LayerConfig, +} from './adapters/mmgisAPI' +import { useMMGISHandlerReady } from '../_shared/adapters/useMMGISHandlerReady' +import { + TimelineView, + TimeModeControl, + DateSelector, + PlaybackControls, + PlaybackSpeedControl, + getNextPlaybackSpeed, + TIME_MODE_ORDER, + type TimeMode, + type LayerTimeData +} from './lib' +import { getTimeStep, clampDate } from './lib/utils/timeUtils' +import './Timeline.css' + +/** The wire shape of both 'time:changeRequested' and 'time:changed'. */ +interface TimePayload { + startTime: string + endTime: string + currentTime: string +} + +/** + * 'loading' until core answers; 'unavailable' when the mission has no time + * enabled, which leaves nothing meaningful to scrub. + */ +type Readiness = 'loading' | 'ready' | 'unavailable' + +const sameInstant = (a: Date, b: Date): boolean => a.getTime() === b.getTime() + +/** Keeps the previous Date when the instant is unchanged, so effects keyed on + * it don't re-run on every echo from core. */ +const preserveIdentity = (prev: Date, next: Date): Date => + sameInstant(prev, next) ? prev : next + +export const TimelineAdapter: React.FC = () => { + const [startTime, setStartTime] = useState(() => { + const d = new Date() + d.setDate(d.getDate() - 30) + return d + }) + const [endTime, setEndTime] = useState(new Date()) + const [currentTime, setCurrentTime] = useState(() => { + const d = new Date() + d.setDate(d.getDate() - 15) + return d + }) + const [timeMode, setTimeMode] = useState('DAY') + const [shownTimeModes, setShownTimeModes] = useState(TIME_MODE_ORDER) + const [layers, setLayers] = useState([]) + const [readiness, setReadiness] = useState('loading') + + const [isPlaying, setIsPlaying] = useState(false) + const [playbackSpeed, setPlaybackSpeed] = useState(1) + const [allowPlayback, setAllowPlayback] = useState(true) + const [layerVisibilityVersion, setLayerVisibilityVersion] = useState(0) + const [layersApiReady, setLayersApiReady] = useState(false) + const [showInfoPopup, setShowInfoPopup] = useState(false) + // Collapsed hides the layer list / scrubber area, leaving just the header + const [isCollapsed, setIsCollapsed] = useState(false) + const [resetZoomFn, setResetZoomFn] = useState<(() => void) | null>(null) + const infoButtonRef = useRef(null) + + // Mirror the committed window so emit/step callbacks keep a stable identity + // and don't re-arm the playback interval on every tick. + const startTimeRef = useRef(startTime) + const endTimeRef = useRef(endTime) + const currentTimeRef = useRef(currentTime) + useEffect(() => { + startTimeRef.current = startTime + }, [startTime]) + useEffect(() => { + endTimeRef.current = endTime + }, [endTime]) + useEffect(() => { + currentTimeRef.current = currentTime + }, [currentTime]) + + // The last payload this plugin asked core to commit. Core broadcasts every + // commit back on 'time:changed', including this one; matching it here keeps + // the echo from fighting an in-flight drag or playback tick. + const lastRequestedRef = useRef(null) + + const handleResetZoomReady = useCallback((fn: () => void) => { + setResetZoomFn(() => fn) + }, []) + + /** Moves the scrubber and asks core to commit the same instant. */ + const commitTime = useCallback((next: Date) => { + setCurrentTime((prev) => preserveIdentity(prev, next)) + const payload: TimePayload = { + startTime: startTimeRef.current.toISOString(), + endTime: endTimeRef.current.toISOString(), + currentTime: next.toISOString(), + } + lastRequestedRef.current = payload + mmgisEmit('time:changeRequested', payload) + }, []) + + // Tool variables from the mission config. 'tool:getVars' is registered by + // Layers_.fina() during mission load, after this tool mounts. + const fetchVars = useCallback(async () => { + try { + const vars = await mmgisRequest<{ + allowPlayback?: boolean + defaultTimeMode?: string + shownTimeModes?: string[] + }>('tool:getVars', 'timeline') + if (!vars) return + + if (typeof vars.allowPlayback === 'boolean') { + setAllowPlayback(vars.allowPlayback) + } + + // Which mode buttons to show (canonical order, empty = all) + let effectiveModes = TIME_MODE_ORDER + if (Array.isArray(vars.shownTimeModes)) { + const requested = vars.shownTimeModes.map((m) => String(m).toUpperCase()) + const normalized = TIME_MODE_ORDER.filter((m) => requested.includes(m)) + if (normalized.length > 0) effectiveModes = normalized + } + setShownTimeModes(effectiveModes) + + // Initial mode: the configured default when valid, else 'DAY', + // then clamped to a shown mode. + const requestedDefault = vars.defaultTimeMode?.toUpperCase() + let mode: TimeMode = + requestedDefault === 'YEAR' || + requestedDefault === 'MONTH' || + requestedDefault === 'DAY' || + requestedDefault === 'HOUR' + ? (requestedDefault as TimeMode) + : 'DAY' + if (!effectiveModes.includes(mode)) mode = effectiveModes[0] + setTimeMode(mode) + } catch (err) { + console.warn('[Timeline] Failed to fetch tool vars:', err) + } + }, []) + useMMGISHandlerReady('tool:getVars', fetchVars) + + // Stop playback if it becomes disabled via config + useEffect(() => { + if (!allowPlayback && isPlaying) setIsPlaying(false) + }, [allowPlayback, isPlaying]) + + // Subscribe to layer visibility changes + useEffect(() => { + return mmgisOn('layer:visibilityChange', () => { + setLayerVisibilityVersion((v) => v + 1) + }) + }, []) + + const markLayersApiReady = useCallback(() => setLayersApiReady(true), []) + useMMGISHandlerReady('layers:getAllConfigs', markLayersApiReady) + + useEffect(() => { + if (!layersApiReady) return + let cancelled = false + + const fetchLayers = async () => { + const [configs, visibleLayers] = await Promise.all([ + mmgisGetLayerConfigs(), + mmgisGetVisibleLayers(), + ]) + if (cancelled || !configs) return + + const newLayers: LayerTimeData[] = [] + + Object.keys(configs).forEach((layerName) => { + const layer: LayerConfig = configs[layerName] + + if (!visibleLayers?.[layerName]) return + + let start = startTime + let end = endTime + let color = 'var(--theme-color-base, #71767a)' // default grey + + if (layer.time && layer.time.enabled) { + const timeConfig = layer.time + + if (timeConfig.dataStartTime) { + const parsedStart = new Date(timeConfig.dataStartTime) + if (!isNaN(parsedStart.getTime())) { + start = parsedStart + } + } + if (timeConfig.dataEndTime) { + const parsedEnd = timeConfig.dataEndTime === 'now' ? new Date() : new Date(timeConfig.dataEndTime) + if (!isNaN(parsedEnd.getTime())) { + end = parsedEnd + } + } + + // Time-enabled layers stand out in the theme's secondary colour + color = 'var(--theme-color-secondary, #c91b6e)' + } + + newLayers.push({ + name: layerName, + displayName: layer.display_name || layer.name || layerName, + color: color, + timeRanges: [ + { start, end } + ] + }) + }) + + setLayers(newLayers) + } + + fetchLayers() + return () => { + cancelled = true + } + }, [layersApiReady, startTime, endTime, layerVisibilityVersion]) + + // Seed from TimeControl, then follow every committed change. + const fetchInitialTimeData = useCallback(async () => { + try { + // Null means core predates the handler, not that time is off. + if ((await mmgisIsTimeEnabled()) === false) { + setReadiness('unavailable') + return + } + + const [start, end, current] = await Promise.all([ + mmgisRequest('time:getStart'), + mmgisRequest('time:getEnd'), + mmgisRequest('time:getCurrent'), + ]) + + if (!start || !end || !current) { + setReadiness('unavailable') + return + } + + setStartTime((prev) => preserveIdentity(prev, new Date(start))) + setEndTime((prev) => preserveIdentity(prev, new Date(end))) + setCurrentTime((prev) => preserveIdentity(prev, new Date(current))) + setReadiness('ready') + } catch (err) { + console.error('[Timeline] Failed to fetch initial time data:', err) + setReadiness('unavailable') + } + }, []) + useMMGISHandlerReady('time:getStart', fetchInitialTimeData) + + useEffect(() => { + return mmgisOn('time:changed', (payload?: unknown) => { + const data = payload as Partial | undefined + if (!data) return + + const requested = lastRequestedRef.current + if ( + requested && + requested.startTime === data.startTime && + requested.endTime === data.endTime && + requested.currentTime === data.currentTime + ) { + // This plugin's own commit; local state already matches. + return + } + + if (data.startTime) setStartTime((prev) => preserveIdentity(prev, new Date(data.startTime as string))) + if (data.endTime) setEndTime((prev) => preserveIdentity(prev, new Date(data.endTime as string))) + if (data.currentTime) setCurrentTime((prev) => preserveIdentity(prev, new Date(data.currentTime as string))) + }) + }, []) + + // Committed time change from the scrubber + const handleCurrentTimeChange = useCallback( + (newTime: Date) => { + commitTime(clampDate(newTime, startTimeRef.current, endTimeRef.current)) + }, + [commitTime] + ) + + // Live time while the scrubber is dragged: the header date follows along, + // but nothing is emitted until the drag is released. + const handleCurrentTimePreview = useCallback((newTime: Date) => { + setCurrentTime((prev) => preserveIdentity(prev, newTime)) + }, []) + + const handleCurrentDateChange = handleCurrentTimeChange + + const canStepBackward = currentTime > startTime + const canStepForward = currentTime < endTime + + const handleStepForward = useCallback(() => { + const { unit, value } = getTimeStep(timeMode) + const nextTime = moment(currentTimeRef.current).add(value, unit).toDate() + handleCurrentTimeChange(nextTime) + }, [timeMode, handleCurrentTimeChange]) + + const handleStepBackward = useCallback(() => { + const { unit, value } = getTimeStep(timeMode) + const prevTime = moment(currentTimeRef.current).subtract(value, unit).toDate() + handleCurrentTimeChange(prevTime) + }, [timeMode, handleCurrentTimeChange]) + + const handleGoToStart = useCallback(() => { + commitTime(startTimeRef.current) + }, [commitTime]) + + const handleGoToEnd = useCallback(() => { + commitTime(endTimeRef.current) + }, [commitTime]) + + /** Playing from the end restarts at the beginning rather than stalling. */ + const handlePlayToggle = useCallback(() => { + if (isPlaying) { + setIsPlaying(false) + return + } + if (currentTimeRef.current >= endTimeRef.current) { + commitTime(startTimeRef.current) + } + setIsPlaying(true) + }, [isPlaying, commitTime]) + + useEffect(() => { + if (!isPlaying) return + + const { unit, value } = getTimeStep(timeMode) + // One step per second, divided by the speed multiplier + // (2x -> 500ms, 4x -> 250ms, 0.5x -> 2000ms). + const speed = 1000 / playbackSpeed + + const interval = setInterval(() => { + const nextTime = moment(currentTimeRef.current).add(value, unit).toDate() + if (nextTime > endTimeRef.current) { + setIsPlaying(false) + return + } + commitTime(nextTime) + }, speed) + + return () => clearInterval(interval) + }, [isPlaying, timeMode, playbackSpeed, commitTime]) + + const infoPopupId = 'timeline-info-popup' + + if (readiness === 'loading') { + return ( +
+
Loading timeline...
+
+ ) + } + + if (readiness === 'unavailable') { + return ( +
+
+ Time is not enabled for this mission +
+
+ Enable time in the mission configuration to use the timeline. +
+
+ ) + } + + return ( +
+
+
+ +
+
+ + {allowPlayback && ( + setPlaybackSpeed((s) => getNextPlaybackSpeed(s))} + /> + )} +
+
+ +
+ + + +
+
+
+
+ {layers.length === 0 ? ( +
+
+ No visible layers on the map +
+
+ Enable the visibility of one or more map layers to display them here. +
+
+ ) : ( + + )} +
+ setShowInfoPopup(false)} + placement="top" + offset={8} + className="timeline-info-tooltip-portal" + label="Timeline controls" + > +
+ Timeline Controls +

Scroll to zoom • Drag scrubber to change time • Click to jump

+
+
+
+ ) +} diff --git a/src/essence/Tools/Timeline/TimelineTool.tsx b/src/essence/Tools/Timeline/TimelineTool.tsx index 5c1c17fa1..0fe8e6374 100644 --- a/src/essence/Tools/Timeline/TimelineTool.tsx +++ b/src/essence/Tools/Timeline/TimelineTool.tsx @@ -16,7 +16,6 @@ const TimelineTool = { vars: {} as ToolVars, targetId: null as string | null, made: false, - _cleanups: [] as Array<() => void>, initialize: async function () { try { @@ -57,6 +56,12 @@ const TimelineTool = { ) return } + // A prior root left mounted on this container would collide with the + // new one, so it is torn down first. + if (_root) { + _root.unmount() + _root = null + } _root = createRoot(container) _root.render() this.made = true @@ -67,8 +72,6 @@ const TimelineTool = { _root.unmount() _root = null } - this._cleanups.forEach((cleanup) => cleanup()) - this._cleanups = [] this.targetId = null this.made = false }, diff --git a/src/essence/Tools/Timeline/adapters/mmgisAPI.ts b/src/essence/Tools/Timeline/adapters/mmgisAPI.ts index ca377ce5b..690e36f00 100644 --- a/src/essence/Tools/Timeline/adapters/mmgisAPI.ts +++ b/src/essence/Tools/Timeline/adapters/mmgisAPI.ts @@ -1,4 +1,6 @@ -import type { MMGISAPI } from '../../_shared/adapters/mmgisAPI' +// Core is reached only through the shared client's request/provide bus, so +// every call survives a sandbox boundary. Layer and tool-var handlers are +// registered in Layers_.fina(), so callers gate on readiness. export { mmgisRequest, @@ -6,24 +8,9 @@ export { mmgisEmit, mmgisProvide, mmgisHasHandler, + mmgisGetLayerConfigs, + mmgisGetVisibleLayers, + mmgisIsTimeEnabled, } from '../../_shared/adapters/mmgisAPI' -// window.mmgisAPI is typed once, by the shared client. Config reads below go -// through direct methods core exposes outside the request/provide bus, so they -// are described by a Timeline-local widening of that type rather than a second -// global declaration (two global declarations of the same property collide). -type MMGISAPIWithConfigMethods = MMGISAPI & { - getLayerConfigs?: () => any - getVisibleLayers?: () => Record -} - -const configAPI = (): MMGISAPIWithConfigMethods | undefined => - window.mmgisAPI as MMGISAPIWithConfigMethods | undefined - -export const mmgisGetLayerConfigs = (): any => { - return configAPI()?.getLayerConfigs?.() || {} -} - -export const mmgisGetVisibleLayers = (): Record => { - return configAPI()?.getVisibleLayers?.() || {} -} +export type { LayerConfig } from '../../_shared/adapters/mmgisAPI' diff --git a/src/essence/Tools/Timeline/lib/FloatingPopover/FloatingPopover.tsx b/src/essence/Tools/Timeline/lib/FloatingPopover/FloatingPopover.tsx index 457d9631f..b7d85f734 100644 --- a/src/essence/Tools/Timeline/lib/FloatingPopover/FloatingPopover.tsx +++ b/src/essence/Tools/Timeline/lib/FloatingPopover/FloatingPopover.tsx @@ -8,6 +8,15 @@ export interface FloatingPopoverProps { placement?: 'top' | 'bottom' | 'left' | 'right' offset?: number className?: string + /** Ties the anchor's aria-controls to this popover. */ + id?: string + /** Accessible name for the dialog. */ + label?: string + /** + * Moves focus into the popover on open. Leave false for informational + * content, where taking focus off the trigger is disruptive. + */ + autoFocus?: boolean children: React.ReactNode } @@ -18,11 +27,58 @@ export const FloatingPopover: React.FC = ({ placement = 'bottom', offset = 8, className = '', + id, + label, + autoFocus = false, children }) => { const popupRef = useRef(null) const [pos, setPos] = useState({ top: 0, left: 0 }) + // Escape closes from anywhere, including while focus sits on the trigger. + useEffect(() => { + if (!isOpen || !onClose) return + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.stopPropagation() + onClose() + } + } + + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [isOpen, onClose]) + + // The portal renders at the end of document.body, so focus is placed and + // restored explicitly rather than following DOM order. + useEffect(() => { + if (!isOpen) return + const previouslyFocused = document.activeElement as HTMLElement | null + + if (autoFocus) { + const focusTarget = + popupRef.current?.querySelector( + 'input, button, [href], select, textarea, [tabindex]:not([tabindex="-1"])', + ) || popupRef.current + focusTarget?.focus() + } + + return () => { + const anchor = anchorRef.current + const restoreTo = + anchor && document.contains(anchor) ? anchor : previouslyFocused + // Only reclaim focus if it is still inside the closing popover. + if ( + popupRef.current && + document.activeElement && + popupRef.current.contains(document.activeElement) + ) { + restoreTo?.focus() + } + } + }, [isOpen, autoFocus, anchorRef]) + // Close on outside click useEffect(() => { if (!isOpen || !onClose) return @@ -123,6 +179,10 @@ export const FloatingPopover: React.FC = ({ return createPortal(
diff --git a/src/essence/Tools/Timeline/TimelineTool.tsx b/src/essence/Tools/Timeline/TimelineTool.tsx index 0503d68bc..157345618 100644 --- a/src/essence/Tools/Timeline/TimelineTool.tsx +++ b/src/essence/Tools/Timeline/TimelineTool.tsx @@ -1,84 +1,84 @@ -import React from 'react' -import { createRoot, type Root } from 'react-dom/client' -import { TimelineAdapter } from './TimelineAdapter' -import { mmgisRequest } from '../_shared/adapters/mmgisAPI' - -type ToolVars = { - width?: number - height?: number -} - -let _root: Root | null = null - -const TimelineTool = { - height: 200, - width: 'full' as number | 'full', - vars: {} as ToolVars, - targetId: null as string | null, - made: false, - - initialize: async function () { - try { - this.vars = - (await mmgisRequest( - 'tool:getVars', - 'timeline', - )) || {} - if (this.vars.width) this.width = this.vars.width - if (this.vars.height) this.height = this.vars.height - } catch (err) { - console.warn( - '[TimelineTool] tool:getVars unavailable:', - err instanceof Error ? err.message : err, - ) - } - - try { - const isMobile = await mmgisRequest('app:isMobile') - if (isMobile) { - this.width = 'full' - this.height = 300 - } - } catch (err) { - console.warn( - '[TimelineTool] app:isMobile unavailable:', - err instanceof Error ? err.message : err, - ) - } - }, - - make: function (targetId?: string) { - this.targetId = typeof targetId === 'string' ? targetId : 'toolPanel' - const container = document.getElementById(this.targetId) - if (!container) { - console.error( - `TimelineTool: container ${this.targetId} not found`, - ) - return - } - // A prior root left mounted on this container would collide with the - // new one, so it is torn down first. - if (_root) { - _root.unmount() - _root = null - } - _root = createRoot(container) - _root.render() - this.made = true - }, - - destroy: function () { - if (_root) { - _root.unmount() - _root = null - } - this.targetId = null - this.made = false - }, - - getUrlString: function () { - return '' - }, -} - -export default TimelineTool +import React from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { TimelineAdapter } from './TimelineAdapter' +import { mmgisRequest } from '../_shared/adapters/mmgisAPI' + +type ToolVars = { + width?: number + height?: number +} + +let _root: Root | null = null + +const TimelineTool = { + height: 200, + width: 'full' as number | 'full', + vars: {} as ToolVars, + targetId: null as string | null, + made: false, + + initialize: async function () { + try { + this.vars = + (await mmgisRequest( + 'tool:getVars', + 'timeline', + )) || {} + if (this.vars.width) this.width = this.vars.width + if (this.vars.height) this.height = this.vars.height + } catch (err) { + console.warn( + '[TimelineTool] tool:getVars unavailable:', + err instanceof Error ? err.message : err, + ) + } + + try { + const isMobile = await mmgisRequest('app:isMobile') + if (isMobile) { + this.width = 'full' + this.height = 300 + } + } catch (err) { + console.warn( + '[TimelineTool] app:isMobile unavailable:', + err instanceof Error ? err.message : err, + ) + } + }, + + make: function (targetId?: string) { + this.targetId = typeof targetId === 'string' ? targetId : 'toolPanel' + const container = document.getElementById(this.targetId) + if (!container) { + console.error( + `TimelineTool: container ${this.targetId} not found`, + ) + return + } + // A prior root left mounted on this container would collide with the + // new one, so it is torn down first. + if (_root) { + _root.unmount() + _root = null + } + _root = createRoot(container) + _root.render() + this.made = true + }, + + destroy: function () { + if (_root) { + _root.unmount() + _root = null + } + this.targetId = null + this.made = false + }, + + getUrlString: function () { + return '' + }, +} + +export default TimelineTool diff --git a/src/essence/Tools/Timeline/config.json b/src/essence/Tools/Timeline/config.json index 34d217fd4..f6546a772 100644 --- a/src/essence/Tools/Timeline/config.json +++ b/src/essence/Tools/Timeline/config.json @@ -1,50 +1,48 @@ -{ - "name": "Timeline", - "description": "Interactive timeline visualization for navigating temporal data with zoom and layer visibility controls", - "defaultIcon": "timeline", - "hasVars": true, - "paths": { - "TimelineTool": "essence/Tools/Timeline/TimelineTool" - }, - "metadata": { - "icon": "timeline", - "requiredOrientation": "horizontal", - "compatiblePositions": ["bottom"], - "preferredPosition": "bottom", - "modernLayoutSupport": true, - "minHeight": 150, - "recommendedHeight": 200 - }, - "config": { - "rows": [ - { - "components": [ - { - "field": "variables.allowPlayback", - "name": "Allow Playback", - "description": "When enabled, the timeline shows the play/pause button and the playback speed selector. When disabled, those two controls are removed; the step and skip-to-start/end buttons remain.", - "type": "checkbox", - "width": 6, - "defaultChecked": true - }, - { - "field": "variables.shownTimeModes", - "name": "Shown Time Modes", - "description": "Which time-granularity buttons appear on the timeline: YEAR, MONTH, DAY, HOUR. Leave empty to show all of them.", - "type": "multiselect", - "width": 6, - "options": ["YEAR", "MONTH", "DAY", "HOUR"] - }, - { - "field": "variables.defaultTimeMode", - "name": "Default Time Mode", - "description": "The time granularity the timeline uses when it first loads. Must be one of the shown time modes; users can still switch between them at runtime.", - "type": "dropdown", - "width": 6, - "options": ["DAY", "MONTH", "HOUR", "YEAR"] - } - ] - } - ] - } -} +{ + "name": "Timeline", + "description": "Interactive timeline visualization for navigating temporal data with zoom and layer visibility controls", + "defaultIcon": "timeline", + "hasVars": true, + "paths": { + "TimelineTool": "essence/Tools/Timeline/TimelineTool" + }, + "metadata": { + "icon": "timeline", + "requiredOrientation": "horizontal", + "compatiblePositions": ["bottom"], + "preferredPosition": "bottom", + "modernLayoutSupport": true + }, + "config": { + "rows": [ + { + "components": [ + { + "field": "variables.allowPlayback", + "name": "Allow Playback", + "description": "When enabled, the timeline shows the play/pause button and the playback speed selector. When disabled, those two controls are removed; the step and skip-to-start/end buttons remain.", + "type": "checkbox", + "width": 6, + "defaultChecked": true + }, + { + "field": "variables.shownTimeModes", + "name": "Shown Time Modes", + "description": "Which time-granularity buttons appear on the timeline: YEAR, MONTH, DAY, HOUR. Leave empty to show all of them.", + "type": "multiselect", + "width": 6, + "options": ["YEAR", "MONTH", "DAY", "HOUR"] + }, + { + "field": "variables.defaultTimeMode", + "name": "Default Time Mode", + "description": "The time granularity the timeline uses when it first loads. Must be one of the shown time modes; users can still switch between them at runtime.", + "type": "dropdown", + "width": 6, + "options": ["YEAR", "MONTH", "DAY", "HOUR"] + } + ] + } + ] + } +} diff --git a/src/essence/Tools/Timeline/lib/FloatingPopover/FloatingPopover.tsx b/src/essence/Tools/Timeline/lib/FloatingPopover/FloatingPopover.tsx index b7d85f734..51904f129 100644 --- a/src/essence/Tools/Timeline/lib/FloatingPopover/FloatingPopover.tsx +++ b/src/essence/Tools/Timeline/lib/FloatingPopover/FloatingPopover.tsx @@ -34,6 +34,26 @@ export const FloatingPopover: React.FC = ({ }) => { const popupRef = useRef(null) const [pos, setPos] = useState({ top: 0, left: 0 }) + // Whether focus currently sits inside the popover. Read on close, when the + // portal's DOM is already detached and document.activeElement has fallen + // back to , so it can't be worked out from the DOM by then. + const focusInsideRef = useRef(false) + + useEffect(() => { + if (!isOpen) return + + const trackFocus = () => { + focusInsideRef.current = !!( + popupRef.current && + document.activeElement && + popupRef.current.contains(document.activeElement) + ) + } + + trackFocus() + document.addEventListener('focusin', trackFocus) + return () => document.removeEventListener('focusin', trackFocus) + }, [isOpen]) // Escape closes from anywhere, including while focus sits on the trigger. useEffect(() => { @@ -65,17 +85,15 @@ export const FloatingPopover: React.FC = ({ } return () => { + // Only reclaim focus if the popover held it as it closed; a click + // elsewhere has already placed focus where the user wants it. + if (!focusInsideRef.current) return + focusInsideRef.current = false + const anchor = anchorRef.current const restoreTo = anchor && document.contains(anchor) ? anchor : previouslyFocused - // Only reclaim focus if it is still inside the closing popover. - if ( - popupRef.current && - document.activeElement && - popupRef.current.contains(document.activeElement) - ) { - restoreTo?.focus() - } + restoreTo?.focus() } }, [isOpen, autoFocus, anchorRef]) @@ -164,11 +182,21 @@ export const FloatingPopover: React.FC = ({ window.addEventListener('resize', updatePosition) window.addEventListener('scroll', updatePosition, true) + // Content can change size after opening — a validation message + // appearing, a calendar switching to a longer month — which moves where + // the popover should sit relative to its anchor. + const contentObserver = + typeof ResizeObserver !== 'undefined' + ? new ResizeObserver(updatePosition) + : null + if (popupRef.current) contentObserver?.observe(popupRef.current) + // Wait a tick and update again in case children render changed dimensions const timeout = setTimeout(updatePosition, 0) return () => { clearTimeout(timeout) + contentObserver?.disconnect() window.removeEventListener('resize', updatePosition) window.removeEventListener('scroll', updatePosition, true) } diff --git a/src/essence/Tools/Timeline/lib/utils/timeUtils.ts b/src/essence/Tools/Timeline/lib/utils/timeUtils.ts index ecadada6b..c7f79c6ec 100644 --- a/src/essence/Tools/Timeline/lib/utils/timeUtils.ts +++ b/src/essence/Tools/Timeline/lib/utils/timeUtils.ts @@ -32,8 +32,11 @@ export function generateTimeTicks( const ticks: Date[] = [] const { unit, value } = getTimeStep(mode) - const start = moment(startTime) - const end = moment(endTime) + // The whole plugin reads and writes UTC, so ticks snap to UTC unit + // boundaries. Local snapping would offset every label from the date it + // carries by the viewer's UTC offset. + const start = moment.utc(startTime) + const end = moment.utc(endTime) if (!end.isAfter(start)) return [startTime] // Snapping to the unit boundary can land before the domain; step forward @@ -83,10 +86,11 @@ export function generateTimeTicks( } /** - * Format date based on time mode + * Format date based on time mode. UTC, matching the header, the layer bar + * tooltips and the scrubber's accessible value. */ export function formatDateByMode(date: Date, mode: TimeMode): string { - const m = moment(date) + const m = moment.utc(date) switch (mode) { case 'YEAR': return m.format('YYYY') @@ -100,31 +104,16 @@ export function formatDateByMode(date: Date, mode: TimeMode): string { } /** - * Calculate zoom extents for the timeline + * Move an instant by whole time-mode units, in UTC. Local calendar arithmetic + * would make a step across the viewer's daylight-saving boundary 23 or 25 + * hours long, drifting the displayed UTC clock by an hour each time. */ -export function calculateZoomExtent( - totalDuration: number, - mode: TimeMode -): [number, number] { - // Minimum zoom shows at least 2 units - // Maximum zoom shows the entire range - const minUnits = 2 - const maxUnits = Math.ceil(totalDuration / getMillisecondsPerUnit(mode)) - - return [1, Math.max(maxUnits / minUnits, 1)] -} - -function getMillisecondsPerUnit(mode: TimeMode): number { - switch (mode) { - case 'YEAR': - return 365 * 24 * 60 * 60 * 1000 // Approximate - case 'MONTH': - return 30 * 24 * 60 * 60 * 1000 // Approximate - case 'DAY': - return 24 * 60 * 60 * 1000 - case 'HOUR': - return 60 * 60 * 1000 - } +export function stepTime(date: Date, mode: TimeMode, steps: number): Date { + const { unit, value } = getTimeStep(mode) + return moment + .utc(date) + .add(steps * value, unit) + .toDate() } /** diff --git a/tests/unit/timelineTimeUtils.spec.js b/tests/unit/timelineTimeUtils.spec.js index 9001360ae..70264c1a4 100644 --- a/tests/unit/timelineTimeUtils.spec.js +++ b/tests/unit/timelineTimeUtils.spec.js @@ -1,12 +1,27 @@ -import { describe, it, expect } from 'vitest' +import { describe, it, expect, beforeAll, afterAll } from 'vitest' import { generateTimeTicks, getTimeStep, clampDate, + formatDateByMode, + stepTime, } from '../../src/essence/Tools/Timeline/lib/utils/timeUtils' const iso = (d) => d.toISOString() +// The timeline displays UTC everywhere, so every helper has to compute in UTC +// too. Running these under a non-UTC zone is what makes that observable: on a +// UTC machine local and UTC arithmetic agree and the assertions pass either way. +const withTimeZone = (zone) => { + const original = process.env.TZ + beforeAll(() => { + process.env.TZ = zone + }) + afterAll(() => { + process.env.TZ = original + }) +} + describe('getTimeStep', () => { it('maps every time mode to a one-unit step', () => { expect(getTimeStep('YEAR')).toEqual({ unit: 'years', value: 1 }) @@ -72,6 +87,47 @@ describe('generateTimeTicks', () => { }) }) +describe('UTC handling away from UTC', () => { + // UTC+9, no daylight saving: a local day boundary sits at 15:00Z. + withTimeZone('Asia/Tokyo') + + it('snaps day ticks to UTC midnight, not local midnight', () => { + const start = new Date('2026-08-01T00:00:00Z') + const end = new Date('2026-08-05T00:00:00Z') + + for (const tick of generateTimeTicks(start, end, 'DAY')) { + expect(tick.getUTCHours()).toBe(0) + } + }) + + it('labels an instant with its UTC date', () => { + const instant = new Date('2026-08-03T00:00:00Z') + expect(formatDateByMode(instant, 'DAY')).toBe('Aug 3') + expect(formatDateByMode(instant, 'HOUR')).toBe('00:00') + }) +}) + +describe('stepTime', () => { + it('advances and rewinds by one unit of the mode', () => { + const from = new Date('2026-08-03T12:00:00Z') + expect(iso(stepTime(from, 'DAY', 1))).toBe('2026-08-04T12:00:00.000Z') + expect(iso(stepTime(from, 'DAY', -1))).toBe('2026-08-02T12:00:00.000Z') + expect(iso(stepTime(from, 'HOUR', 1))).toBe('2026-08-03T13:00:00.000Z') + expect(iso(stepTime(from, 'MONTH', 1))).toBe('2026-09-03T12:00:00.000Z') + expect(iso(stepTime(from, 'YEAR', 1))).toBe('2027-08-03T12:00:00.000Z') + }) + + describe('across a daylight-saving boundary', () => { + // US DST starts 2026-03-08; a local "one day" step spans 23 hours there. + withTimeZone('America/New_York') + + it('keeps a day step exactly 24 hours', () => { + const before = new Date('2026-03-07T12:00:00Z') + expect(iso(stepTime(before, 'DAY', 1))).toBe('2026-03-08T12:00:00.000Z') + }) + }) +}) + describe('clampDate', () => { const min = new Date('2026-01-01T00:00:00Z') const max = new Date('2026-12-31T00:00:00Z')