From a9afd680db4ff480cb9702dbe29616591bf44081 Mon Sep 17 00:00:00 2001 From: Sandesh Pandey Date: Tue, 2 Jun 2026 22:29:09 -0500 Subject: [PATCH 1/2] Initial implementation of timeline component --- src/essence/Tools/Timeline/Timeline.css | 375 ++++++++++++++++++ .../Tools/Timeline/TimelineAdapter.tsx | 281 +++++++++++++ src/essence/Tools/Timeline/TimelineTool.tsx | 81 ++++ .../Tools/Timeline/adapters/mmgisAPI.ts | 56 +++ src/essence/Tools/Timeline/config.json | 17 + .../lib/geo/DateSelector/DateSelector.tsx | 108 +++++ .../lib/geo/LayerTimeline/LayerTimeline.tsx | 48 +++ .../geo/PlaybackControls/PlaybackControls.tsx | 55 +++ .../geo/TimeModeControl/TimeModeControl.tsx | 29 ++ .../lib/geo/TimelineView/TimelineView.tsx | 286 +++++++++++++ src/essence/Tools/Timeline/lib/index.ts | 9 + src/essence/Tools/Timeline/lib/types.ts | 13 + .../Tools/Timeline/lib/utils/timeUtils.ts | 126 ++++++ 13 files changed, 1484 insertions(+) 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/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/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 diff --git a/src/essence/Tools/Timeline/Timeline.css b/src/essence/Tools/Timeline/Timeline.css new file mode 100644 index 000000000..9d9ec85f1 --- /dev/null +++ b/src/essence/Tools/Timeline/Timeline.css @@ -0,0 +1,375 @@ +.timeline { + display: flex; + flex-direction: column; + height: 100%; + width: 100%; + background: var(--theme-color-white, #ffffff); + color: var(--theme-color-base-darkest, #1b1b1b); + font-family: var(--theme-font-ui, 'Inter', system-ui, -apple-system, sans-serif); + overflow: hidden; +} + + +.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: 10px 15px; + 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-content { + flex: 1; + overflow: hidden; + position: relative; + display: flex; + flex-direction: column; +} + +/* Date Selector */ +.date-selector { + position: relative; + display: flex; + align-items: center; + gap: 8px; +} + +.date-selector-display { + display: flex; + align-items: center; + gap: 16px; +} + +.date-selector-main-button { + display: flex; + align-items: center; + gap: 8px; + background: transparent; + border: none; + padding: 0; + cursor: pointer; + color: var(--theme-color-base-darkest, #1b1b1b); + transition: color 0.2s ease; +} + +.date-selector-main-button:hover { + color: var(--timeline-brand, #0b7a8a); +} + +.date-selector-main-button .calendar-icon { + color: var(--timeline-brand, #0b7a8a); + font-size: 20px; +} + +.date-selector-main-button .date-text { + font-size: 14px; + font-weight: 700; + white-space: nowrap; +} + +.date-selector-divider { + width: 1px; + height: 24px; + background-color: var(--theme-color-base-lighter, #dfe1e2); +} + +.compare-date-button { + background: transparent; + border: none; + color: var(--timeline-brand, #0b7a8a); + font-size: 14px; + font-weight: 600; + cursor: pointer; + padding: 4px 8px; + transition: opacity 0.2s ease; +} + +.compare-date-button:hover { + opacity: 0.8; +} + +.date-selector-dropdown { + position: absolute; + top: calc(100% + 10px); + left: 0; + background: var(--theme-color-white, #ffffff); + border: 1px solid var(--theme-color-base-light, #a9aeb1); + border-radius: 4px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + padding: 16px; + z-index: 1000; + min-width: 250px; +} + +.date-input-form { + display: flex; + flex-direction: column; + gap: 8px; +} + +.date-input-form label { + font-size: 14px; + font-weight: 600; + color: var(--theme-color-base-darker, #565c65); +} + +.date-input-form input[type='date'] { + padding: 8px; + border: 1px solid var(--theme-color-base-light, #a9aeb1); + border-radius: 4px; + font-family: inherit; + font-size: 14px; +} + +.date-input-form input[type='date']:focus { + outline: none; + border-color: var(--timeline-brand, #0b7a8a); +} + +.date-input-form .date-submit-button { + padding: 8px 16px; + background: var(--timeline-brand, #0b7a8a); + color: var(--theme-color-white, #ffffff); + border: none; + border-radius: 4px; + font-weight: 600; + cursor: pointer; + transition: background 0.2s ease; +} + +.date-input-form .date-submit-button:hover { + background: #096472; +} + +.date-range-info { + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid var(--theme-color-base-lighter, #dfe1e2); +} + +.date-range-info small { + font-size: 12px; + color: var(--theme-color-base, #71767a); +} + +/* Playback Controls */ +.playback-controls { + display: flex; + align-items: center; + gap: 12px; + color: var(--timeline-brand, #0b7a8a); +} + +.playback-btn { + background: transparent; + border: none; + padding: 4px; + cursor: pointer; + color: inherit; + display: flex; + align-items: center; + justify-content: center; + transition: opacity 0.2s ease; +} + +.playback-btn:hover { + opacity: 0.7; +} + +.play-btn { + color: var(--timeline-brand, #0b7a8a); +} + +/* Time Mode Control */ +.time-mode-control { + display: flex; + background: var(--theme-color-white, #ffffff); +} + +.time-mode-button { + padding: 6px 16px; + background: transparent; + border: none; + font-size: 13px; + font-weight: 600; + color: var(--theme-color-base, #71767a); + cursor: pointer; + transition: all 0.2s ease; +} + +.time-mode-button:hover { + color: var(--theme-color-base-darkest, #1b1b1b); +} + +.time-mode-button.active { + background: var(--timeline-brand, #0b7a8a); + color: var(--theme-color-white, #ffffff); +} + +/* Timeline View Layout */ +.timeline-view-container { + display: flex; + width: 100%; + height: 100%; + background: var(--theme-color-white, #ffffff); +} + +.timeline-sidebar { + width: 160px; + flex-shrink: 0; + border-right: 1px solid var(--theme-color-base-lighter, #dfe1e2); + display: flex; + flex-direction: column; +} + +.timeline-sidebar-header { + height: 24px; /* matches timeline-top-bar */ + border-bottom: 1px solid var(--theme-color-base-lighter, #dfe1e2); + box-sizing: border-box; +} + +.timeline-sidebar-layers { + display: flex; + flex-direction: column; + padding: 0; +} + +.layer-item { + display: flex; + align-items: center; + height: 20px; + padding: 0 10px; + border-bottom: 1px solid var(--theme-color-base-lighter, #dfe1e2); + box-sizing: border-box; +} + +.layer-color-dot { + width: 8px; + height: 8px; + border-radius: 50%; + margin-right: 8px; + flex-shrink: 0; +} + +.layer-name { + font-size: 11px; + font-weight: 600; + color: var(--theme-color-base-darker, #565c65); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.timeline-svg-container { + flex-grow: 1; + overflow: hidden; + position: relative; + display: flex; + flex-direction: column; +} + +.timeline-top-bar { + height: 24px; + background-color: var(--theme-color-base-lightest, #f9f9f9); + border-bottom: 1px solid var(--theme-color-base-lighter, #dfe1e2); + overflow: hidden; +} + +.timeline-top-axis .domain, +.timeline-top-axis .tick line { + display: none; +} + +.timeline-top-axis .tick text { + fill: var(--theme-color-base, #71767a); + font-size: 11px; + font-weight: 600; + font-family: var(--theme-font-ui, 'Inter', system-ui, -apple-system, sans-serif); +} + +.timeline-axis .domain { + display: none; +} + +.timeline-axis .tick line { + stroke: var(--theme-color-base-lighter, #dfe1e2); + stroke-dasharray: 2 2; +} + +.timeline-axis .tick text { + fill: var(--theme-color-base-darker, #565c65); + font-size: 11px; + font-family: var(--theme-font-ui, 'Inter', system-ui, -apple-system, sans-serif); +} + +.layer-timelines { + pointer-events: none; +} + +.layer-row-bg { + stroke: var(--theme-color-base-lighter, #dfe1e2); + stroke-width: 1px; + stroke-dasharray: 4 4; +} + +.layer-time-range { + transition: opacity 0.2s ease; +} + +.timeline-scrubber { + pointer-events: all; +} + +.timeline-instructions { + text-align: center; + padding: 8px 12px; + font-size: 11px; + color: var(--theme-color-base, #71767a); + background: var(--theme-color-white, #ffffff); + border-top: 1px solid var(--theme-color-base-lighter, #dfe1e2); +} + +/* 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..d58b44dae --- /dev/null +++ b/src/essence/Tools/Timeline/TimelineAdapter.tsx @@ -0,0 +1,281 @@ +import React, { useState, useEffect, useCallback } from 'react' +import moment from 'moment' +import { mmgisRequest, mmgisOn, mmgisEmit, mmgisGetLayerConfigs, mmgisGetRawConfigData } from './adapters/mmgisAPI' +import { + TimelineView, + TimeModeControl, + DateSelector, + PlaybackControls, + 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 [layers, setLayers] = useState([]) + const [isReady, setIsReady] = useState(false) + + const [isPlaying, setIsPlaying] = useState(false) + + useEffect(() => { + const fetchLayers = () => { + const configs = mmgisGetLayerConfigs() + const rawConfig = mmgisGetRawConfigData() + 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 + } + + console.log('[Timeline] Fetching layer configs for timeline:', { configs, rawLayers }) + + Object.keys(configs).forEach(layerName => { + const layer = configs[layerName] + + let start = startTime + let end = endTime + let color = '#808080' // default grey + + if (layer.time && layer.time.enabled) { + let rawLayer = null + if (rawLayers.length > 0) { + rawLayer = findRawLayer(rawLayers, layerName) + } + const timeConfig = rawLayer?.time || layer.time + + if (timeConfig.start) { + const parsedStart = new Date(timeConfig.start) + if (!isNaN(parsedStart.getTime())) { + start = parsedStart + } + } + if (timeConfig.end) { + const parsedEnd = timeConfig.end === 'now' ? new Date() : new Date(timeConfig.end) + if (!isNaN(parsedEnd.getTime())) { + end = parsedEnd + } + } + + color = '#FF4D85' // pink for time enabled layers + } + + newLayers.push({ + name: layerName, + displayName: layer.display_name || layer.name || layerName, + color: color, + timeRanges: [ + { start, end } + ] + }) + }) + + setLayers(newLayers) + } + + fetchLayers() + }, [startTime, endTime]) + + // 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:userChanged event for TimeControl to respond + const payload = { + startTime: startTime.toISOString(), + endTime: endTime.toISOString(), + currentTime: newTime.toISOString(), + } + console.log('[Timeline] Emitting time:userChanged:', payload) + mmgisEmit('time:userChanged', payload) + }, + [startTime, endTime] + ) + + // Handle start date change + const handleStartDateChange = useCallback( + (newStart: Date) => { + setStartTime(newStart) + + // Emit time:userChanged event for TimeControl to respond + mmgisEmit('time:userChanged', { + startTime: newStart.toISOString(), + endTime: endTime.toISOString(), + currentTime: currentTime.toISOString(), + }) + }, + [endTime, currentTime] + ) + + // 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) + // Adjust interval speed based on mode (e.g. DAY is faster than MONTH) + const speed = 1000 + + const interval = setInterval(() => { + setCurrentTime(prev => { + const nextTime = moment(prev).add(value, unit as moment.unitOfTime.DurationConstructor).toDate() + if (nextTime <= endTime) { + // Emit time:userChanged event to notify TimeControl + const payload = { + startTime: startTime.toISOString(), + endTime: endTime.toISOString(), + currentTime: nextTime.toISOString(), + } + console.log('[Timeline] Playback emitting time:userChanged:', payload) + mmgisEmit('time:userChanged', payload) + return nextTime + } else { + console.log('[Timeline] Playback reached end, stopping') + setIsPlaying(false) + return prev + } + }) + }, speed) + + return () => clearInterval(interval) + }, [isPlaying, timeMode, endTime, startTime]) + + if (!isReady) { + return ( +
+
Loading timeline...
+
+ ) + } + + return ( +
+
+
+ +
+
+ setIsPlaying(!isPlaying)} + onStepForward={handleStepForward} + onStepBackward={handleStepBackward} + onGoToStart={handleGoToStart} + onGoToEnd={handleGoToEnd} + /> +
+
+ +
+
+
+ +
+
+ ) +} + + 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..e420d2c9f --- /dev/null +++ b/src/essence/Tools/Timeline/adapters/mmgisAPI.ts @@ -0,0 +1,56 @@ +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 +} + +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?.() || {} +} diff --git a/src/essence/Tools/Timeline/config.json b/src/essence/Tools/Timeline/config.json new file mode 100644 index 000000000..151be46af --- /dev/null +++ b/src/essence/Tools/Timeline/config.json @@ -0,0 +1,17 @@ +{ + "name": "Timeline", + "description": "Interactive timeline visualization for navigating temporal data with zoom and layer visibility controls", + "defaultIcon": "timeline", + "paths": { + "TimelineTool": "essence/Tools/Timeline/TimelineTool" + }, + "metadata": { + "icon": "timeline", + "requiredOrientation": "horizontal", + "compatiblePositions": ["bottom"], + "preferredPosition": "bottom", + "modernLayoutSupport": true, + "minHeight": 150, + "recommendedHeight": 200 + } +} 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..ec1211665 --- /dev/null +++ b/src/essence/Tools/Timeline/lib/geo/DateSelector/DateSelector.tsx @@ -0,0 +1,108 @@ +import React, { useState, useRef, useEffect } from 'react' +import moment from 'moment' + +export interface DateSelectorProps { + selectedDate: Date + startTime: Date + endTime: Date + onDateChange: (date: Date) => void +} + +export const DateSelector: React.FC = ({ + selectedDate, + startTime, + endTime, + onDateChange, +}) => { + const [isOpen, setIsOpen] = useState(false) + const [inputValue, setInputValue] = useState('') + const dropdownRef = useRef(null) + + // Format the selected date for display + const formattedDate = moment(selectedDate).format('MMM, D YYYY') + + // Handle click outside to close dropdown + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsOpen(false) + } + } + + if (isOpen) { + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + } + }, [isOpen]) + + const handleDateClick = () => { + setIsOpen(!isOpen) + setInputValue(moment(selectedDate).format('YYYY-MM-DD')) + } + + const handleInputChange = (e: React.ChangeEvent) => { + setInputValue(e.target.value) + } + + const handleInputSubmit = (e: React.FormEvent) => { + e.preventDefault() + const newDate = moment(inputValue) + + if (newDate.isValid()) { + const date = newDate.toDate() + // Clamp to valid range + if (date >= startTime && date <= endTime) { + onDateChange(date) + setIsOpen(false) + } else { + alert('Date must be within the timeline range') + } + } + } + + return ( +
+
+ + +
+ + +
+ + {isOpen && ( +
+
+ + + +
+
+ + Range: {moment(startTime).format('MMM D, YYYY')} -{' '} + {moment(endTime).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..4991df27f --- /dev/null +++ b/src/essence/Tools/Timeline/lib/geo/LayerTimeline/LayerTimeline.tsx @@ -0,0 +1,48 @@ +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 +} + +export const LayerTimeline: React.FC = ({ + layer, + xScale, + y, + height, +}) => { + return ( + + {/* Time range bars */} + {layer.timeRanges.map((range, index) => { + const x1 = xScale(range.start) + const x2 = xScale(range.end) + const width = Math.max(x2 - x1, 2) // Minimum 2px width + + return ( + + + {layer.displayName} + {'\n'} + {range.start.toISOString()} to {range.end.toISOString()} + + + ) + })} + + ) +} 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..dd1ab8e0a --- /dev/null +++ b/src/essence/Tools/Timeline/lib/geo/PlaybackControls/PlaybackControls.tsx @@ -0,0 +1,55 @@ +import React from 'react' + +export interface PlaybackControlsProps { + onPlayToggle?: () => void + onStepForward?: () => void + onStepBackward?: () => void + onGoToStart?: () => void + onGoToEnd?: () => void + isPlaying?: boolean +} + +export const PlaybackControls: React.FC = ({ + onPlayToggle, + onStepForward, + onStepBackward, + onGoToStart, + onGoToEnd, + isPlaying = false +}) => { + return ( +
+ + + + + +
+ ) +} 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..d9057c892 --- /dev/null +++ b/src/essence/Tools/Timeline/lib/geo/TimeModeControl/TimeModeControl.tsx @@ -0,0 +1,29 @@ +import React from 'react' +import type { TimeMode } from '../../types' + +export interface TimeModeControlProps { + currentMode: TimeMode + onModeChange: (mode: TimeMode) => void +} + +const modes: TimeMode[] = ['MONTH', 'DAY', 'HOUR'] + +export const TimeModeControl: React.FC = ({ + currentMode, + onModeChange, +}) => { + return ( +
+ {modes.map((mode) => ( + + ))} +
+ ) +} 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..bc8fc5c51 --- /dev/null +++ b/src/essence/Tools/Timeline/lib/geo/TimelineView/TimelineView.tsx @@ -0,0 +1,286 @@ +import React, { useRef, useEffect, useState, useCallback } from 'react' +import { scaleTime } from 'd3-scale' +import { axisBottom } from 'd3-axis' +import { select } from 'd3-selection' +import { zoom, zoomIdentity, ZoomBehavior } from 'd3-zoom' +import type { TimeMode, LayerTimeData } from '../../types' +import { generateTimeTicks, formatDateByMode, clampDate } from '../../utils/timeUtils' +import { LayerTimeline } from '../LayerTimeline/LayerTimeline' + +export interface TimelineViewProps { + startTime: Date + endTime: Date + currentTime: Date + timeMode: TimeMode + layers: LayerTimeData[] + onCurrentTimeChange: (time: Date) => void +} + +export const TimelineView: React.FC = ({ + startTime, + endTime, + currentTime, + timeMode, + layers, + onCurrentTimeChange, +}) => { + const containerRef = useRef(null) + const svgRef = useRef(null) + const axisRef = useRef(null) + const topAxisRef = useRef(null) + const scrubberRef = useRef(null) + const [isDragging, setIsDragging] = useState(false) + const [dimensions, setDimensions] = useState({ width: 800, height: 200 }) + const [zoomTransform, setZoomTransform] = useState(zoomIdentity) + + const axisHeight = 24 // Space for the bottom axis + const layerBarHeight = 24 + const topBarHeight = 24 // Space for top axis + + // Calculate total height needed for layers + const totalLayersHeight = layers.length * layerBarHeight + + // Calculate required SVG height + const requiredHeight = axisHeight + totalLayersHeight + + // Update dimensions on resize + useEffect(() => { + if (!containerRef.current) return + + const resizeObserver = new ResizeObserver((entries) => { + for (const entry of entries) { + const { width } = entry.contentRect + setDimensions({ width, height: requiredHeight }) + } + }) + + resizeObserver.observe(containerRef.current) + return () => resizeObserver.disconnect() + }, [requiredHeight]) + + // Create scales + const xScale = scaleTime() + .domain([startTime, endTime]) + .range([0, dimensions.width]) + + const transformedXScale = zoomTransform.rescaleX(xScale) + + // Render bottom axis + useEffect(() => { + if (!axisRef.current) return + + const [visibleStart, visibleEnd] = transformedXScale.domain() as [Date, Date] + const tickValues = generateTimeTicks(visibleStart, visibleEnd, timeMode, Math.max(2, Math.floor(dimensions.width / 80))) + const axis = axisBottom(transformedXScale) + .tickValues(tickValues) + .tickFormat((d) => formatDateByMode(d as Date, timeMode)) + .tickSize(6) + .tickPadding(8) + + const axisGroup = select(axisRef.current) + axisGroup.selectAll('*').remove() // Clear existing axis + axisGroup.call(axis as any) + + // Grid lines shooting up through the layers + axisGroup.selectAll('.tick line').attr('y2', -totalLayersHeight) + + // Ensure text is visible + axisGroup.selectAll('.tick text') + .attr('fill', '#565c65') + .style('font-size', '11px') + .style('font-family', "'Inter', system-ui, -apple-system, sans-serif") + }, [transformedXScale, timeMode, totalLayersHeight, startTime, endTime]) + + // Render top axis for month/year (like JAN 2025) + useEffect(() => { + if (!topAxisRef.current) return + + const [visibleStart, visibleEnd] = transformedXScale.domain() as [Date, Date] + const tickValues = generateTimeTicks(visibleStart, visibleEnd, 'MONTH', Math.max(2, Math.floor(dimensions.width / 100))) + const topAxis = axisBottom(transformedXScale) + .tickValues(tickValues) + .tickFormat((d) => formatDateByMode(d as Date, 'MONTH')) + .tickSize(0) + .tickPadding(6) + + const topAxisGroup = select(topAxisRef.current) + topAxisGroup.selectAll('*').remove() // Clear existing axis + topAxisGroup.call(topAxis as any) + + // Ensure text is visible + topAxisGroup.selectAll('.tick text') + .attr('fill', '#71767a') + .style('font-size', '11px') + .style('font-weight', '600') + .style('font-family', "'Inter', system-ui, -apple-system, sans-serif") + }, [transformedXScale, startTime, endTime]) + + // Setup zoom behavior + useEffect(() => { + if (!svgRef.current) return + + const zoomBehavior: ZoomBehavior = zoom() + .scaleExtent([1, 50]) + .translateExtent([ + [0, 0], + [dimensions.width, dimensions.height], + ]) + .on('zoom', (event) => { + setZoomTransform(event.transform) + }) + + const svg = select(svgRef.current) + svg.call(zoomBehavior as any) + + return () => { + svg.on('.zoom', null) + } + }, [dimensions]) + + // Update scrubber position + const scrubberX = transformedXScale(currentTime) + + // Handle scrubber drag + const handleScrubberMouseDown = useCallback(() => { + setIsDragging(true) + }, []) + + const handleMouseMove = useCallback( + (event: MouseEvent) => { + if (!isDragging || !svgRef.current) return + + const svgRect = svgRef.current.getBoundingClientRect() + const x = event.clientX - svgRect.left + const newTime = transformedXScale.invert(x) + const clampedTime = clampDate(newTime, startTime, endTime) + + onCurrentTimeChange(clampedTime) + }, + [isDragging, transformedXScale, startTime, endTime, onCurrentTimeChange] + ) + + const handleMouseUp = useCallback(() => { + setIsDragging(false) + }, []) + + useEffect(() => { + if (isDragging) { + window.addEventListener('mousemove', handleMouseMove) + window.addEventListener('mouseup', handleMouseUp) + + return () => { + window.removeEventListener('mousemove', handleMouseMove) + window.removeEventListener('mouseup', handleMouseUp) + } + } + }, [isDragging, handleMouseMove, handleMouseUp]) + + // Handle click on timeline to jump to that time + const handleTimelineClick = useCallback( + (event: React.MouseEvent) => { + if (isDragging) return + + const svgRect = event.currentTarget.getBoundingClientRect() + const x = event.clientX - svgRect.left + const newTime = transformedXScale.invert(x) + const clampedTime = clampDate(newTime, startTime, endTime) + + onCurrentTimeChange(clampedTime) + }, + [isDragging, transformedXScale, startTime, endTime, onCurrentTimeChange] + ) + + return ( + <> +
+ {/* Layers Sidebar */} +
+
+
+ {layers.map((layer) => ( +
+ + {layer.displayName} +
+ ))} +
+
+ + {/* Timeline SVG Area */} +
+
+ + + +
+ + {/* Layer timelines (rendered first so grid/scrubber goes on top) */} + + {layers.map((layer, index) => ( + + + + + ))} + + + {/* Bottom Time axis */} + + + {/* Current time scrubber */} + + {/* Scrubber line through all layers */} + + + {/* Scrubber diamond head at the top of the layers */} + + + +
+
+ {/* Zoom instructions */} +
+ + Scroll to zoom • Drag scrubber to change time • Click to jump + +
+ + ) +} diff --git a/src/essence/Tools/Timeline/lib/index.ts b/src/essence/Tools/Timeline/lib/index.ts new file mode 100644 index 000000000..f66020b31 --- /dev/null +++ b/src/essence/Tools/Timeline/lib/index.ts @@ -0,0 +1,9 @@ +// 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 { TimeModeControl, type TimeModeControlProps } from './geo/TimeModeControl/TimeModeControl' +export { TimelineView, type TimelineViewProps } from './geo/TimelineView/TimelineView' + +// Shared domain 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..045d99f56 --- /dev/null +++ b/src/essence/Tools/Timeline/lib/types.ts @@ -0,0 +1,13 @@ +export type TimeMode = '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..b5a88c06f --- /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 '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 '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 '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 +} From b1f819ab0b63eaaa976be6a0d902a7eda073560d Mon Sep 17 00:00:00 2001 From: Sandesh Pandey Date: Wed, 3 Jun 2026 15:16:12 -0500 Subject: [PATCH 2/2] Update the styling of Timeline plugin to match disasters viz tool figma --- .../FloatingPopover/FloatingPopover.tsx | 140 +++ .../Ancillary/FloatingPopover/index.ts | 1 + src/essence/Basics/TimeControl_/TimeUI.js | 1 - .../UserInterface_/UserInterfaceModern_.css | 1 + .../lib/geo/LayerLegend/LayerLegend.tsx | 24 +- src/essence/Tools/Timeline/Timeline.css | 882 ++++++++++-------- .../Tools/Timeline/TimelineAdapter.tsx | 101 +- .../Tools/Timeline/adapters/mmgisAPI.ts | 5 + .../lib/geo/DateSelector/DateSelector.tsx | 140 ++- .../lib/geo/LayerTimeline/LayerTimeline.tsx | 2 +- .../geo/PlaybackControls/PlaybackControls.tsx | 41 +- .../lib/geo/TimelineView/TimelineView.tsx | 22 +- src/essence/mmgisAPI/mmgisAPI.js | 2 +- src/styles/disasters/index.scss | 10 + 14 files changed, 904 insertions(+), 468 deletions(-) create mode 100644 src/essence/Ancillary/FloatingPopover/FloatingPopover.tsx create mode 100644 src/essence/Ancillary/FloatingPopover/index.ts diff --git a/src/essence/Ancillary/FloatingPopover/FloatingPopover.tsx b/src/essence/Ancillary/FloatingPopover/FloatingPopover.tsx new file mode 100644 index 000000000..287eff864 --- /dev/null +++ b/src/essence/Ancillary/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/Ancillary/FloatingPopover/index.ts b/src/essence/Ancillary/FloatingPopover/index.ts new file mode 100644 index 000000000..f0c557299 --- /dev/null +++ b/src/essence/Ancillary/FloatingPopover/index.ts @@ -0,0 +1 @@ +export * from './FloatingPopover' diff --git a/src/essence/Basics/TimeControl_/TimeUI.js b/src/essence/Basics/TimeControl_/TimeUI.js index 447334088..ca308f567 100644 --- a/src/essence/Basics/TimeControl_/TimeUI.js +++ b/src/essence/Basics/TimeControl_/TimeUI.js @@ -761,7 +761,6 @@ const TimeUI = { promptTimeOnDateChangeTransitionDelay: 200, } - // startElm is already defined at the top TimeUI.startTempus = new TempusDominus(startElm, options) TimeUI.startTempus.dates.formatInput = function (date) { return moment(date).format(FORMAT) diff --git a/src/essence/Basics/UserInterface_/UserInterfaceModern_.css b/src/essence/Basics/UserInterface_/UserInterfaceModern_.css index bd83f44d7..aa5f9a6e8 100644 --- a/src/essence/Basics/UserInterface_/UserInterfaceModern_.css +++ b/src/essence/Basics/UserInterface_/UserInterfaceModern_.css @@ -109,6 +109,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/geo/LayerLegend/LayerLegend.tsx b/src/essence/Tools/LayerManager/lib/geo/LayerLegend/LayerLegend.tsx index dc7ed5e25..cb3a1642a 100644 --- a/src/essence/Tools/LayerManager/lib/geo/LayerLegend/LayerLegend.tsx +++ b/src/essence/Tools/LayerManager/lib/geo/LayerLegend/LayerLegend.tsx @@ -9,8 +9,8 @@ import { } from 'react' import { GradientGraphic } from '../GradientGraphic/GradientGraphic' import { CategoricalGraphic } from '../CategoricalGraphic/CategoricalGraphic' -import { useClickOutside } from '../../hooks/useClickOutside' import type { Layer } from '../../types' +import { FloatingPopover } from '../../../../../Ancillary/FloatingPopover' export type LayerLegendProps = { layer: Layer @@ -49,7 +49,6 @@ export function LayerLegend({ const [isOpacityExpanded, setIsOpacityExpanded] = useState(false) const [localOpacity, setLocalOpacity] = useState(opacity ?? 1) const opacityBtnRef = useRef(null) - const opacityPopoverRef = useRef(null) useEffect(() => { setIsVisible(visible) @@ -59,12 +58,6 @@ export function LayerLegend({ setLocalOpacity(opacity ?? 1) }, [opacity]) - useClickOutside( - [opacityPopoverRef, opacityBtnRef], - useCallback(() => setIsOpacityExpanded(false), []), - isOpacityExpanded, - ) - const handleVisibilityToggle = () => { const newState = !isVisible setIsVisible(newState) @@ -146,11 +139,14 @@ export function LayerLegend({ > - {isOpacityExpanded && ( -
+ setIsOpacityExpanded(false)} + placement="bottom" + offset={8} + > +
- )} +
+ +
@@ -272,8 +317,22 @@ export const TimelineAdapter: React.FC = () => { timeMode={timeMode} layers={layers} onCurrentTimeChange={handleCurrentTimeChange} + onResetZoomReady={handleResetZoomReady} />
+ 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/adapters/mmgisAPI.ts b/src/essence/Tools/Timeline/adapters/mmgisAPI.ts index e420d2c9f..2676f160f 100644 --- a/src/essence/Tools/Timeline/adapters/mmgisAPI.ts +++ b/src/essence/Tools/Timeline/adapters/mmgisAPI.ts @@ -8,6 +8,7 @@ type MMGISAPI = { hasHandler?: (name: string) => boolean getLayerConfigs?: () => any getRawConfigData?: () => any + getVisibleLayers?: () => Record } declare global { @@ -54,3 +55,7 @@ export const mmgisGetLayerConfigs = (): any => { export const mmgisGetRawConfigData = (): any => { return window.mmgisAPI?.getRawConfigData?.() || {} } + +export const mmgisGetVisibleLayers = (): Record => { + return window.mmgisAPI?.getVisibleLayers?.() || {} +} diff --git a/src/essence/Tools/Timeline/lib/geo/DateSelector/DateSelector.tsx b/src/essence/Tools/Timeline/lib/geo/DateSelector/DateSelector.tsx index ec1211665..26a518542 100644 --- a/src/essence/Tools/Timeline/lib/geo/DateSelector/DateSelector.tsx +++ b/src/essence/Tools/Timeline/lib/geo/DateSelector/DateSelector.tsx @@ -1,10 +1,13 @@ -import React, { useState, useRef, useEffect } from 'react' +import React, { useState, useRef } from 'react' import moment from 'moment' +import { FloatingPopover } from '../../../../../Ancillary/FloatingPopover' +import { TimeMode } from '../../types' export interface DateSelectorProps { selectedDate: Date startTime: Date endTime: Date + timeMode?: TimeMode onDateChange: (date: Date) => void } @@ -12,32 +15,36 @@ export const DateSelector: React.FC = ({ selectedDate, startTime, endTime, + timeMode = 'DAY', onDateChange, }) => { const [isOpen, setIsOpen] = useState(false) const [inputValue, setInputValue] = useState('') - const dropdownRef = useRef(null) + const buttonRef = useRef(null) // Format the selected date for display - const formattedDate = moment(selectedDate).format('MMM, D YYYY') + let formattedDate = moment(selectedDate).format('MMM D, YYYY') + if (timeMode === 'MONTH') { + formattedDate = moment(selectedDate).format('MMMM YYYY') + } else if (timeMode === 'HOUR') { + formattedDate = moment(selectedDate).format('MMM D, YYYY, HH:mm') + } - // Handle click outside to close dropdown - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { - setIsOpen(false) - } - } + const getFormatPattern = () => { + if (timeMode === 'MONTH') return 'YYYY-MM' + if (timeMode === 'HOUR') return 'YYYY-MM-DDTHH:mm' + return 'YYYY-MM-DD' + } - if (isOpen) { - document.addEventListener('mousedown', handleClickOutside) - return () => document.removeEventListener('mousedown', handleClickOutside) - } - }, [isOpen]) + const getInputType = () => { + if (timeMode === 'MONTH') return 'month' + if (timeMode === 'HOUR') return 'datetime-local' + return 'date' + } const handleDateClick = () => { setIsOpen(!isOpen) - setInputValue(moment(selectedDate).format('YYYY-MM-DD')) + setInputValue(moment(selectedDate).format(getFormatPattern())) } const handleInputChange = (e: React.ChangeEvent) => { @@ -61,9 +68,10 @@ export const DateSelector: React.FC = ({ } return ( -
+
- {isOpen && ( -
+ setIsOpen(false)} + placement="bottom" + offset={10} + > +
- - + + + {timeMode === 'MONTH' && (() => { + const currentMonth = moment(inputValue || selectedDate).month() + 1 + const currentYear = moment(inputValue || selectedDate).year() + const startY = moment(startTime).year() + const endY = moment(endTime).year() + const years = [] + for(let y=startY; y<=endY; y++) years.push(y) + + return ( +
+ + +
+ ) + })()} + + {timeMode === 'HOUR' && (() => { + const datePart = moment(inputValue || selectedDate).format('YYYY-MM-DD') + const timePart = moment(inputValue || selectedDate).format('HH:mm') + return ( +
+ setInputValue(`${e.target.value}T${timePart}`)} + min={moment(startTime).format('YYYY-MM-DD')} + max={moment(endTime).format('YYYY-MM-DD')} + /> + setInputValue(`${datePart}T${e.target.value}`)} + /> +
+ ) + })()} + + {timeMode === 'DAY' && ( + + )} +
- - Range: {moment(startTime).format('MMM D, YYYY')} -{' '} - {moment(endTime).format('MMM D, YYYY')} - + Range +
+ {moment(startTime).format('MMM D, YYYY')} - {moment(endTime).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 index 4991df27f..d47cd9058 100644 --- a/src/essence/Tools/Timeline/lib/geo/LayerTimeline/LayerTimeline.tsx +++ b/src/essence/Tools/Timeline/lib/geo/LayerTimeline/LayerTimeline.tsx @@ -29,7 +29,7 @@ export const LayerTimeline: React.FC = ({ x={x1} y={y + 4} width={width} - height={height - 8} + height={height - 6} fill={layer.color} opacity={0.85} rx={4} diff --git a/src/essence/Tools/Timeline/lib/geo/PlaybackControls/PlaybackControls.tsx b/src/essence/Tools/Timeline/lib/geo/PlaybackControls/PlaybackControls.tsx index dd1ab8e0a..941afbd66 100644 --- a/src/essence/Tools/Timeline/lib/geo/PlaybackControls/PlaybackControls.tsx +++ b/src/essence/Tools/Timeline/lib/geo/PlaybackControls/PlaybackControls.tsx @@ -20,34 +20,51 @@ export const PlaybackControls: 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 index bc8fc5c51..9b9f571fd 100644 --- a/src/essence/Tools/Timeline/lib/geo/TimelineView/TimelineView.tsx +++ b/src/essence/Tools/Timeline/lib/geo/TimelineView/TimelineView.tsx @@ -14,6 +14,7 @@ export interface TimelineViewProps { timeMode: TimeMode layers: LayerTimeData[] onCurrentTimeChange: (time: Date) => void + onResetZoomReady?: (resetZoomFn: () => void) => void } export const TimelineView: React.FC = ({ @@ -23,6 +24,7 @@ export const TimelineView: React.FC = ({ timeMode, layers, onCurrentTimeChange, + onResetZoomReady, }) => { const containerRef = useRef(null) const svgRef = useRef(null) @@ -34,7 +36,7 @@ export const TimelineView: React.FC = ({ const [zoomTransform, setZoomTransform] = useState(zoomIdentity) const axisHeight = 24 // Space for the bottom axis - const layerBarHeight = 24 + const layerBarHeight = 15 const topBarHeight = 24 // Space for top axis // Calculate total height needed for layers @@ -132,10 +134,18 @@ export const TimelineView: React.FC = ({ const svg = select(svgRef.current) svg.call(zoomBehavior as any) + // Expose reset zoom function to parent + if (onResetZoomReady) { + const resetZoom = () => { + svg.transition().duration(300).call(zoomBehavior.transform as any, zoomIdentity) + } + onResetZoomReady(resetZoom) + } + return () => { svg.on('.zoom', null) } - }, [dimensions]) + }, [dimensions, onResetZoomReady]) // Update scrubber position const scrubberX = transformedXScale(currentTime) @@ -264,7 +274,7 @@ export const TimelineView: React.FC = ({ {/* Scrubber diamond head at the top of the layers */} = ({
- {/* Zoom instructions */} -
- - Scroll to zoom • Drag scrubber to change time • Click to jump - -
) } diff --git a/src/essence/mmgisAPI/mmgisAPI.js b/src/essence/mmgisAPI/mmgisAPI.js index aeb6008c1..4110ca436 100644 --- a/src/essence/mmgisAPI/mmgisAPI.js +++ b/src/essence/mmgisAPI/mmgisAPI.js @@ -696,7 +696,7 @@ var mmgisAPI = { * @returns {object} - an object containing the visibility state of each layer */ getLayerConfigs: mmgisAPI_.getLayerConfigs, - /** getRawConfigData - returns the original unmutated configuration data + /** getRawConfigData - returns the original unmutated configuration data * @returns {object} - the original configData object */ getRawConfigData: mmgisAPI_.getRawConfigData, diff --git a/src/styles/disasters/index.scss b/src/styles/disasters/index.scss index b63c9937c..f0557b0db 100644 --- a/src/styles/disasters/index.scss +++ b/src/styles/disasters/index.scss @@ -12,3 +12,13 @@ // 4. Export theme tokens as CSS custom properties @use "../theme-export"; @include theme-export.export-theme-tokens(); + +// 5. Override USWDS blue focus outline +input:not([disabled]):focus, +select:not([disabled]):focus, +textarea:not([disabled]):focus, +button:not([disabled]):focus, +[tabindex]:focus, +[contentEditable=true]:focus { + outline: none !important; +}