diff --git a/docs/adr/20260209-plugin-communication-model.md b/docs/adr/20260209-plugin-communication-model.md index f5580e30b..570fa9f48 100644 --- a/docs/adr/20260209-plugin-communication-model.md +++ b/docs/adr/20260209-plugin-communication-model.md @@ -895,7 +895,7 @@ All 5 existing communication mechanisms can be migrated to the unified Event Bus │ MIGRATION MAPPING │ ├──────────────────────────────────────────────────────────────────────────┤ │ │ -│ L_.subscribeTimeChange(id, cb) → mmgisAPI.on('time:change', cb) │ +│ L_.subscribeTimeChange(id, cb) → mmgisAPI.on('time:changed', cb) │ │ L_.subscribeOnLayerToggle(id, cb) → mmgisAPI.on('layer:toggle', cb) │ │ L_.unsubscribeTimeChange(id) → unsubscribe() return value │ │ │ @@ -934,7 +934,7 @@ L_.subscribeTimeChange('MyTool', callback); document.dispatchEvent(new CustomEvent('toolChange', { detail })); // After (same mmgisAPI object, new methods) -mmgisAPI.on('time:change', callback); +mmgisAPI.on('time:changed', callback); mmgisAPI.emit('tool:change', detail); ``` diff --git a/docs/pages/APIs/JavaScript/Main/Event-Bus-API.md b/docs/pages/APIs/JavaScript/Main/Event-Bus-API.md index 48ba06499..c3221c59c 100644 --- a/docs/pages/APIs/JavaScript/Main/Event-Bus-API.md +++ b/docs/pages/APIs/JavaScript/Main/Event-Bus-API.md @@ -299,13 +299,22 @@ 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:changeRequested` | `{ startTime, currentTime, endTime }` | Emitted by a plugin to ask core to commit a new time window. ISO 8601 strings. Ignored while time is disabled for the mission | | `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}`) }) + +// Ask core to move the current time. Core commits it and broadcasts the +// result on 'time:changed' — including back to the plugin that asked. +window.mmgisAPI.emit('time:changeRequested', { + startTime: '2024-01-01T00:00:00.000Z', + endTime: '2024-12-31T23:59:59.000Z', + currentTime: '2024-06-15T12:00:00.000Z' +}) ``` ### Legend Events @@ -384,6 +393,7 @@ const newState = await window.mmgisAPI.request('layers:toggle', 'myLayerName') | Provider | Params | Returns | Description | |----------|--------|---------|-------------| +| `time:isEnabled` | none | `boolean` | Whether the mission has time enabled. The getters below return `null` both when time is off and when it is on but not yet seeded | | `time:getCurrent` | none | `string` | Get current time | | `time:getStart` | none | `string` | Get start time | | `time:getEnd` | none | `string` | Get end time | @@ -391,6 +401,7 @@ const newState = await window.mmgisAPI.request('layers:toggle', 'myLayerName') ```javascript // Get time state +const timeEnabled = await window.mmgisAPI.request('time:isEnabled') const current = await window.mmgisAPI.request('time:getCurrent') const start = await window.mmgisAPI.request('time:getStart') const end = await window.mmgisAPI.request('time:getEnd') @@ -469,7 +480,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/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 46c2a201c..02e32b787 100644 --- a/src/essence/Basics/UserInterface_/UserInterfaceModern_.css +++ b/src/essence/Basics/UserInterface_/UserInterfaceModern_.css @@ -128,6 +128,20 @@ 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%; +} + /* Stacked Tool Cards (default clickable cards in panel body) */ .ui-tool-card-stacked { display: flex; 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..1ec53d383 --- /dev/null +++ b/src/essence/Tools/Timeline/Timeline.css @@ -0,0 +1,241 @@ +.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; +} + +/* 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; +} + +.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; +} + +/* 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; + 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. 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; + 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; + font-family: var(--theme-font-ui, 'Public Sans', system-ui, -apple-system, sans-serif); +} + +@keyframes tooltipFadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@media (prefers-reduced-motion: reduce) { + .timeline-info-tooltip-portal { + animation: 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..50f7f4d46 --- /dev/null +++ b/src/essence/Tools/Timeline/TimelineAdapter.tsx @@ -0,0 +1,493 @@ +import React, { useState, useEffect, useCallback, useRef } from 'react' +import { FloatingPopover } from './lib/FloatingPopover' +import { + mmgisRequest, + mmgisOn, + mmgisEmit, + mmgisGetLayerConfigs, + mmgisGetVisibleLayers, + mmgisIsTimeEnabled, + type LayerConfig, +} from '../_shared/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 { stepTime, 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. The + // match is consumed so a later external commit landing on the + // same instant is treated as the real change it is. + lastRequestedRef.current = null + 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 canStepBackward = currentTime > startTime + const canStepForward = currentTime < endTime + + const handleStepForward = useCallback(() => { + handleCurrentTimeChange(stepTime(currentTimeRef.current, timeMode, 1)) + }, [timeMode, handleCurrentTimeChange]) + + const handleStepBackward = useCallback(() => { + handleCurrentTimeChange(stepTime(currentTimeRef.current, timeMode, -1)) + }, [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 + + // 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 = stepTime(currentTimeRef.current, timeMode, 1) + 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 new file mode 100644 index 000000000..157345618 --- /dev/null +++ b/src/essence/Tools/Timeline/TimelineTool.tsx @@ -0,0 +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 diff --git a/src/essence/Tools/Timeline/config.json b/src/essence/Tools/Timeline/config.json new file mode 100644 index 000000000..f6546a772 --- /dev/null +++ b/src/essence/Tools/Timeline/config.json @@ -0,0 +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 + }, + "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 new file mode 100644 index 000000000..51904f129 --- /dev/null +++ b/src/essence/Tools/Timeline/lib/FloatingPopover/FloatingPopover.tsx @@ -0,0 +1,228 @@ +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 + /** 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 +} + +export const FloatingPopover: React.FC = ({ + anchorRef, + isOpen, + onClose, + placement = 'bottom', + offset = 8, + className = '', + id, + label, + autoFocus = false, + children +}) => { + 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(() => { + 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 () => { + // 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 + restoreTo?.focus() + } + }, [isOpen, autoFocus, anchorRef]) + + // 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) + + // 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) + } + }, [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..0e09d4404 --- /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.utc(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..598373ddc --- /dev/null +++ b/src/essence/Tools/Timeline/lib/geo/PlaybackControls/PlaybackControls.tsx @@ -0,0 +1,25 @@ +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 + /** False once the current time sits at the end of the range. */ + canStepForward?: boolean + /** False once the current time sits at the start of the range. */ + canStepBackward?: 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..c7f79c6ec --- /dev/null +++ b/src/essence/Tools/Timeline/lib/utils/timeUtils.ts @@ -0,0 +1,126 @@ +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) + + // 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 + // until the first tick is inside it so the axis never renders a label for + // an instant left of startTime. + let current = start.clone().startOf(unit as moment.unitOfTime.StartOf) + + const totalSteps = end.diff(current, unit as moment.unitOfTime.Diff) / value + let stepMultiplier = 1 + if (totalSteps > maxTicks) { + stepMultiplier = Math.ceil(totalSteps / maxTicks) + + // Round the multiplier to a readable interval + 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 + } + } + + const step = value * stepMultiplier + while (current.isBefore(start)) { + current = current.add(step, unit) + } + + let count = 0 + while (current.isBefore(end) && count < maxTicks) { + ticks.push(current.toDate()) + current = current.clone().add(step, unit) + count++ + } + + // Close the axis on the domain's end, unless a tick already sits there. + const last = ticks[ticks.length - 1] + if (!last || last.getTime() !== endTime.getTime()) { + ticks.push(endTime) + } + + return ticks +} + +/** + * 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.utc(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') + } +} + +/** + * 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 stepTime(date: Date, mode: TimeMode, steps: number): Date { + const { unit, value } = getTimeStep(mode) + return moment + .utc(date) + .add(steps * value, unit) + .toDate() +} + +/** + * 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/essence/Tools/_shared/adapters/mmgisAPI.ts b/src/essence/Tools/_shared/adapters/mmgisAPI.ts index 9ca8bcf47..ae183c464 100644 --- a/src/essence/Tools/_shared/adapters/mmgisAPI.ts +++ b/src/essence/Tools/_shared/adapters/mmgisAPI.ts @@ -16,6 +16,19 @@ export type MapScreenshotResult = { height: number } +/** The subset of a mission layer config that plugins read off the bus. */ +export type LayerConfig = { + name?: string + display_name?: string + time?: { + enabled?: boolean + dataStartTime?: string + dataEndTime?: string + [key: string]: unknown + } + [key: string]: unknown +} + export type ViewState = { missionName: string | null time: string | null @@ -87,6 +100,34 @@ export const mmgisGetViewState = (): Promise => { return mmgisRequestIfProvided('map:getViewState') } +/** + * Every layer's config, keyed by layer UUID. Core registers this handler in + * Layers_.fina(), after the mission's layers load and after tools mount, so + * drive the call with useMMGISHandlerReady rather than requesting at mount. + */ +export const mmgisGetLayerConfigs = (): Promise | null> => { + return mmgisRequestIfProvided>( + 'layers:getAllConfigs', + ) +} + +/** Per-layer visibility, keyed by layer UUID. Registered as late as + * mmgisGetLayerConfigs; the same readiness caveat applies. */ +export const mmgisGetVisibleLayers = (): Promise | null> => { + return mmgisRequestIfProvided>('layers:getVisible') +} + +/** Whether the mission has time enabled at all. */ +export const mmgisIsTimeEnabled = (): Promise => { + return mmgisRequestIfProvided('time:isEnabled') +} + /** * Copies text to the clipboard via core's app:copyText handler; true on * success. Against cores that predate the handler — including ones whose diff --git a/src/styles/_theme-export.scss b/src/styles/_theme-export.scss index 9aef520c8..602ddc922 100644 --- a/src/styles/_theme-export.scss +++ b/src/styles/_theme-export.scss @@ -15,6 +15,14 @@ --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 hover surface. USWDS leaves the lightest grade unset by default; + brands without one fall back to their own primary-lighter. */ + --theme-color-primary-lightest: #{if( + $theme-color-primary-lightest, + color("primary-lightest"), + color("primary-lighter") + )}; /* 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/tests/unit/timelineTimeUtils.spec.js b/tests/unit/timelineTimeUtils.spec.js new file mode 100644 index 000000000..70264c1a4 --- /dev/null +++ b/tests/unit/timelineTimeUtils.spec.js @@ -0,0 +1,144 @@ +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 }) + expect(getTimeStep('MONTH')).toEqual({ unit: 'months', value: 1 }) + expect(getTimeStep('DAY')).toEqual({ unit: 'days', value: 1 }) + expect(getTimeStep('HOUR')).toEqual({ unit: 'hours', value: 1 }) + }) +}) + +describe('generateTimeTicks', () => { + it('keeps every tick inside the domain', () => { + const start = new Date('2026-07-01T00:00:00Z') + const end = new Date('2026-07-31T00:00:00Z') + + for (const mode of ['YEAR', 'MONTH', 'DAY', 'HOUR']) { + const ticks = generateTimeTicks(start, end, mode) + for (const tick of ticks) { + expect(tick.getTime()).toBeGreaterThanOrEqual(start.getTime()) + expect(tick.getTime()).toBeLessThanOrEqual(end.getTime()) + } + } + }) + + it('closes the axis on the domain end exactly once', () => { + const start = new Date('2026-01-01T00:00:00Z') + const end = new Date('2026-01-05T00:00:00Z') + const ticks = generateTimeTicks(start, end, 'DAY') + + expect(iso(ticks[ticks.length - 1])).toBe(iso(end)) + expect(ticks.filter((t) => t.getTime() === end.getTime())).toHaveLength(1) + }) + + it('returns a single tick when the domain is empty or inverted', () => { + const instant = new Date('2026-03-01T00:00:00Z') + expect(generateTimeTicks(instant, instant, 'DAY')).toEqual([instant]) + + const earlier = new Date('2026-02-01T00:00:00Z') + expect(generateTimeTicks(instant, earlier, 'DAY')).toEqual([instant]) + }) + + it('does not emit a boundary label left of the domain when the range is shorter than the unit', () => { + const start = new Date('2026-07-01T00:00:00Z') + const end = new Date('2026-07-31T00:00:00Z') + const ticks = generateTimeTicks(start, end, 'YEAR') + + // 2026-01-01 snaps before the domain and must not appear. + expect(ticks.some((t) => t.getUTCMonth() === 0 && t.getUTCDate() === 1)).toBe(false) + }) + + it('honours maxTicks for a long range', () => { + const start = new Date('2000-01-01T00:00:00Z') + const end = new Date('2026-01-01T00:00:00Z') + const ticks = generateTimeTicks(start, end, 'HOUR', 50) + + expect(ticks.length).toBeLessThanOrEqual(51) // maxTicks + the end tick + expect(ticks.length).toBeGreaterThan(1) + }) + + it('terminates for a multi-decade hourly range', () => { + const start = new Date('1990-01-01T00:00:00Z') + const end = new Date('2026-01-01T00:00:00Z') + expect(() => generateTimeTicks(start, end, 'HOUR')).not.toThrow() + }) +}) + +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') + + it('returns the bound when the date falls outside the range', () => { + expect(clampDate(new Date('2025-01-01T00:00:00Z'), min, max)).toBe(min) + expect(clampDate(new Date('2027-01-01T00:00:00Z'), min, max)).toBe(max) + }) + + it('passes an in-range date through untouched', () => { + const inside = new Date('2026-06-01T00:00:00Z') + expect(clampDate(inside, min, max)).toBe(inside) + }) +})