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} + > +
- )} +
+ + + + +
+ +
+ setShowInfoPopup(false)} + placement="top" + offset={8} + className="timeline-info-tooltip-portal" + > +
+ Timeline Controls +

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

+
+
+ + ) +} + + diff --git a/src/essence/Tools/Timeline/TimelineTool.tsx b/src/essence/Tools/Timeline/TimelineTool.tsx new file mode 100644 index 000000000..5c1c17fa1 --- /dev/null +++ b/src/essence/Tools/Timeline/TimelineTool.tsx @@ -0,0 +1,81 @@ +import React from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { TimelineAdapter } from './TimelineAdapter' +import { mmgisRequest } from './adapters/mmgisAPI' + +type ToolVars = { + width?: number + height?: number +} + +let _root: Root | null = null + +const TimelineTool = { + height: 200, + width: 'full' as number | 'full', + vars: {} as ToolVars, + targetId: null as string | null, + made: false, + _cleanups: [] as Array<() => void>, + + initialize: async function () { + try { + this.vars = + (await mmgisRequest( + 'tool:getVars', + 'timeline', + )) || {} + if (this.vars.width) this.width = this.vars.width + if (this.vars.height) this.height = this.vars.height + } catch (err) { + console.warn( + '[TimelineTool] tool:getVars unavailable:', + err instanceof Error ? err.message : err, + ) + } + + try { + const isMobile = await mmgisRequest('app:isMobile') + if (isMobile) { + this.width = 'full' + this.height = 300 + } + } catch (err) { + console.warn( + '[TimelineTool] app:isMobile unavailable:', + err instanceof Error ? err.message : err, + ) + } + }, + + make: function (targetId?: string) { + this.targetId = typeof targetId === 'string' ? targetId : 'toolPanel' + const container = document.getElementById(this.targetId) + if (!container) { + console.error( + `TimelineTool: container ${this.targetId} not found`, + ) + return + } + _root = createRoot(container) + _root.render() + this.made = true + }, + + destroy: function () { + if (_root) { + _root.unmount() + _root = null + } + this._cleanups.forEach((cleanup) => cleanup()) + this._cleanups = [] + this.targetId = null + this.made = false + }, + + getUrlString: function () { + return '' + }, +} + +export default TimelineTool diff --git a/src/essence/Tools/Timeline/adapters/mmgisAPI.ts b/src/essence/Tools/Timeline/adapters/mmgisAPI.ts new file mode 100644 index 000000000..2676f160f --- /dev/null +++ b/src/essence/Tools/Timeline/adapters/mmgisAPI.ts @@ -0,0 +1,61 @@ +type EventCleanup = () => void + +type MMGISAPI = { + request: (name: string, params?: unknown) => Promise + on: (event: string, handler: (payload?: unknown) => void) => EventCleanup + emit: (event: string, payload?: unknown) => void + provide?: (name: string, handler: (...args: unknown[]) => unknown) => EventCleanup + hasHandler?: (name: string) => boolean + getLayerConfigs?: () => any + getRawConfigData?: () => any + getVisibleLayers?: () => Record +} + +declare global { + interface Window { + mmgisAPI?: MMGISAPI + } +} + +export const mmgisRequest = async (name: string, params?: unknown): Promise => { + if (window.mmgisAPI?.request) { + return (await window.mmgisAPI.request(name, params)) as T + } + return null +} + +export const mmgisOn = (event: string, handler: (payload?: unknown) => void): EventCleanup => { + if (!window.mmgisAPI?.on) return () => {} + return window.mmgisAPI.on(event, handler) +} + +export const mmgisEmit = (event: string, payload?: unknown): void => { + console.log('[mmgisAPI] mmgisEmit called:', event, 'window.mmgisAPI exists:', !!window.mmgisAPI, 'emit exists:', !!window.mmgisAPI?.emit) + if (window.mmgisAPI?.emit) { + window.mmgisAPI.emit(event, payload) + console.log('[mmgisAPI] Emitted event:', event) + } else { + console.warn('[mmgisAPI] Cannot emit - mmgisAPI or emit not available') + } +} + +export const mmgisProvide = (name: string, handler: (...args: unknown[]) => unknown): EventCleanup => { + if (!window.mmgisAPI?.provide) return () => {} + return window.mmgisAPI.provide(name, handler) +} + +export const mmgisHasHandler = (name: string): boolean => { + return window.mmgisAPI?.hasHandler?.(name) === true +} + +export const mmgisGetLayerConfigs = (): any => { + return window.mmgisAPI?.getLayerConfigs?.() || {} +} + +export const mmgisGetRawConfigData = (): any => { + return window.mmgisAPI?.getRawConfigData?.() || {} +} + +export const mmgisGetVisibleLayers = (): Record => { + return window.mmgisAPI?.getVisibleLayers?.() || {} +} diff --git a/src/essence/Tools/Timeline/config.json b/src/essence/Tools/Timeline/config.json new file mode 100644 index 000000000..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..26a518542 --- /dev/null +++ b/src/essence/Tools/Timeline/lib/geo/DateSelector/DateSelector.tsx @@ -0,0 +1,180 @@ +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 +} + +export const DateSelector: React.FC = ({ + selectedDate, + startTime, + endTime, + timeMode = 'DAY', + onDateChange, +}) => { + const [isOpen, setIsOpen] = useState(false) + const [inputValue, setInputValue] = useState('') + const buttonRef = useRef(null) + + // Format the selected date for display + 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') + } + + const getFormatPattern = () => { + if (timeMode === 'MONTH') return 'YYYY-MM' + if (timeMode === 'HOUR') return 'YYYY-MM-DDTHH:mm' + return 'YYYY-MM-DD' + } + + const getInputType = () => { + if (timeMode === 'MONTH') return 'month' + if (timeMode === 'HOUR') return 'datetime-local' + return 'date' + } + + const handleDateClick = () => { + setIsOpen(!isOpen) + setInputValue(moment(selectedDate).format(getFormatPattern())) + } + + 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 ( +
+
+ + +
+ + +
+ + 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')} +
+
+
+
+
+ ) +} 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..d47cd9058 --- /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..941afbd66 --- /dev/null +++ b/src/essence/Tools/Timeline/lib/geo/PlaybackControls/PlaybackControls.tsx @@ -0,0 +1,72 @@ +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..9b9f571fd --- /dev/null +++ b/src/essence/Tools/Timeline/lib/geo/TimelineView/TimelineView.tsx @@ -0,0 +1,290 @@ +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 + onResetZoomReady?: (resetZoomFn: () => void) => void +} + +export const TimelineView: React.FC = ({ + startTime, + endTime, + currentTime, + timeMode, + layers, + onCurrentTimeChange, + onResetZoomReady, +}) => { + 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 = 15 + 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) + + // 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, onResetZoomReady]) + + // 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 */} + + + +
+
+ + ) +} 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 +} 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; +}