diff --git a/examples/timeline-layers/timeline-layer/README.md b/examples/timeline-layers/timeline-layer/README.md new file mode 100644 index 000000000..e41580529 --- /dev/null +++ b/examples/timeline-layers/timeline-layer/README.md @@ -0,0 +1,108 @@ +# Timeline Layer - Prototype + +An interactive timeline visualization layer for deck.gl. This is a **working prototype** demonstrating core timeline functionality for video editing, animation sequencing, and temporal data visualization. + +## Current Status + +This prototype successfully demonstrates: + +- Multi-track timeline rendering with clip visualization +- Interactive scrubber with drag support +- Zoom and pan controls for temporal navigation +- Clip and track selection with hover states +- Collision detection for overlapping clips (subtrack assignment) +- Multiple time formatting options +- Performance-optimized rendering with memoization + +## Remaining Work + +### 1. Critical Bugs to Fix + +- **Scrubber Position After Window Resize**: When the window is resized and the user immediately drags the scrubber, the scrubber position jumps incorrectly. The coordinate system calculation needs investigation - likely related to how mouse coordinates are translated to timeline coordinates after dimension changes. + +### 2. API Design & Internal Interactivity + +**Critical Priority**: The current implementation exposes significant internal complexity to users. A production-ready timeline layer requires: + +- **Simplified Props Interface**: Reduce the number of exposed props while maintaining flexibility +- **Internal State Management**: Move interaction state (panning, zooming, scrubber dragging) inside the layer +- **Hover State Management**: Currently hover states (hoveredClipId, hoveredTrackId) are managed externally. The layer should handle hover state internally and only expose necessary callbacks. +- **Callback Consolidation**: Streamline the event callback system (currently: onClipClick, onClipHover, onTrackClick, onTrackHover, onScrubberDrag, onViewportChange, onZoomChange) +- **Declarative Configuration**: Users should specify what they want, not how to achieve it +- **Default Behaviors**: Provide sensible defaults for common use cases (video editor vs. analytics timeline) + +The goal: Users should be able to create a functional timeline with 5-10 props, not 30+. + +### 3. Official Documentation + +This layer needs comprehensive documentation matching the standards of other deck.gl layers: + +- **API Reference**: Complete PropTypes documentation with descriptions, types, and defaults +- **Usage Guide**: Common patterns and best practices +- **Examples**: Multiple real-world use cases (video editing, analytics, scheduling) +- **Architecture Overview**: How the layer works internally (collision detection, sublayers, viewport management) +- **Migration Guide**: How to integrate into existing deck.gl projects + +Reference: [deck.gl Layer Catalog](https://deck.gl/docs/api-reference/layers) + +### 4. Testing & Validation + +- Unit tests for collision detection and time calculations +- Integration tests for interaction behaviors +- Performance benchmarks with large datasets (1000+ clips) +- Cross-browser compatibility testing + +### 5. Additional Features + +- Clip resizing (drag edges to extend/trim) +- Multi-selection support +- Keyboard shortcuts (arrow keys, space for play/pause) +- Clip drag-and-drop between tracks +- Undo/redo support +- Timeline markers and regions +- Customizable track heights +- Nested timeline groups + +## Running the Demo + +```bash +yarn install +yarn start +``` + +Open [http://localhost:5173](http://localhost:5173) + +## Architecture + +``` +timeline-layer.ts # Main CompositeLayer implementation +timeline-types.ts # TypeScript type definitions +timeline-utils.ts # Time formatting and position calculations +timeline-collision.ts # Subtrack assignment algorithm +timeline-hooks.ts # React hooks for interaction state +demo-controls.tsx # Demo control panel +app.tsx # Demo application +``` + +## Core Interactions Implemented + +- **Mouse Wheel**: Zoom timeline in/out +- **Click + Drag**: Pan when zoomed (zoom level > 1.0) +- **Click Clip**: Select clip and show details +- **Click Track Background**: Deselect clip, select track +- **Click Empty Space**: Deselect both clip and track +- **Drag Scrubber**: Scrub through timeline +- **Hover**: Clips and tracks lighten on hover + +## Contributing + +This prototype demonstrates feasibility and core functionality. To make it production-ready: + +1. Review the "Remaining Work" section above +2. Start with API simplification - gather feedback on ideal developer experience +3. Create comprehensive documentation following deck.gl standards +4. Add tests and validate performance at scale + +--- + +**Note**: This is experimental code intended for evaluation and feedback. Not recommended for production use without addressing the items in "Remaining Work". diff --git a/examples/timeline-layers/timeline-layer/app.tsx b/examples/timeline-layers/timeline-layer/app.tsx new file mode 100644 index 000000000..8791f80b7 --- /dev/null +++ b/examples/timeline-layers/timeline-layer/app.tsx @@ -0,0 +1,213 @@ +// deck.gl-community +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + + +import React, {ReactElement, useMemo} from 'react'; +import {createRoot} from 'react-dom/client'; + + +import DeckGL from '@deck.gl/react'; +import {OrthographicView} from '@deck.gl/core'; +import {TimelineLayer} from '@deck.gl-community/timeline-layers'; +import {useTimelineControls, TimelineControls} from './demo-controls'; + +import { + useTimelineInteractionState, + useTimelineRefs, + useGlobalMouseUpCleanup, + useWheelZoom, + useTimelineCallbacks, + useContainerHandlers, + useDeckGLHandlers, + useCursorGetter, + useTimelineResize +} from './timeline-hooks'; + +const canvasWidth = (typeof window !== 'undefined' ? window.innerWidth : 1280) - 320; +const canvasHeight = typeof window !== 'undefined' ? window.innerHeight : 720; + +const INITIAL_VIEW_STATE: OrthographicViewState = { + target: [canvasWidth / 2, canvasHeight / 2, 0], + zoom: 0 +}; + +// Memoized constants +const ORTHOGRAPHIC_VIEW = new OrthographicView(); +const CONTROLLER_CONFIG = { + scrollZoom: false, + doubleClickZoom: false, + touchZoom: false, + dragPan: false, + dragRotate: false, + keyboard: false +} as const; + +const CONTAINER_OUTER_STYLE = { + display: 'flex', + width: '100vw', + height: '100vh', + overflow: 'hidden' +} as const; + +const CONTAINER_INNER_STYLE = { + flex: 1, + height: '100vh', + position: 'relative' as const, + overflow: 'hidden', + border: '3px solid black' +} as const; + +export default function App(): ReactElement { + const {state, controls, trackCount, clipsPerTrack, labelFormatterType} = useTimelineControls(); + + const {state: interactionState, setState} = useTimelineInteractionState(); + const {timelineLayerRef, containerRef} = useTimelineRefs(); + + useGlobalMouseUpCleanup( + setState.setIsDraggingScrubber, + setState.setIsPanning, + setState.setPanStartViewport + ); + + useWheelZoom(containerRef, timelineLayerRef, state.zoomLevel); + useTimelineResize(controls.setTimelineWidth); + + const timelineCallbacks = useTimelineCallbacks(controls); + + const containerHandlers = useContainerHandlers( + interactionState, + setState, + timelineLayerRef, + controls, + state + ); + + const deckGLHandlers = useDeckGLHandlers(setState, timelineLayerRef, controls, state); + + const getCursor = useCursorGetter(interactionState, state.zoomLevel); + + // Memoize viewport object + const viewport = useMemo( + () => ({startMs: state.viewportStartMs, endMs: state.viewportEndMs}), + [state.viewportStartMs, state.viewportEndMs] + ); + + // Memoize selectionStyle object + const selectionStyle = useMemo( + () => ({ + selectedClipColor: [255, 200, 0, 255] as [number, number, number, number], + hoveredClipColor: [200, 200, 200, 255] as [number, number, number, number], + selectedTrackColor: [80, 80, 80, 255] as [number, number, number, number], + hoveredTrackColor: [70, 70, 70, 255] as [number, number, number, number], + selectedLineWidth: state.selectedLineWidth, + hoveredLineWidth: state.hoveredLineWidth + }), + [state.selectedLineWidth, state.hoveredLineWidth] + ); + + const timelineLayerProps = useMemo( + () => ({ + id: 'timeline', + data: state.tracks, + timelineStart: state.timelineStart, + timelineEnd: state.timelineEnd, + x: state.timelineX, + y: state.timelineY, + width: state.timelineWidth, + trackHeight: state.trackHeight, + trackSpacing: state.trackSpacing, + currentTimeMs: state.currentTimeMs, + viewport, + timeFormatter: state.labelFormatter, + selectedClipId: state.selectedClip?.id, + hoveredClipId: state.hoveredClip?.id, + selectedTrackId: state.selectedTrack?.id, + hoveredTrackId: state.hoveredTrack?.id, + showTrackLabels: true, + showClipLabels: true, + showScrubber: true, + showAxis: true, + showSubtrackSeparators: state.showSubtrackSeparators, + selectionStyle, + onClipClick: timelineCallbacks.handleClipClick, + onClipHover: timelineCallbacks.handleClipHover, + onTrackClick: timelineCallbacks.handleTrackClick, + onTrackHover: timelineCallbacks.handleTrackHover, + onScrubberDrag: timelineCallbacks.handleScrubberDrag, + onTimelineClick: timelineCallbacks.handleTimelineClick, + onViewportChange: timelineCallbacks.handleViewportChange, + onZoomChange: timelineCallbacks.handleZoomChange + }), + [ + state.tracks, + state.timelineStart, + state.timelineEnd, + state.timelineX, + state.timelineY, + state.timelineWidth, + state.trackHeight, + state.trackSpacing, + state.currentTimeMs, + viewport, + state.labelFormatter, + state.selectedClip?.id, + state.hoveredClip?.id, + state.selectedTrack?.id, + state.hoveredTrack?.id, + state.showSubtrackSeparators, + selectionStyle, + timelineCallbacks + ] + ); + + const timelineLayer = useMemo( + () => new TimelineLayer(timelineLayerProps), + [timelineLayerProps] + ); + + // Update ref when layer changes + React.useEffect(() => { + timelineLayerRef.current = timelineLayer; + }, [timelineLayer]); + + const layers = useMemo(() => [timelineLayer], [timelineLayer]); + + return ( +
+ + +
+ +
+
+ ); +} + + +const root = createRoot(document.getElementById('app')!); +root.render(); + diff --git a/examples/timeline-layers/timeline-layer/demo-controls.tsx b/examples/timeline-layers/timeline-layer/demo-controls.tsx new file mode 100644 index 000000000..59355fc30 --- /dev/null +++ b/examples/timeline-layers/timeline-layer/demo-controls.tsx @@ -0,0 +1,700 @@ +// deck.gl-community +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import React, {ReactElement, useMemo, useState} from 'react'; +import {timeAxisFormatters, type TimelineTrack, type TimelineClip, type SelectionStyle} from '@deck.gl-community/timeline-layers'; +import {generateRandomTracks} from './demo-utils'; + +const canvasWidth = (typeof window !== 'undefined' ? window.innerWidth : 1280) - 320; + +export type TimelineControlsState = { + // Data + tracks: TimelineTrack[]; + + // Timeline config + timelineStart: number; + timelineEnd: number; + currentTimeMs: number; + zoomLevel: number; + + // Layout + trackHeight: number; + trackSpacing: number; + timelineX: number; + timelineY: number; + timelineWidth: number; + + // Viewport + viewportStartMs?: number; + viewportEndMs?: number; + + // Formatter + labelFormatter: (timeMs: number) => string; + + // Selection + selectedClip: TimelineClip | null; + hoveredClip: TimelineClip | null; + selectedTrack: TimelineTrack | null; + hoveredTrack: TimelineTrack | null; + + // Advanced configuration + selectedLineWidth: number; + hoveredLineWidth: number; + showSubtrackSeparators: boolean; + trackLabelFontSize: number; + clipLabelFontSize: number; + + // Performance + renderTime: number; + visibleClipsCount: number; + visibleLabelsCount: number; +} + +export function useTimelineControls() { + // Timeline configuration + const [trackCount, setTrackCount] = useState(4); + const [clipsPerTrack, setClipsPerTrack] = useState(3); + const [timelineStart] = useState(0); + const [timelineEnd, setTimelineEnd] = useState(60000); + const [currentTimeMs, setCurrentTimeMs] = useState(0); + const [zoomLevel, setZoomLevel] = useState(1); + + // Layout configuration + const [trackHeight, setTrackHeight] = useState(40); + const [trackSpacing, setTrackSpacing] = useState(8); + const [timelineX, setTimelineX] = useState(150); + const [timelineY, setTimelineY] = useState(80); + const [timelineWidth, setTimelineWidth] = useState(canvasWidth - 200); + + // Viewport for zoom/pan + const [viewportStartMs, setViewportStartMs] = useState(undefined); + const [viewportEndMs, setViewportEndMs] = useState(undefined); + + // Interaction state + const [selectedClip, setSelectedClip] = useState(null); + const [hoveredClip, setHoveredClip] = useState(null); + const [selectedTrack, setSelectedTrack] = useState(null); + const [hoveredTrack, setHoveredTrack] = useState(null); + + // Label formatter + const [labelFormatterType, setLabelFormatterType] = useState('seconds'); + const labelFormatter = timeAxisFormatters[labelFormatterType]; + + // Advanced configuration + const [selectedLineWidth, setSelectedLineWidth] = useState(3); + const [hoveredLineWidth, setHoveredLineWidth] = useState(2); + const [showSubtrackSeparators, setShowSubtrackSeparators] = useState(true); + const [trackLabelFontSize, setTrackLabelFontSize] = useState(12); + const [clipLabelFontSize, setClipLabelFontSize] = useState(10); + + // Generate data + const [renderTime, setRenderTime] = useState(0); + const tracks = useMemo(() => { + const startTime = performance.now(); + const result = generateRandomTracks(trackCount, clipsPerTrack, timelineStart, timelineEnd); + const endTime = performance.now(); + setRenderTime(endTime - startTime); + return result; + }, [trackCount, clipsPerTrack, timelineStart, timelineEnd]); + + // Memoize visible clips calculation + const {visibleClipsCount, visibleLabelsCount} = useMemo(() => { + const effectiveStartMs = viewportStartMs ?? timelineStart; + const effectiveEndMs = viewportEndMs ?? timelineEnd; + + let count = 0; + tracks.forEach((track) => { + track.clips.forEach((clip) => { + if (clip.endMs > effectiveStartMs && clip.startMs < effectiveEndMs) { + count++; + } + }); + }); + + const labels = zoomLevel >= 1.5 ? Math.min(1000, count) : 0; + return {visibleClipsCount: count, visibleLabelsCount: labels}; + }, [tracks, viewportStartMs, viewportEndMs, timelineStart, timelineEnd, zoomLevel]); + + // Memoize state object + const state: TimelineControlsState = useMemo( + () => ({ + tracks, + timelineStart, + timelineEnd, + currentTimeMs, + zoomLevel, + trackHeight, + trackSpacing, + timelineX, + timelineY, + timelineWidth, + viewportStartMs, + viewportEndMs, + labelFormatter, + selectedClip, + hoveredClip, + selectedTrack, + hoveredTrack, + selectedLineWidth, + hoveredLineWidth, + showSubtrackSeparators, + trackLabelFontSize, + clipLabelFontSize, + renderTime, + visibleClipsCount, + visibleLabelsCount + }), + [ + tracks, + timelineStart, + timelineEnd, + currentTimeMs, + zoomLevel, + trackHeight, + trackSpacing, + timelineX, + timelineY, + timelineWidth, + viewportStartMs, + viewportEndMs, + labelFormatter, + selectedClip, + hoveredClip, + selectedTrack, + hoveredTrack, + selectedLineWidth, + hoveredLineWidth, + showSubtrackSeparators, + trackLabelFontSize, + clipLabelFontSize, + renderTime, + visibleClipsCount, + visibleLabelsCount + ] + ); + + // Memoize controls object + const controls = useMemo( + () => ({ + setTrackCount, + setClipsPerTrack, + setTimelineEnd, + setCurrentTimeMs, + setZoomLevel, + setTrackHeight, + setTrackSpacing, + setTimelineX, + setTimelineY, + setTimelineWidth, + setViewportStartMs, + setViewportEndMs, + setLabelFormatterType, + setSelectedClip, + setHoveredClip, + setSelectedTrack, + setHoveredTrack, + setSelectedLineWidth, + setHoveredLineWidth, + setShowSubtrackSeparators, + setTrackLabelFontSize, + setClipLabelFontSize + }), + [ + setTrackCount, + setClipsPerTrack, + setTimelineEnd, + setCurrentTimeMs, + setZoomLevel, + setTrackHeight, + setTrackSpacing, + setTimelineX, + setTimelineY, + setTimelineWidth, + setViewportStartMs, + setViewportEndMs, + setLabelFormatterType, + setSelectedClip, + setHoveredClip, + setSelectedTrack, + setHoveredTrack, + setSelectedLineWidth, + setHoveredLineWidth, + setShowSubtrackSeparators, + setTrackLabelFontSize, + setClipLabelFontSize + ] + ); + + return {state, controls, trackCount, clipsPerTrack, labelFormatterType}; +} + +export function TimelineControls({ + state, + controls, + trackCount, + clipsPerTrack, + labelFormatterType +}: { + state: TimelineControlsState; + controls: ReturnType['controls']; + trackCount: number; + clipsPerTrack: number; + labelFormatterType: string; +}): ReactElement { + return ( +
+
+

Timeline Controls

+
+ +
+
+

+ Performance Stats +

+
+
+ Clips Rendered: {state.visibleClipsCount.toLocaleString()} +
+
+ Labels Shown: {state.visibleLabelsCount.toLocaleString()} +
+
+ Data Generation: {state.renderTime.toFixed(2)}ms +
+
+ Zoom Level: {state.zoomLevel.toFixed(2)}x +
+
+
+ +
+

+ Timeline Configuration +

+ +
+ + controls.setTrackCount(Number(e.target.value))} + style={{width: '100%'}} + /> +
+ +
+ + +
+ +
+ + controls.setTimelineEnd(state.timelineStart + Number(e.target.value))} + style={{width: '100%'}} + /> +
+
+ +
+

+ Timeline Axis Labels +

+ +
+ + +
+
+ +
+

+ Track Appearance +

+ +
+ + controls.setTrackHeight(Number(e.target.value))} + style={{width: '100%'}} + /> +
+ +
+ + controls.setTrackSpacing(Number(e.target.value))} + style={{width: '100%'}} + /> +
+
+ + + + {state.selectedClip && ( +
+

+ Selected Clip +

+
+
+ Label: {state.selectedClip.label} +
+
+ Start: {(state.selectedClip.startMs / 1000).toFixed(2)}s +
+
+ End: {(state.selectedClip.endMs / 1000).toFixed(2)}s +
+
+ Duration:{' '} + {((state.selectedClip.endMs - state.selectedClip.startMs) / 1000).toFixed(2)}s +
+ +
+
+ )} + + {state.selectedTrack && ( +
+

+ Selected Track +

+
+
+ Name: {state.selectedTrack.name} +
+
+ ID: {state.selectedTrack.id} +
+
+ Clips: {state.selectedTrack.clips.length} +
+ +
+
+ )} + +
+

+ Timeline Scrubber +

+ +
+ + controls.setCurrentTimeMs(Number(e.target.value))} + style={{width: '100%'}} + /> +
+
+ +
+

+ Timeline Zoom +

+ +
+ + controls.setZoomLevel(Number(e.target.value))} + style={{width: '100%'}} + /> +
+
+ +
+ Features: +
+ • Clips nested in tracks +
+ • Click clips/tracks to select +
+ • Hover for highlighting +
+ • Zoom and pan support +
• Clean data structure +
+
+
+ ); +} + +function AdvancedSettings({ + state, + controls +}: { + state: TimelineControlsState; + controls: ReturnType['controls']; +}): ReactElement { + const [isExpanded, setIsExpanded] = useState(false); + + return ( +
+
setIsExpanded(!isExpanded)} + style={{ + cursor: 'pointer', + padding: '10px', + background: '#f5f5f5', + border: '1px solid #ddd', + borderRadius: '4px', + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: isExpanded ? '10px' : '0' + }} + > +

Advanced Settings

+ {isExpanded ? '▼' : '▶'} +
+ + {isExpanded && ( +
+
+

+ Selection & Hover +

+ +
+ + controls.setSelectedLineWidth(Number(e.target.value))} + style={{width: '100%'}} + /> +
+ +
+ + controls.setHoveredLineWidth(Number(e.target.value))} + style={{width: '100%'}} + /> +
+
+ +
+

+ Subtrack Separators +

+ +
+ +
+

Track Labels

+
+ + controls.setTrackLabelFontSize(Number(e.target.value))} + style={{width: '100%'}} + /> +
+
+ +
+

Clip Labels

+
+ + controls.setClipLabelFontSize(Number(e.target.value))} + style={{width: '100%'}} + /> +
+
+
+ )} +
+ ); +} diff --git a/examples/timeline-layers/timeline-layer/demo-utils.ts b/examples/timeline-layers/timeline-layer/demo-utils.ts new file mode 100644 index 000000000..cada22007 --- /dev/null +++ b/examples/timeline-layers/timeline-layer/demo-utils.ts @@ -0,0 +1,68 @@ +// deck.gl-community +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import type {TimelineClip, TimelineTrack} from './timeline-utils'; + +/** + * Generate random tracks with clips for demo purposes + * This is a demo utility and should not be used in production + */ +export const generateRandomTracks = ( + trackCount: number, + clipsPerTrack: number, + timelineStart: number, + timelineEnd: number +): TimelineTrack[] => { + const tracks: TimelineTrack[] = []; + // More subtle, professional colors + const colors = [ + [80, 120, 160, 220], + [120, 160, 180, 220], + [100, 140, 120, 220], + [140, 120, 140, 220], + [160, 140, 100, 220], + [120, 100, 140, 220], + [100, 120, 120, 220], + [140, 120, 100, 220] + ] as [number, number, number, number][]; + + for (let trackIdx = 0; trackIdx < trackCount; trackIdx++) { + const clips: TimelineClip[] = []; + + for (let clipIdx = 0; clipIdx < clipsPerTrack; clipIdx++) { + const duration = Math.random() * (timelineEnd - timelineStart) * 0.1; // Max 10% of timeline + let startMs: number; + + // 30% chance of creating an overlapping clip (if not the first clip) + if (clipIdx > 0 && Math.random() < 0.3) { + const prevClip = clips[clips.length - 1]; + // Start somewhere in the middle of the previous clip + const overlapPoint = + prevClip.startMs + (prevClip.endMs - prevClip.startMs) * (0.3 + Math.random() * 0.4); + startMs = overlapPoint; + } else { + startMs = Math.random() * (timelineEnd - timelineStart - duration) + timelineStart; + } + + const endMs = startMs + duration; + + clips.push({ + id: `track-${trackIdx}-clip-${clipIdx}`, + startMs, + endMs, + label: `Clip ${clipIdx + 1}`, + color: colors[clipIdx % colors.length] + }); + } + + tracks.push({ + id: trackIdx, + name: `Track ${trackIdx + 1}`, + visible: true, + clips + }); + } + + return tracks; +}; diff --git a/examples/timeline-layers/timeline-layer/index.html b/examples/timeline-layers/timeline-layer/index.html new file mode 100644 index 000000000..33f4cc8d5 --- /dev/null +++ b/examples/timeline-layers/timeline-layer/index.html @@ -0,0 +1,22 @@ + + + + + deck.gl-community/info-vis TimelineLayer + + + + +
+ + + diff --git a/examples/timeline-layers/timeline-layer/index.tsx b/examples/timeline-layers/timeline-layer/index.tsx new file mode 100644 index 000000000..419355285 --- /dev/null +++ b/examples/timeline-layers/timeline-layer/index.tsx @@ -0,0 +1,4 @@ +// deck.gl-community +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + diff --git a/examples/timeline-layers/timeline-layer/package.json b/examples/timeline-layers/timeline-layer/package.json new file mode 100644 index 000000000..94b37699a --- /dev/null +++ b/examples/timeline-layers/timeline-layer/package.json @@ -0,0 +1,23 @@ +{ + "license": "MIT", + "scripts": { + "start": "vite --open", + "start-local": "vite --config ../../vite.config.local.mjs" + }, + "dependencies": { + "@deck.gl-community/timeline-layers": "workspace:*", + "@deck.gl/core": "~9.2.1", + "@deck.gl/layers": "~9.2.1", + "@deck.gl/react": "~9.2.1", + "@deck.gl/widgets": "~9.2.1", + "@loaders.gl/core": "^4.2.0", + "@luma.gl/core": "~9.2.0", + "@luma.gl/engine": "~9.2.0", + "@luma.gl/shadertools": "~9.2.0", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "vite": "7.1.1" + } +} diff --git a/examples/timeline-layers/timeline-layer/timeline-hooks.ts b/examples/timeline-layers/timeline-layer/timeline-hooks.ts new file mode 100644 index 000000000..43602517c --- /dev/null +++ b/examples/timeline-layers/timeline-layer/timeline-hooks.ts @@ -0,0 +1,651 @@ +// deck.gl-community +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import {useState, useRef, useEffect, useCallback, useMemo} from 'react'; +import type {TimelineLayer, TimelineClipInfo, TimelineTrackInfo} from './timeline-layeri/timeline-layer'; +import {positionToTime} from '@deck.gl-community/timeline-layers'; +import type {TimelineControlsState} from './demo-controls'; + +const DRAG_THRESHOLD = 5; + +interface TimelineInteractionState { + isDraggingScrubber: boolean; + isPanning: boolean; + panStartX: number; + panStartViewport: {start: number; end: number} | null; + mouseDownPos: {x: number; y: number} | null; + hoveredObjectId: string | null; +} + +interface TimelineRefs { + timelineLayerRef: React.RefObject; + containerRef: React.RefObject; +} + +interface TimelineControls { + setSelectedClip: (clip: unknown) => void; + setHoveredClip: (clip: unknown) => void; + setSelectedTrack: (track: unknown) => void; + setHoveredTrack: (track: unknown) => void; + setCurrentTimeMs: (timeMs: number) => void; + setViewportStartMs: (startMs: number) => void; + setViewportEndMs: (endMs: number) => void; + setZoomLevel: (zoomLevel: number) => void; +} + +/** + * Hook for managing timeline interaction state + */ +export function useTimelineInteractionState(): { + state: TimelineInteractionState; + setState: { + setIsDraggingScrubber: (value: boolean) => void; + setIsPanning: (value: boolean) => void; + setPanStartX: (value: number | null) => void; + setPanStartViewport: (value: {start: number; end: number} | null) => void; + setMouseDownPos: (value: {x: number; y: number} | null) => void; + setHoveredObjectId: (value: string | null) => void; + }; +} { + const [isDraggingScrubber, setIsDraggingScrubber] = useState(false); + const [isPanning, setIsPanning] = useState(false); + const [panStartX, setPanStartX] = useState(0); + const [panStartViewport, setPanStartViewport] = useState<{start: number; end: number} | null>( + null + ); + const [mouseDownPos, setMouseDownPos] = useState<{x: number; y: number} | null>(null); + const [hoveredObjectId, setHoveredObjectId] = useState(null); + + // Memoize state object + const state = useMemo( + () => ({ + isDraggingScrubber, + isPanning, + panStartX, + panStartViewport, + mouseDownPos, + hoveredObjectId + }), + [isDraggingScrubber, isPanning, panStartX, panStartViewport, mouseDownPos, hoveredObjectId] + ); + + // Memoize setState object + const setState = useMemo( + () => ({ + setIsDraggingScrubber, + setIsPanning, + setPanStartX, + setPanStartViewport, + setMouseDownPos, + setHoveredObjectId + }), + [ + setIsDraggingScrubber, + setIsPanning, + setPanStartX, + setPanStartViewport, + setMouseDownPos, + setHoveredObjectId + ] + ); + + return {state, setState}; +} + +/** + * Hook for creating timeline refs + */ +export function useTimelineRefs(): TimelineRefs { + const timelineLayerRef = useRef(null); + const containerRef = useRef(null); + + // Memoize refs object (refs themselves never change) + return useMemo(() => ({timelineLayerRef, containerRef}), []); +} + +/** + * Hook for global mouse up cleanup effect + */ +export function useGlobalMouseUpCleanup( + setIsDraggingScrubber: (value: boolean) => void, + setIsPanning: (value: boolean) => void, + setPanStartViewport: (value: {start: number; end: number} | null) => void +): void { + useEffect(() => { + const handleGlobalMouseUp = () => { + setIsDraggingScrubber(false); + setIsPanning(false); + setPanStartViewport(null); + }; + document.addEventListener('mouseup', handleGlobalMouseUp); + return () => document.removeEventListener('mouseup', handleGlobalMouseUp); + }, [setIsDraggingScrubber, setIsPanning, setPanStartViewport]); +} + +/** + * Hook for wheel zoom handler + */ +export function useWheelZoom( + containerRef: React.RefObject, + timelineLayerRef: React.RefObject, + zoomLevel: number +): void { + useEffect(() => { + const container = containerRef.current; + if (!container) return undefined; + + const handleWheel = (e: WheelEvent) => { + if (!timelineLayerRef.current) return; + e.preventDefault(); + const rect = container.getBoundingClientRect(); + const mouseX = e.clientX - rect.left; + const zoomFactor = e.deltaY > 0 ? 0.8 : 1.2; + timelineLayerRef.current.zoomToPoint(zoomFactor, mouseX, zoomLevel); + }; + + container.addEventListener('wheel', handleWheel, {passive: false}); + return () => container.removeEventListener('wheel', handleWheel); + }, [containerRef, timelineLayerRef, zoomLevel]); +} + +/** + * Hook for timeline layer callbacks + */ +export function useTimelineCallbacks(controls: TimelineControls): { + handleClipClick: (info: TimelineClipInfo) => void; + handleClipHover: (info: TimelineClipInfo | null) => void; + handleTrackClick: (info: TimelineTrackInfo) => void; + handleTrackHover: (info: TimelineTrackInfo | null) => void; + handleScrubberDrag: (timeMs: number) => void; + handleTimelineClick: (timeMs: number) => void; + handleViewportChange: (startMs: number, endMs: number) => void; + handleZoomChange: (zoomLevel: number) => void; +} { + // Destructure controls to get stable setter references + const { + setSelectedClip, + setHoveredClip, + setSelectedTrack, + setHoveredTrack, + setCurrentTimeMs, + setViewportStartMs, + setViewportEndMs, + setZoomLevel + } = controls; + + const handleClipClick = useCallback( + (info: TimelineClipInfo) => setSelectedClip(info.clip), + [setSelectedClip] + ); + + const handleClipHover = useCallback( + (info: TimelineClipInfo | null) => setHoveredClip(info?.clip || null), + [setHoveredClip] + ); + + const handleTrackClick = useCallback( + (info: TimelineTrackInfo) => setSelectedTrack(info.track), + [setSelectedTrack] + ); + + const handleTrackHover = useCallback( + (info: TimelineTrackInfo | null) => setHoveredTrack(info?.track || null), + [setHoveredTrack] + ); + + const handleScrubberDrag = useCallback( + (timeMs: number) => setCurrentTimeMs(timeMs), + [setCurrentTimeMs] + ); + + const handleTimelineClick = useCallback( + (timeMs: number) => setCurrentTimeMs(timeMs), + [setCurrentTimeMs] + ); + + const handleViewportChange = useCallback( + (startMs: number, endMs: number) => { + setViewportStartMs(startMs); + setViewportEndMs(endMs); + }, + [setViewportStartMs, setViewportEndMs] + ); + + const handleZoomChange = useCallback( + (zoomLevel: number) => setZoomLevel(zoomLevel), + [setZoomLevel] + ); + + // Memoize return object + return useMemo( + () => ({ + handleClipClick, + handleClipHover, + handleTrackClick, + handleTrackHover, + handleScrubberDrag, + handleTimelineClick, + handleViewportChange, + handleZoomChange + }), + [ + handleClipClick, + handleClipHover, + handleTrackClick, + handleTrackHover, + handleScrubberDrag, + handleTimelineClick, + handleViewportChange, + handleZoomChange + ] + ); +} + +// Helper: Handle pan start detection +function handlePanStartDetection(params: { + mouseX: number; + mouseY: number; + mouseDownPos: {x: number; y: number}; + zoomLevel: number; + viewportStartMs: number | undefined; + viewportEndMs: number | undefined; + timelineStart: number; + timelineEnd: number; + setIsPanning: (value: boolean) => void; + setPanStartX: (value: number | null) => void; + setPanStartViewport: (value: {start: number; end: number} | null) => void; + setMouseDownPos: (value: {x: number; y: number} | null) => void; +}): void { + if (params.zoomLevel <= 1.0) return; + + const dragDistance = Math.sqrt( + Math.pow(params.mouseX - params.mouseDownPos.x, 2) + + Math.pow(params.mouseY - params.mouseDownPos.y, 2) + ); + + if (dragDistance > DRAG_THRESHOLD) { + params.setIsPanning(true); + params.setPanStartX(params.mouseDownPos.x); + params.setPanStartViewport({ + start: params.viewportStartMs ?? params.timelineStart, + end: params.viewportEndMs ?? params.timelineEnd + }); + params.setMouseDownPos(null); + } +} + +// Helper: Handle pan viewport update +function handlePanUpdate(params: { + e: React.MouseEvent; + mouseX: number; + panStartX: number; + panStartViewport: {start: number; end: number}; + timelineWidth: number; + timelineStart: number; + timelineEnd: number; + setViewportStartMs: (startMs: number) => void; + setViewportEndMs: (endMs: number) => void; +}): void { + params.e.preventDefault(); + const deltaX = params.mouseX - params.panStartX; + const currentRange = params.panStartViewport.end - params.panStartViewport.start; + const timePerPixel = currentRange / params.timelineWidth; + const timeDelta = -deltaX * timePerPixel; + + let newStart = params.panStartViewport.start + timeDelta; + let newEnd = params.panStartViewport.end + timeDelta; + + if (newStart < params.timelineStart) { + newStart = params.timelineStart; + newEnd = params.timelineStart + currentRange; + } else if (newEnd > params.timelineEnd) { + newEnd = params.timelineEnd; + newStart = params.timelineEnd - currentRange; + } + + params.setViewportStartMs(newStart); + params.setViewportEndMs(newEnd); +} + +/** + * Hook for container mouse event handlers + */ +export function useContainerHandlers( + interactionState: TimelineInteractionState, + setState: ReturnType['setState'], + timelineLayerRef: React.RefObject, + controls: TimelineControls, + state: TimelineControlsState +): { + handleContainerMouseDown: (e: React.MouseEvent) => void; + handleContainerMouseMove: (e: React.MouseEvent) => void; + handleContainerMouseUp: () => void; +} { + // Destructure controls and setState to get stable setter references + const {setCurrentTimeMs, setViewportStartMs, setViewportEndMs} = controls; + const {setIsDraggingScrubber, setMouseDownPos, setIsPanning, setPanStartX, setPanStartViewport} = + setState; + + const handleContainerMouseDown = useCallback( + (e: React.MouseEvent) => { + if ((e.target as HTMLElement).tagName !== 'CANVAS') return; + + if (interactionState.hoveredObjectId === 'scrubber-handle') { + setIsDraggingScrubber(true); + return; + } + + if (state.zoomLevel > 1.0) { + const rect = e.currentTarget.getBoundingClientRect(); + // Account for container border (3px) + const borderWidth = 3; + setMouseDownPos({ + x: e.clientX - rect.left - borderWidth, + y: e.clientY - rect.top - borderWidth + }); + } + }, + [interactionState.hoveredObjectId, state.zoomLevel, setIsDraggingScrubber, setMouseDownPos] + ); + + const handleContainerMouseMove = useCallback( + (e: React.MouseEvent) => { + const rect = e.currentTarget.getBoundingClientRect(); + // Account for container border (3px) - canvas is inside the border + const borderWidth = 3; + const mouseX = e.clientX - rect.left - borderWidth; + const mouseY = e.clientY - rect.top - borderWidth; + + // Handle scrubber dragging + if (interactionState.isDraggingScrubber) { + // Calculate time directly using current state values (always up-to-date after resize) + const effectiveStartMs = state.viewportStartMs ?? state.timelineStart; + const effectiveEndMs = state.viewportEndMs ?? state.timelineEnd; + const timeMs = positionToTime( + mouseX, + state.timelineX, + state.timelineWidth, + effectiveStartMs, + effectiveEndMs + ); + setCurrentTimeMs(timeMs); + return; + } + + // Handle pan start detection + if ( + interactionState.mouseDownPos && + !interactionState.isPanning && + !interactionState.isDraggingScrubber + ) { + handlePanStartDetection({ + mouseX, + mouseY, + mouseDownPos: interactionState.mouseDownPos, + zoomLevel: state.zoomLevel, + viewportStartMs: state.viewportStartMs, + viewportEndMs: state.viewportEndMs, + timelineStart: state.timelineStart, + timelineEnd: state.timelineEnd, + setIsPanning, + setPanStartX, + setPanStartViewport, + setMouseDownPos + }); + } + + // Handle pan update + if (interactionState.isPanning && interactionState.panStartViewport) { + handlePanUpdate({ + e, + mouseX, + panStartX: interactionState.panStartX, + panStartViewport: interactionState.panStartViewport, + timelineWidth: state.timelineWidth, + timelineStart: state.timelineStart, + timelineEnd: state.timelineEnd, + setViewportStartMs, + setViewportEndMs + }); + } + }, + [ + interactionState.isDraggingScrubber, + interactionState.isPanning, + interactionState.mouseDownPos, + interactionState.panStartX, + interactionState.panStartViewport, + state.zoomLevel, + state.timelineStart, + state.timelineEnd, + state.timelineWidth, + state.timelineX, + state.viewportStartMs, + state.viewportEndMs, + setCurrentTimeMs, + setIsPanning, + setPanStartX, + setPanStartViewport, + setMouseDownPos, + setViewportStartMs, + setViewportEndMs + ] + ); + + const handleContainerMouseUp = useCallback(() => { + setMouseDownPos(null); + if (interactionState.isDraggingScrubber) setIsDraggingScrubber(false); + if (interactionState.isPanning) { + setIsPanning(false); + setPanStartViewport(null); + } + }, [ + interactionState.isDraggingScrubber, + interactionState.isPanning, + setMouseDownPos, + setIsDraggingScrubber, + setIsPanning, + setPanStartViewport + ]); + + // Memoize return object + return useMemo( + () => ({ + handleContainerMouseDown, + handleContainerMouseMove, + handleContainerMouseUp + }), + [handleContainerMouseDown, handleContainerMouseMove, handleContainerMouseUp] + ); +} + +// Helper: Update timeline position from click coordinate +function updateTimeFromClick( + coordinate: number[] | undefined, + timelineX: number, + timelineWidth: number, + viewportStartMs: number | undefined, + viewportEndMs: number | undefined, + timelineStart: number, + timelineEnd: number, + setCurrentTimeMs: (time: number) => void +): void { + if (coordinate) { + const clickX = coordinate[0] || 0; + const effectiveStartMs = viewportStartMs ?? timelineStart; + const effectiveEndMs = viewportEndMs ?? timelineEnd; + const timeMs = positionToTime(clickX, timelineX, timelineWidth, effectiveStartMs, effectiveEndMs); + setCurrentTimeMs(timeMs); + } +} + +/** + * Hook for DeckGL event handlers + */ +export function useDeckGLHandlers( + setState: ReturnType['setState'], + timelineLayerRef: React.RefObject, + controls: TimelineControls, + state: TimelineControlsState +): { + handleDeckGLHover: (info: any) => void; + handleDeckGLClick: (info: any) => void; +} { + // Destructure controls and setState to get stable setter references + const {setCurrentTimeMs, setSelectedClip, setSelectedTrack, setHoveredClip, setHoveredTrack} = + controls; + const {setHoveredObjectId, setIsDraggingScrubber, setMouseDownPos} = setState; + + const handleDeckGLHover = useCallback( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (info: any) => { + if (!info.object) { + setHoveredObjectId(null); + setHoveredClip(null); + setHoveredTrack(null); + return; + } + + const objectId = String(info.object.id); + const obj = info.object; + + setHoveredObjectId(objectId); + + // Set hovered clip or track based on object type + if (objectId.startsWith('track-') && objectId.includes('-clip-')) { + // Hovering clip + if (obj.clip) setHoveredClip(obj.clip); + setHoveredTrack(null); + } else if (objectId.startsWith('track-bg-')) { + // Hovering track background + setHoveredClip(null); + if (obj.track) setHoveredTrack(obj.track); + } else { + // Hovering something else (scrubber, axis, etc) + setHoveredClip(null); + setHoveredTrack(null); + } + }, + [setHoveredObjectId, setHoveredClip, setHoveredTrack] + ); + + const handleDeckGLClick = useCallback( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (info: any) => { + if (!info.object) { + // Clicked empty space + updateTimeFromClick( + info.coordinate, + state.timelineX, + state.timelineWidth, + state.viewportStartMs, + state.viewportEndMs, + state.timelineStart, + state.timelineEnd, + setCurrentTimeMs + ); + setSelectedClip(null); + setSelectedTrack(null); + return; + } + + const objectId = String(info.object.id); + const obj = info.object; + + if (objectId.startsWith('track-') && objectId.includes('-clip-')) { + // Clicked clip + if (obj.clip) setSelectedClip(obj.clip); + setSelectedTrack(null); + } else if (objectId === 'scrubber-handle') { + // Clicked scrubber + setIsDraggingScrubber(true); + setMouseDownPos(null); + setSelectedClip(null); + setSelectedTrack(null); + } else if (objectId.startsWith('track-bg-')) { + // Clicked track background + updateTimeFromClick( + info.coordinate, + state.timelineX, + state.timelineWidth, + state.viewportStartMs, + state.viewportEndMs, + state.timelineStart, + state.timelineEnd, + setCurrentTimeMs + ); + setSelectedClip(null); + if (obj.track) setSelectedTrack(obj.track); + } else { + // Clicked something else + setSelectedClip(null); + setSelectedTrack(null); + } + }, + [ + state.timelineX, + state.timelineWidth, + state.viewportStartMs, + state.viewportEndMs, + state.timelineStart, + state.timelineEnd, + setCurrentTimeMs, + setSelectedClip, + setSelectedTrack, + setIsDraggingScrubber, + setMouseDownPos + ] + ); + + // Memoize return object + return useMemo( + () => ({ + handleDeckGLHover, + handleDeckGLClick + }), + [handleDeckGLHover, handleDeckGLClick] + ); +} + +/** + * Hook for cursor getter + */ +export function useCursorGetter( + interactionState: TimelineInteractionState, + zoomLevel: number +): ({isHovering}: {isHovering: boolean}) => string { + return useCallback( + ({isHovering}: {isHovering: boolean}) => { + if (interactionState.isDraggingScrubber || interactionState.isPanning) return 'grabbing'; + if (interactionState.hoveredObjectId === 'scrubber-handle') return 'grab'; + if (isHovering) return 'pointer'; + if (zoomLevel > 1.0) return 'grab'; + return 'default'; + }, + [ + interactionState.isDraggingScrubber, + interactionState.isPanning, + interactionState.hoveredObjectId, + zoomLevel + ] + ); +} + +/** + * Hook to handle timeline dimension updates on window resize + * Note: This could be consolidated into a more generic useTimelineEffects hook + * that handles all lifecycle side effects (resize, initialization, cleanup, etc.) + */ +export function useTimelineResize(setTimelineWidth: (width: number) => void): void { + useEffect(() => { + const handleResize = () => { + const newCanvasWidth = window.innerWidth - 320; + const newTimelineWidth = newCanvasWidth - 200; + setTimelineWidth(newTimelineWidth); + }; + + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, [setTimelineWidth]); +} diff --git a/examples/timeline-layers/timeline-layer/tsconfig.json b/examples/timeline-layers/timeline-layer/tsconfig.json new file mode 100644 index 000000000..fed13de20 --- /dev/null +++ b/examples/timeline-layers/timeline-layer/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../../tsconfig.json", + "include": ["./*.tsx"] +} diff --git a/examples/timeline-layers/timeline-layer/use-timeline-interactions.ts b/examples/timeline-layers/timeline-layer/use-timeline-interactions.ts new file mode 100644 index 000000000..8e6ad6480 --- /dev/null +++ b/examples/timeline-layers/timeline-layer/use-timeline-interactions.ts @@ -0,0 +1,224 @@ +// deck.gl-community +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import {useCallback} from 'react'; +import {positionToTime} from '@deck.gl-community/timeline-layers'; +import type {TimelineControlsState} from './demo-controls'; + +export type TimelineInteractions = { + getTimeFromPosition: (x: number) => number; + zoomToPoint: (zoomFactor: number, mouseX?: number) => void; + startPan: (mouseX: number) => void; + updatePan: (mouseX: number) => void; + endPan: () => void; + handleMouseDown: (e: React.MouseEvent) => void; + handleMouseMove: (e: React.MouseEvent) => void; + handleWheel: (e: React.WheelEvent) => void; +} + +export function useTimelineInteractions( + state: TimelineControlsState, + controls: any, + isDraggingScrubber: boolean, + isPanning: boolean, + panStartX: number, + panStartViewport: {start: number; end: number} | null, + setIsDraggingScrubber: (v: boolean) => void, + setIsPanning: (v: boolean) => void, + setPanStartX: (v: number) => void, + setPanStartViewport: (v: {start: number; end: number} | null) => void, + isHoveringClip?: boolean +): TimelineInteractions { + const getTimeFromPosition = useCallback( + (x: number) => { + const effectiveStartMs = state.viewportStartMs ?? state.timelineStart; + const effectiveEndMs = state.viewportEndMs ?? state.timelineEnd; + return positionToTime( + x, + state.timelineX, + state.timelineWidth, + effectiveStartMs, + effectiveEndMs + ); + }, + [state] + ); + + const zoomToPoint = useCallback( + (zoomFactor: number, mouseX?: number) => { + const newZoomLevel = Math.max(1.0, Math.min(100, state.zoomLevel * zoomFactor)); + + if (mouseX !== undefined) { + const mouseRatio = Math.max( + 0, + Math.min(1, (mouseX - state.timelineX) / state.timelineWidth) + ); + const currentStartMs = state.viewportStartMs ?? state.timelineStart; + const currentEndMs = state.viewportEndMs ?? state.timelineEnd; + const mouseTimeMs = currentStartMs + mouseRatio * (currentEndMs - currentStartMs); + + const fullTimeRange = state.timelineEnd - state.timelineStart; + const newViewportRange = fullTimeRange / newZoomLevel; + + let newStartMs = mouseTimeMs - mouseRatio * newViewportRange; + let newEndMs = newStartMs + newViewportRange; + + if (newStartMs < state.timelineStart) { + newStartMs = state.timelineStart; + newEndMs = state.timelineStart + newViewportRange; + } else if (newEndMs > state.timelineEnd) { + newEndMs = state.timelineEnd; + newStartMs = state.timelineEnd - newViewportRange; + } + + if (newZoomLevel > 1.0) { + controls.setViewportStartMs(newStartMs); + controls.setViewportEndMs(newEndMs); + } else { + controls.setViewportStartMs(undefined); + controls.setViewportEndMs(undefined); + } + } else if (newZoomLevel <= 1.0) { + controls.setViewportStartMs(undefined); + controls.setViewportEndMs(undefined); + } + + controls.setZoomLevel(newZoomLevel); + }, + [state, controls] + ); + + const startPan = useCallback( + (mouseX: number) => { + if (state.zoomLevel > 1.0) { + setIsPanning(true); + setPanStartX(mouseX); + const currentStart = state.viewportStartMs ?? state.timelineStart; + const currentEnd = state.viewportEndMs ?? state.timelineEnd; + setPanStartViewport({start: currentStart, end: currentEnd}); + } + }, + [state, setIsPanning, setPanStartX, setPanStartViewport] + ); + + const updatePan = useCallback( + (mouseX: number) => { + if (isPanning && panStartViewport && state.zoomLevel > 1.0) { + const deltaX = mouseX - panStartX; + const currentRange = panStartViewport.end - panStartViewport.start; + const timePerPixel = currentRange / state.timelineWidth; + const timeDelta = -deltaX * timePerPixel; + + let newStart = panStartViewport.start + timeDelta; + let newEnd = panStartViewport.end + timeDelta; + + if (newStart < state.timelineStart) { + newStart = state.timelineStart; + newEnd = state.timelineStart + currentRange; + } else if (newEnd > state.timelineEnd) { + newEnd = state.timelineEnd; + newStart = state.timelineEnd - currentRange; + } + + controls.setViewportStartMs(newStart); + controls.setViewportEndMs(newEnd); + } + }, + [isPanning, panStartViewport, panStartX, state, controls] + ); + + const endPan = useCallback(() => { + setIsPanning(false); + setPanStartViewport(null); + }, [setIsPanning, setPanStartViewport]); + + const handleMouseDown = useCallback( + (e: React.MouseEvent) => { + // Skip timeline interactions if hovering over a clip + if (isHoveringClip) { + return; + } + + const rect = e.currentTarget.getBoundingClientRect(); + const mouseX = e.clientX - rect.left; + const mouseY = e.clientY - rect.top; + + // Calculate scrubber position + const effectiveStartMs = state.viewportStartMs ?? state.timelineStart; + const effectiveEndMs = state.viewportEndMs ?? state.timelineEnd; + const timeRatio = + (state.currentTimeMs - effectiveStartMs) / (effectiveEndMs - effectiveStartMs); + const scrubberPosition = + state.timelineX + Math.max(0, Math.min(1, timeRatio)) * state.timelineWidth; + + // Check if clicking on scrubber handle + const scrubberHandleLeft = scrubberPosition - 8; + const scrubberHandleRight = scrubberPosition + 8; + const scrubberHandleTop = state.timelineY - 45; + const scrubberHandleBottom = state.timelineY - 25; + + if ( + mouseX >= scrubberHandleLeft && + mouseX <= scrubberHandleRight && + mouseY >= scrubberHandleTop && + mouseY <= scrubberHandleBottom + ) { + e.preventDefault(); + setIsDraggingScrubber(true); + return; + } + + // Check if in timeline area + const isInTimeline = + mouseX >= state.timelineX && mouseX <= state.timelineX + state.timelineWidth; + + if (isInTimeline) { + if (state.zoomLevel > 1.0) { + startPan(mouseX); + } else { + controls.setCurrentTimeMs(getTimeFromPosition(mouseX)); + } + } + }, + [state, startPan, getTimeFromPosition, controls, setIsDraggingScrubber, isHoveringClip] + ); + + const handleMouseMove = useCallback( + (e: React.MouseEvent) => { + const rect = e.currentTarget.getBoundingClientRect(); + const mouseX = e.clientX - rect.left; + + if (isDraggingScrubber) { + e.preventDefault(); + controls.setCurrentTimeMs(getTimeFromPosition(mouseX)); + } else if (isPanning) { + e.preventDefault(); + updatePan(mouseX); + } + }, + [isDraggingScrubber, isPanning, getTimeFromPosition, updatePan, controls] + ); + + const handleWheel = useCallback( + (e: React.WheelEvent) => { + e.preventDefault(); + const rect = e.currentTarget.getBoundingClientRect(); + const mouseX = e.clientX - rect.left; + const zoomFactor = e.deltaY > 0 ? 0.8 : 1.2; + zoomToPoint(zoomFactor, mouseX); + }, + [zoomToPoint] + ); + + return { + getTimeFromPosition, + zoomToPoint, + startPan, + updatePan, + endPan, + handleMouseDown, + handleMouseMove, + handleWheel + }; +} diff --git a/modules/timeline-layers/src/index.ts b/modules/timeline-layers/src/index.ts index d65c9b9fe..32e6c1de2 100644 --- a/modules/timeline-layers/src/index.ts +++ b/modules/timeline-layers/src/index.ts @@ -2,12 +2,52 @@ // SPDX-License-Identifier: MIT // Copyright (c) vis.gl contributors +// HORIZON GRAPH LAYERS export type {HorizonGraphLayerProps} from './layers/horizon-graph-layer/horizon-graph-layer'; export {HorizonGraphLayer} from './layers/horizon-graph-layer/horizon-graph-layer'; export type {MultiHorizonGraphLayerProps} from './layers/horizon-graph-layer/multi-horizon-graph-layer'; export {MultiHorizonGraphLayer} from './layers/horizon-graph-layer/multi-horizon-graph-layer'; + +// AXIS LAYERS export type {TimeAxisLayerProps} from './layers/time-axis-layer'; export {TimeAxisLayer} from './layers/time-axis-layer'; export type {VerticalGridLayerProps} from './layers/vertical-grid-layer'; export {VerticalGridLayer} from './layers/vertical-grid-layer'; export {formatTimeMs, formatTimeRangeMs} from './utils/format-utils'; + +// TIMELINE LAYER +export type {TimelineLayerProps} from './layers/timeline-layer/timeline-layer'; +export {TimelineLayer} from './layers/timeline-layer/timeline-layer'; + +export type { + TimelineClip, + TimelineTrack, + TrackWithSubtracks, + TrackPosition, + TrackBackgroundData, + TrackLabelData, + ClipPolygonData, + ClipLabelData, + SeparatorLineData, + AxisLineData, + AxisLabelData, + ScrubberLineData, + ScrubberHandleData, + ScrubberLabelData, + TimelineTick, + TimelineViewport, + TimelineClipInfo, + TimelineTrackInfo, + TimelineScrubberInfo, + TimelineViewportInfo, + TimeAxisLabelFormatter, +} from './layers/timeline-layer/timeline-types'; + +export { + SelectionStyle, + TimelineLayout, + DEFAULT_TIMELINE_LAYOUT +} from './layers/timeline-layer/timeline-layout'; + +export {positionToTime, timeToPosition} from './layers/timeline-layer/timeline-utils'; +export {timeAxisFormatters} from './layers/timeline-layer/timeline-utils'; \ No newline at end of file diff --git a/modules/timeline-layers/src/layers/timeline-layer/timeline-collision.ts b/modules/timeline-layers/src/layers/timeline-layer/timeline-collision.ts new file mode 100644 index 000000000..25b861b0f --- /dev/null +++ b/modules/timeline-layers/src/layers/timeline-layer/timeline-collision.ts @@ -0,0 +1,60 @@ +// deck.gl-community +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import {TimelineClip} from './timeline-types'; + +export type ClipWithSubtrack = TimelineClip & { + subtrackIndex: number; +} + +/** + * Detects overlapping clips and assigns them to subtracks + * Uses a greedy algorithm to minimize the number of subtracks needed + */ +export function assignClipsToSubtracks(clips: TimelineClip[]): ClipWithSubtrack[] { + if (clips.length === 0) return []; + + // Sort clips by start time + const sortedClips = [...clips].sort((a, b) => a.startMs - b.startMs); + + // Track the end time of the last clip in each subtrack + const subtrackEndTimes: number[] = []; + const result: ClipWithSubtrack[] = []; + + for (const clip of sortedClips) { + // Find the first subtrack where this clip can fit + let assignedSubtrack = -1; + + for (let i = 0; i < subtrackEndTimes.length; i++) { + if (clip.startMs >= subtrackEndTimes[i]) { + // This clip doesn't overlap with the last clip in this subtrack + assignedSubtrack = i; + subtrackEndTimes[i] = clip.endMs; + break; + } + } + + // If no existing subtrack works, create a new one + if (assignedSubtrack === -1) { + assignedSubtrack = subtrackEndTimes.length; + subtrackEndTimes.push(clip.endMs); + } + + result.push({ + ...clip, + subtrackIndex: assignedSubtrack + }); + } + + return result; +} + +/** + * Calculate the number of subtracks needed for a set of clips + */ +export function calculateSubtrackCount(clips: TimelineClip[]): number { + if (clips.length === 0) return 1; + const clipsWithSubtracks = assignClipsToSubtracks(clips); + return Math.max(...clipsWithSubtracks.map(c => c.subtrackIndex)) + 1; +} diff --git a/modules/timeline-layers/src/layers/timeline-layer/timeline-layer.ts b/modules/timeline-layers/src/layers/timeline-layer/timeline-layer.ts new file mode 100644 index 000000000..f6ddbe039 --- /dev/null +++ b/modules/timeline-layers/src/layers/timeline-layer/timeline-layer.ts @@ -0,0 +1,915 @@ +// deck.gl-community +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import {CompositeLayer, COORDINATE_SYSTEM, PickingInfo, Layer} from '@deck.gl/core'; +import {SolidPolygonLayer, LineLayer, TextLayer} from '@deck.gl/layers'; +import type {CompositeLayerProps} from '@deck.gl/core'; +import type {LineLayerProps, SolidPolygonLayerProps, TextLayerProps} from '@deck.gl/layers'; + +import { + type TimelineClipInfo, + type TimelineTrackInfo, + type TimelineTrack, + type TrackWithSubtracks, + type TrackPosition, + type TrackBackgroundData, + type TrackLabelData, + type ClipPolygonData, + type ClipLabelData, + type SeparatorLineData, + type AxisLineData, + type AxisLabelData, + type ScrubberLineData, + type ScrubberHandleData, + type ScrubberLabelData, + type TimeAxisLabelFormatter +} from './timeline-types'; + +import { + type SelectionStyle, +} from './timeline-layout'; + +import { + timeAxisFormatters, + generateTimelineTicks, + timeToPosition, + positionToTime +} from './timeline-utils'; +import {assignClipsToSubtracks, calculateSubtrackCount} from './timeline-collision'; + +/** + * Helper function to lighten a color by adding a fixed amount to RGB channels + */ +function lightenColor( + color: [number, number, number, number], + amount: number = 30 +): [number, number, number, number] { + return [ + Math.min(255, color[0] + amount), + Math.min(255, color[1] + amount), + Math.min(255, color[2] + amount), + color[3] + ]; +} + +const defaultProps = { + x: 150, + y: 100, + width: 800, + trackHeight: 40, + trackSpacing: 10, + currentTimeMs: 0, + showScrubber: true, + showClipLabels: true, + showTrackLabels: true, + showAxis: true, + showSubtrackSeparators: true, + timeFormatter: timeAxisFormatters.seconds, + selectionStyle: { + selectedClipColor: [255, 200, 0, 255] as [number, number, number, number], + hoveredClipColor: [200, 200, 200, 255] as [number, number, number, number], + selectedTrackColor: [80, 80, 80, 255] as [number, number, number, number], + hoveredTrackColor: [70, 70, 70, 255] as [number, number, number, number], + selectedLineWidth: 3, + hoveredLineWidth: 2 + } +}; + +// ===== LAYER PROPS ===== + +export type TimelineLayerProps = CompositeLayerProps & { + // Required + data: TimelineTrack[]; + timelineStart: number; + timelineEnd: number; + + // Layout + x?: number; + y?: number; + width?: number; + trackHeight?: number; + trackSpacing?: number; + + // Time + currentTimeMs?: number; + viewport?: {startMs?: number; endMs?: number}; + timeFormatter?: TimeAxisLabelFormatter; + + // Selection + selectedClipId?: string | number | null; + hoveredClipId?: string | number | null; + selectedTrackId?: string | number | null; + hoveredTrackId?: string | number | null; + selectionStyle?: SelectionStyle; + + // Visibility + showScrubber?: boolean; + showClipLabels?: boolean; + showTrackLabels?: boolean; + showAxis?: boolean; + showSubtrackSeparators?: boolean; + + // Sublayer customization + clipProps?: Partial>; + trackProps?: Partial>; + trackLabelProps?: Partial>; + clipLabelProps?: Partial>; + axisLineProps?: Partial>; + axisLabelProps?: Partial>; + scrubberLineProps?: Partial>; + + // Callbacks + onClipClick?: (info: TimelineClipInfo, event: PickingInfo) => void; + onClipHover?: (info: TimelineClipInfo | null, event: PickingInfo) => void; + onTrackClick?: (info: TimelineTrackInfo, event: PickingInfo) => void; + onTrackHover?: (info: TimelineTrackInfo | null, event: PickingInfo) => void; + onScrubberHover?: (isHovering: boolean, event: PickingInfo) => void; + onScrubberDragStart?: (event: PickingInfo) => void; + onScrubberDrag?: (timeMs: number, event: PickingInfo) => void; + onTimelineClick?: (timeMs: number, event: PickingInfo) => void; + + // Interaction callbacks + onCurrentTimeChange?: (timeMs: number) => void; + onViewportChange?: (startMs: number, endMs: number) => void; + onZoomChange?: (zoomLevel: number) => void; +} + +export class TimelineLayer extends CompositeLayer { + static layerName = 'TimelineLayer'; + static defaultProps = defaultProps; + + getTimeFromPosition(x: number): number { + const {timelineStart, timelineEnd, viewport, x: timelineX = 150, width = 800} = this.props; + const effectiveStartMs = viewport?.startMs ?? timelineStart; + const effectiveEndMs = viewport?.endMs ?? timelineEnd; + return positionToTime(x, timelineX, width, effectiveStartMs, effectiveEndMs); + } + + zoomToPoint(zoomFactor: number, mouseX: number, currentZoomLevel: number): void { + const { + timelineStart, + timelineEnd, + viewport, + x: timelineX = 150, + width = 800, + onViewportChange, + onZoomChange + } = this.props; + + const newZoomLevel = Math.max(1.0, Math.min(100, currentZoomLevel * zoomFactor)); + const mouseRatio = Math.max(0, Math.min(1, (mouseX - timelineX) / width)); + const currentStartMs = viewport?.startMs ?? timelineStart; + const currentEndMs = viewport?.endMs ?? timelineEnd; + const mouseTimeMs = currentStartMs + mouseRatio * (currentEndMs - currentStartMs); + + const fullTimeRange = timelineEnd - timelineStart; + const newViewportRange = fullTimeRange / newZoomLevel; + + let newStartMs = mouseTimeMs - mouseRatio * newViewportRange; + let newEndMs = newStartMs + newViewportRange; + + if (newStartMs < timelineStart) { + newStartMs = timelineStart; + newEndMs = timelineStart + newViewportRange; + } else if (newEndMs > timelineEnd) { + newEndMs = timelineEnd; + newStartMs = timelineEnd - newViewportRange; + } + + if (newZoomLevel > 1.0) { + onViewportChange?.(newStartMs, newEndMs); + } else { + onViewportChange?.(timelineStart, timelineEnd); + } + + onZoomChange?.(newZoomLevel); + } + + // ===== CALCULATION HELPERS ===== + + private _calculateTrackPositions( + tracksWithSubtracks: TrackWithSubtracks[], + y: number, + trackHeight: number, + trackSpacing: number + ): {trackPositions: TrackPosition[]; totalTimelineHeight: number; subtrackSpacing: number} { + let currentY = y; + const trackPositions: TrackPosition[] = []; + const subtrackSpacing = 2; + + tracksWithSubtracks.forEach(({subtrackCount}) => { + const trackTotalHeight = subtrackCount * trackHeight + (subtrackCount - 1) * subtrackSpacing; + trackPositions.push({y: currentY, height: trackTotalHeight, subtrackCount}); + currentY += trackTotalHeight + trackSpacing; + }); + + const totalTimelineHeight = currentY - y - trackSpacing; + return {trackPositions, totalTimelineHeight, subtrackSpacing}; + } + + // ===== DATA GENERATION HELPERS ===== + + private _generateTrackBackgrounds( + tracksWithSubtracks: TrackWithSubtracks[], + trackPositions: TrackPosition[], + x: number, + width: number, + selectedTrackId: string | number | null | undefined, + hoveredTrackId: string | number | null | undefined, + selectionStyle: SelectionStyle + ): TrackBackgroundData[] { + return tracksWithSubtracks.map(({track, trackIndex}, i) => { + const trackId = track.id; + const {y: trackY, height} = trackPositions[i]; + const isSelected = selectedTrackId === trackId; + const isHovered = hoveredTrackId === trackId; + + let color: [number, number, number, number] = [60, 60, 60, 255]; + if (isSelected) { + color = selectionStyle.selectedTrackColor!; + } else if (isHovered) { + // Lighten the base color on hover + color = lightenColor([60, 60, 60, 255], 20); + } + + return { + id: `track-bg-${trackId}`, + track, + trackIndex, + polygon: [ + [x, trackY], + [x + width, trackY], + [x + width, trackY + height], + [x, trackY + height] + ], + color + }; + }); + } + + private _generateTrackLabels( + tracksWithSubtracks: TrackWithSubtracks[], + trackPositions: TrackPosition[], + x: number, + showTrackLabels: boolean + ): TrackLabelData[] { + if (!showTrackLabels) return []; + + return tracksWithSubtracks.map(({track}, i) => { + const label = track.name || `Track ${track.id}`; + const {y: trackY, height} = trackPositions[i]; + + return { + text: label, + position: [x - 10, trackY + height / 2, 0] + }; + }); + } + + private _generateClipPolygons( + tracksWithSubtracks: TrackWithSubtracks[], + trackPositions: TrackPosition[], + x: number, + width: number, + effectiveStartMs: number, + effectiveEndMs: number, + selectedClipId: string | number | null | undefined, + hoveredClipId: string | number | null | undefined, + selectionStyle: SelectionStyle + ): ClipPolygonData[] { + const clipPolygons: ClipPolygonData[] = []; + const clipPadding = 2; + const subtrackSpacing = 2; + + tracksWithSubtracks.forEach(({track, trackIndex, clips, subtrackCount}, i) => { + const {y: baseTrackY} = trackPositions[i]; + const subtrackHeight = + (trackPositions[i].height - (subtrackCount - 1) * subtrackSpacing) / subtrackCount; + + clips.forEach((clip, clipIndex) => { + const {id: clipId, startMs, endMs, subtrackIndex = 0} = clip; + + if (endMs <= effectiveStartMs || startMs >= effectiveEndMs) return; + + const clipTrackY = baseTrackY + subtrackIndex * (subtrackHeight + subtrackSpacing); + + const clipStartRatio = (startMs - effectiveStartMs) / (effectiveEndMs - effectiveStartMs); + const clipEndRatio = (endMs - effectiveStartMs) / (effectiveEndMs - effectiveStartMs); + + const clipStartX = x + Math.max(0, clipStartRatio) * width; + const clipEndX = x + Math.min(1, clipEndRatio) * width; + + const baseColor = clip.color || ([80, 120, 160, 220] as [number, number, number, number]); + const label = clip.label || ''; + + const isSelected = selectedClipId !== null && String(selectedClipId) === String(clipId); + const isHovered = hoveredClipId !== null && String(hoveredClipId) === String(clipId); + + let color = baseColor; + if (isSelected) { + color = selectionStyle.selectedClipColor!; + } else if (isHovered) { + // Lighten the base color on hover + color = lightenColor(baseColor, 40); + } + + clipPolygons.push({ + id: clipId, + clip, + track, + clipIndex, + trackIndex, + subtrackIndex, + polygon: [ + [clipStartX, clipTrackY + clipPadding], + [clipEndX, clipTrackY + clipPadding], + [clipEndX, clipTrackY + subtrackHeight - clipPadding], + [clipStartX, clipTrackY + subtrackHeight - clipPadding] + ], + color, + label, + labelPosition: [ + clipStartX + (clipEndX - clipStartX) / 2, + clipTrackY + subtrackHeight / 2, + 0 + ] + }); + }); + }); + + return clipPolygons; + } + + private _generateClipLabels( + clipPolygons: ClipPolygonData[], + showClipLabels: boolean + ): ClipLabelData[] { + if (!showClipLabels) return []; + return clipPolygons.map((clip) => ({text: clip.label, position: clip.labelPosition})); + } + + private _generateSubtrackSeparators( + showSubtrackSeparators: boolean, + tracksWithSubtracks: TrackWithSubtracks[], + trackPositions: TrackPosition[], + x: number, + width: number + ): SeparatorLineData[] { + if (!showSubtrackSeparators) return []; + + const separatorLines: SeparatorLineData[] = []; + const subtrackSpacing = 2; + + tracksWithSubtracks.forEach(({subtrackCount}, i) => { + if (subtrackCount <= 1) return; + + const {y: baseTrackY, height: trackHeight} = trackPositions[i]; + const subtrackHeight = (trackHeight - (subtrackCount - 1) * subtrackSpacing) / subtrackCount; + + for (let j = 1; j < subtrackCount; j++) { + const separatorY = + baseTrackY + j * (subtrackHeight + subtrackSpacing) - subtrackSpacing / 2; + separatorLines.push({ + sourcePosition: [x, separatorY], + targetPosition: [x + width, separatorY] + }); + } + }); + + return separatorLines; + } + + private _generateAxis( + showAxis: boolean, + x: number, + y: number, + width: number, + totalTimelineHeight: number, + effectiveStartMs: number, + effectiveEndMs: number, + timeFormatter: TimeAxisLabelFormatter + ): {axisLines: AxisLineData[]; axisLabels: AxisLabelData[]} { + const axisLines: AxisLineData[] = []; + const axisLabels: AxisLabelData[] = []; + + if (!showAxis) return {axisLines, axisLabels}; + + const axisHeight = 30; + const idealTickSpacing = 80; + const maxTicks = Math.floor(width / idealTickSpacing); + const tickCount = Math.max(4, Math.min(10, maxTicks)); + + const timelineTicks = generateTimelineTicks( + effectiveStartMs, + effectiveEndMs, + x, + width, + tickCount, + timeFormatter + ); + + const axisY = y + totalTimelineHeight + axisHeight; + + axisLines.push({ + sourcePosition: [x, axisY], + targetPosition: [x + width, axisY] + }); + + for (const tick of timelineTicks) { + axisLines.push({ + sourcePosition: [tick.position, axisY - 5], + targetPosition: [tick.position, axisY + 5] + }); + axisLabels.push({ + text: tick.label, + position: [tick.position, axisY + 15, 0] + }); + } + + return {axisLines, axisLabels}; + } + + private _generateScrubber( + showScrubber: boolean, + currentTimeMs: number, + x: number, + y: number, + width: number, + totalTimelineHeight: number, + effectiveStartMs: number, + effectiveEndMs: number, + timeFormatter: TimeAxisLabelFormatter + ): { + scrubberLine: ScrubberLineData[]; + scrubberHandle: ScrubberHandleData[]; + scrubberLabel: ScrubberLabelData[]; + } { + if (!showScrubber) { + return {scrubberLine: [], scrubberHandle: [], scrubberLabel: []}; + } + + const scrubberPosition = timeToPosition( + currentTimeMs, + x, + width, + effectiveStartMs, + effectiveEndMs + ); + + const scrubberLine = [ + { + sourcePosition: [scrubberPosition, y - 30] as [number, number], + targetPosition: [scrubberPosition, y + totalTimelineHeight + 30] as [number, number] + } + ]; + + const scrubberHandle = [ + { + id: 'scrubber-handle', + polygon: [ + [scrubberPosition - 8, y - 35], + [scrubberPosition + 8, y - 35], + [scrubberPosition + 8, y - 20], + [scrubberPosition - 8, y - 20] + ] as [number, number][], + color: [255, 100, 100, 255] as [number, number, number, number] + } + ]; + + const scrubberLabel = [ + { + text: timeFormatter(currentTimeMs), + position: [scrubberPosition, y - 40, 0] as [number, number, number] + } + ]; + + return {scrubberLine, scrubberHandle, scrubberLabel}; + } + + // ===== LAYER CREATION METHODS ===== + + private _createTrackLayers( + trackBackgrounds: TrackBackgroundData[], + trackLabels: TrackLabelData[], + showTrackLabels: boolean, + onTrackClick: ((info: TimelineTrackInfo, event: PickingInfo) => void) | undefined, + onTrackHover: ((info: TimelineTrackInfo | null, event: PickingInfo) => void) | undefined + ): Layer[] { + const {trackProps} = this.props; + const layers: Layer[] = []; + + layers.push( + new SolidPolygonLayer( + this.getSubLayerProps({ + ...trackProps, + id: 'tracks', + data: trackBackgrounds, + getPolygon: (d: TrackBackgroundData) => d.polygon, + getFillColor: (d: TrackBackgroundData) => d.color, + stroked: true, + getLineColor: [100, 100, 100, 255], + getLineWidth: 1, + pickable: Boolean(onTrackClick) || Boolean(onTrackHover), + onClick: (info: PickingInfo) => { + if (info.object && onTrackClick) { + const obj = info.object as TrackBackgroundData; + onTrackClick({track: obj.track, index: obj.trackIndex}, info); + } + }, + onHover: (info: PickingInfo) => { + if (onTrackHover) { + const obj = info.object as TrackBackgroundData | undefined; + onTrackHover(obj ? {track: obj.track, index: obj.trackIndex} : null, info); + } + } + }) + ) + ); + + if (showTrackLabels) { + layers.push( + new TextLayer({ + id: `${this.props.id}-track-labels`, + data: trackLabels, + getText: (d: TrackLabelData) => d.text, + getPosition: (d: TrackLabelData) => d.position, + getSize: 12, + getColor: [60, 60, 60, 255], + getTextAnchor: 'end', + getAlignmentBaseline: 'center', + fontFamily: 'Arial, sans-serif', + fontWeight: 'bold', + coordinateSystem: COORDINATE_SYSTEM.CARTESIAN + }) + ); + } + + return layers; + } + + private _createClipLayers( + clipPolygons: ClipPolygonData[], + clipLabelsData: ClipLabelData[], + showClipLabels: boolean, + selectedClipId: string | number | null | undefined, + selectionStyle: SelectionStyle, + onClipClick: ((info: TimelineClipInfo, event: PickingInfo) => void) | undefined, + onClipHover: ((info: TimelineClipInfo | null, event: PickingInfo) => void) | undefined + ): Layer[] { + const {clipProps} = this.props; + const layers: Layer[] = []; + + layers.push( + new SolidPolygonLayer( + this.getSubLayerProps({ + ...clipProps, + id: 'clips', + data: clipPolygons, + getPolygon: (d: ClipPolygonData) => d.polygon, + getFillColor: (d: ClipPolygonData) => d.color, + stroked: true, + getLineColor: [255, 255, 255, 200], + getLineWidth: (d: ClipPolygonData) => { + const isSelected = selectedClipId !== null && String(selectedClipId) === String(d.id); + return isSelected ? selectionStyle.selectedLineWidth || 3 : 2; + }, + pickable: Boolean(onClipClick) || Boolean(onClipHover), + autoHighlight: true, + onClick: (info: PickingInfo) => { + if (info.object && onClipClick) { + const obj = info.object as ClipPolygonData; + onClipClick( + { + clip: obj.clip, + track: obj.track, + clipIndex: obj.clipIndex, + trackIndex: obj.trackIndex, + subtrackIndex: obj.subtrackIndex + }, + info + ); + } + }, + onHover: (info: PickingInfo) => { + if (onClipHover) { + const obj = info.object as ClipPolygonData | undefined; + onClipHover( + obj + ? { + clip: obj.clip, + track: obj.track, + clipIndex: obj.clipIndex, + trackIndex: obj.trackIndex, + subtrackIndex: obj.subtrackIndex + } + : null, + info + ); + } + } + }) + ) + ); + + if (showClipLabels) { + layers.push( + new TextLayer({ + id: `${this.props.id}-clip-labels`, + data: clipLabelsData, + getText: (d: ClipLabelData) => d.text, + getPosition: (d: ClipLabelData) => d.position, + getSize: 10, + getColor: [255, 255, 255, 255], + getTextAnchor: 'middle', + getAlignmentBaseline: 'center', + fontFamily: 'Arial, sans-serif', + coordinateSystem: COORDINATE_SYSTEM.CARTESIAN + }) + ); + } + + return layers; + } + + private _createSubtrackSeparatorLayer( + subtrackSeparators: SeparatorLineData[], + showSubtrackSeparators: boolean + ): Layer | null { + if (!showSubtrackSeparators) return null; + + return new LineLayer({ + id: `${this.props.id}-subtrack-separators`, + data: subtrackSeparators, + getSourcePosition: (d: SeparatorLineData) => d.sourcePosition, + getTargetPosition: (d: SeparatorLineData) => d.targetPosition, + getColor: [180, 180, 180, 128], + getWidth: 1, + coordinateSystem: COORDINATE_SYSTEM.CARTESIAN + }); + } + + private _createAxisLayers( + axisLines: AxisLineData[], + axisLabels: AxisLabelData[], + showAxis: boolean + ): Layer[] { + if (!showAxis) return []; + + const {axisLineProps, axisLabelProps} = this.props; + const layers: Layer[] = []; + + layers.push( + new LineLayer( + this.getSubLayerProps({ + ...axisLineProps, + id: 'axis-lines', + data: axisLines, + getSourcePosition: (d: AxisLineData) => d.sourcePosition, + getTargetPosition: (d: AxisLineData) => d.targetPosition, + getColor: [150, 150, 150, 255], + getWidth: 2 + }) + ) + ); + + layers.push( + new TextLayer( + this.getSubLayerProps({ + ...axisLabelProps, + id: 'axis-labels', + data: axisLabels, + getText: (d: AxisLabelData) => d.text, + getPosition: (d: AxisLabelData) => d.position, + getSize: 11, + getColor: [150, 150, 150, 255], + getTextAnchor: 'middle', + getAlignmentBaseline: 'top' + }) + ) + ); + + return layers; + } + + private _createScrubberLayers( + scrubberLine: ScrubberLineData[], + scrubberHandle: ScrubberHandleData[], + scrubberLabel: ScrubberLabelData[], + showScrubber: boolean, + onScrubberDragStart: ((event: PickingInfo) => void) | undefined, + onScrubberHover: ((isHovering: boolean, event: PickingInfo) => void) | undefined + ): Layer[] { + if (!showScrubber) return []; + + const {scrubberLineProps} = this.props; + const layers: Layer[] = []; + + layers.push( + new LineLayer( + this.getSubLayerProps({ + ...scrubberLineProps, + id: 'scrubber-line', + data: scrubberLine, + getSourcePosition: (d: ScrubberLineData) => d.sourcePosition, + getTargetPosition: (d: ScrubberLineData) => d.targetPosition, + getColor: [255, 100, 100, 255], + getWidth: 2 + }) + ) + ); + + layers.push( + new SolidPolygonLayer( + this.getSubLayerProps({ + id: 'scrubber-handle', + data: scrubberHandle, + getPolygon: (d: ScrubberHandleData) => d.polygon, + getFillColor: (d: ScrubberHandleData) => d.color, + stroked: true, + getLineColor: [255, 255, 255, 255], + getLineWidth: 2, + pickable: true, + onClick: (info: PickingInfo) => { + if (onScrubberDragStart && info.object) { + onScrubberDragStart(info); + } + }, + onHover: (info: PickingInfo) => { + if (onScrubberHover) { + onScrubberHover(Boolean(info.object), info); + } + } + }) + ) + ); + + layers.push( + new TextLayer( + this.getSubLayerProps({ + id: 'scrubber-label', + data: scrubberLabel, + getText: (d: ScrubberLabelData) => d.text, + getPosition: (d: ScrubberLabelData) => d.position, + getSize: 11, + getColor: [255, 100, 100, 255], + getTextAnchor: 'middle', + getAlignmentBaseline: 'bottom' + }) + ) + ); + + return layers; + } + + // ===== MAIN RENDER METHOD ===== + + renderLayers(): Layer[] { + const { + data: tracks, + timelineStart, + timelineEnd, + viewport, + x = 150, + y = 100, + width = 800, + trackHeight = 40, + trackSpacing = 10, + currentTimeMs = 0, + showScrubber = true, + showClipLabels = true, + showTrackLabels = true, + showAxis = true, + showSubtrackSeparators = true, + timeFormatter = timeAxisFormatters.seconds, + selectedClipId, + hoveredClipId, + selectedTrackId, + hoveredTrackId, + selectionStyle = defaultProps.selectionStyle, + onClipClick, + onClipHover, + onTrackClick, + onTrackHover, + onScrubberHover, + onScrubberDragStart + } = this.props; + + const effectiveStartMs = viewport?.startMs ?? timelineStart; + const effectiveEndMs = viewport?.endMs ?? timelineEnd; + + const visibleTracks = tracks.filter((track) => track.visible !== false); + + const tracksWithSubtracks: TrackWithSubtracks[] = visibleTracks.map((track, trackIndex) => { + const clips = track.clips || []; + const clipsWithSubtracks = assignClipsToSubtracks(clips); + const subtrackCount = Math.max(1, calculateSubtrackCount(clips)); + + return {track, trackIndex, clips: clipsWithSubtracks, subtrackCount}; + }); + + const {trackPositions, totalTimelineHeight} = this._calculateTrackPositions( + tracksWithSubtracks, + y, + trackHeight, + trackSpacing + ); + + const trackBackgrounds = this._generateTrackBackgrounds( + tracksWithSubtracks, + trackPositions, + x, + width, + selectedTrackId, + hoveredTrackId, + selectionStyle + ); + + const trackLabels = this._generateTrackLabels( + tracksWithSubtracks, + trackPositions, + x, + showTrackLabels + ); + + const clipPolygons = this._generateClipPolygons( + tracksWithSubtracks, + trackPositions, + x, + width, + effectiveStartMs, + effectiveEndMs, + selectedClipId, + hoveredClipId, + selectionStyle + ); + + const clipLabelsData = this._generateClipLabels(clipPolygons, showClipLabels); + + const subtrackSeparators = this._generateSubtrackSeparators( + showSubtrackSeparators, + tracksWithSubtracks, + trackPositions, + x, + width + ); + + const {axisLines, axisLabels} = this._generateAxis( + showAxis, + x, + y, + width, + totalTimelineHeight, + effectiveStartMs, + effectiveEndMs, + timeFormatter + ); + + const {scrubberLine, scrubberHandle, scrubberLabel} = this._generateScrubber( + showScrubber, + currentTimeMs, + x, + y, + width, + totalTimelineHeight, + effectiveStartMs, + effectiveEndMs, + timeFormatter + ); + + const layers = [ + ...this._createTrackLayers( + trackBackgrounds, + trackLabels, + showTrackLabels, + onTrackClick, + onTrackHover + ), + ...this._createClipLayers( + clipPolygons, + clipLabelsData, + showClipLabels, + selectedClipId, + selectionStyle, + onClipClick, + onClipHover + ), + this._createSubtrackSeparatorLayer(subtrackSeparators, showSubtrackSeparators), + ...this._createAxisLayers(axisLines, axisLabels, showAxis), + ...this._createScrubberLayers( + scrubberLine, + scrubberHandle, + scrubberLabel, + showScrubber, + onScrubberDragStart, + onScrubberHover + ) + ].filter(Boolean); + + return layers; + } +} + +// Export types for external use +export type {TimelineClipInfo, TimelineTrackInfo} from './timeline-types'; +export {timeAxisFormatters} from './timeline-utils'; diff --git a/modules/timeline-layers/src/layers/timeline-layer/timeline-layout.ts b/modules/timeline-layers/src/layers/timeline-layer/timeline-layout.ts new file mode 100644 index 000000000..016df0a6e --- /dev/null +++ b/modules/timeline-layers/src/layers/timeline-layer/timeline-layout.ts @@ -0,0 +1,78 @@ + +// ===== STYLE TYPES ===== + +export type SelectionStyle = { + selectedClipColor?: [number, number, number, number]; + hoveredClipColor?: [number, number, number, number]; + selectedTrackColor?: [number, number, number, number]; + hoveredTrackColor?: [number, number, number, number]; + selectedLineWidth?: number; + hoveredLineWidth?: number; +} + +export type TimelineLayout = { + x?: number; + y?: number; + width?: number; + trackHeight?: number; + trackSpacing?: number; + showTopAxis?: boolean; + showBottomAxis?: boolean; + axisHeight?: number; + axisTickHeight?: number; + axisLabelSize?: number; + showVerticalGrid?: boolean; + showTrackLabels?: boolean; + trackLabelWidth?: number; + scrubberWidth?: number; + scrubberHandleSize?: number; + scrubberLabelSize?: number; + clipPadding?: number; + clipLabelSize?: number; + showClipLabels?: boolean; + clipLabelsMinZoom?: number; + maxClipLabels?: number; + backgroundColor?: [number, number, number, number]; + trackBackgroundColor?: [number, number, number, number]; + trackBorderColor?: [number, number, number, number]; + scrubberColor?: [number, number, number, number]; + axisColor?: [number, number, number, number]; + gridColor?: [number, number, number, number]; + selectedClipColor?: [number, number, number, number]; + hoveredClipColor?: [number, number, number, number]; +} + + +// ===== CONSTANTS ===== + +export const DEFAULT_TIMELINE_LAYOUT: Required = { + x: 50, + y: 100, + width: 800, + trackHeight: 60, + trackSpacing: 10, + showTopAxis: true, + showBottomAxis: false, + axisHeight: 30, + axisTickHeight: 10, + axisLabelSize: 11, + showVerticalGrid: true, + showTrackLabels: true, + trackLabelWidth: 100, + scrubberWidth: 3, + scrubberHandleSize: 16, + scrubberLabelSize: 10, + clipPadding: 5, + clipLabelSize: 10, + showClipLabels: true, + clipLabelsMinZoom: 1.5, + maxClipLabels: 1000, + backgroundColor: [255, 255, 255, 0], + trackBackgroundColor: [240, 240, 240, 100], + trackBorderColor: [200, 200, 200, 255], + scrubberColor: [255, 69, 0, 255], + axisColor: [50, 50, 50, 255], + gridColor: [200, 200, 200, 128], + selectedClipColor: [255, 255, 0, 220], + hoveredClipColor: [255, 255, 255, 255] +}; diff --git a/modules/timeline-layers/src/layers/timeline-layer/timeline-types.ts b/modules/timeline-layers/src/layers/timeline-layer/timeline-types.ts new file mode 100644 index 000000000..96f3cbef1 --- /dev/null +++ b/modules/timeline-layers/src/layers/timeline-layer/timeline-types.ts @@ -0,0 +1,142 @@ +// deck.gl-community +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +// ===== CORE DATA TYPES ===== + +export type TimelineClip = { + id: string | number; + startMs: number; + endMs: number; + color?: [number, number, number, number]; + label?: string; + subtrackIndex?: number; + [key: string]: unknown; +} + +export type TimelineTrack = { + id: string | number; + clips: TimelineClip[]; + visible?: boolean; + name?: string; + [key: string]: unknown; +} + +// ===== INTERNAL DATA TYPES ===== + +export type TrackWithSubtracks = { + track: TimelineTrack; + trackIndex: number; + clips: TimelineClip[]; + subtrackCount: number; +} + +export type TrackPosition = { + y: number; + height: number; + subtrackCount: number; +} + +export type TrackBackgroundData = { + id: string; + track: TimelineTrack; + trackIndex: number; + polygon: [number, number][]; + color: [number, number, number, number]; +} + +export type TrackLabelData = { + text: string; + position: [number, number, number]; +} + +export type ClipPolygonData = { + id: string | number; + clip: TimelineClip; + track: TimelineTrack; + clipIndex: number; + trackIndex: number; + subtrackIndex: number; + polygon: [number, number][]; + color: [number, number, number, number]; + label: string; + labelPosition: [number, number, number]; +} + +export type ClipLabelData = { + text: string; + position: [number, number, number]; +} + +export type SeparatorLineData = { + sourcePosition: [number, number]; + targetPosition: [number, number]; +} + +export type AxisLineData = { + sourcePosition: [number, number]; + targetPosition: [number, number]; +} + +export type AxisLabelData = { + text: string; + position: [number, number, number]; +} + +export type ScrubberLineData = { + sourcePosition: [number, number]; + targetPosition: [number, number]; +} + +export type ScrubberHandleData = { + id: string; + polygon: [number, number][]; + color: [number, number, number, number]; +} + +export type ScrubberLabelData = { + text: string; + position: [number, number, number]; +} + +export type TimelineTick = { + position: number; + timeMs: number; + label: string; +} + +// ===== CALLBACK INFO TYPES ===== + +export type TimelineViewport = { + startMs?: number; + endMs?: number; +} + +export type TimelineClipInfo = { + clip: TimelineClip; + track: TimelineTrack; + clipIndex: number; + trackIndex: number; + subtrackIndex: number; +} + +export type TimelineTrackInfo = { + track: TimelineTrack; + index: number; +} + +export type TimelineScrubberInfo = { + timeMs: number; + isDragging: boolean; +} + +export type TimelineViewportInfo = { + startMs: number; + endMs: number; + zoomLevel: number; +} + +// ===== FORMATTER TYPE ===== + +export type TimeAxisLabelFormatter = (timeMs: number) => string; + diff --git a/modules/timeline-layers/src/layers/timeline-layer/timeline-utils.ts b/modules/timeline-layers/src/layers/timeline-layer/timeline-utils.ts new file mode 100644 index 000000000..d50bf57fb --- /dev/null +++ b/modules/timeline-layers/src/layers/timeline-layer/timeline-utils.ts @@ -0,0 +1,83 @@ +// deck.gl-community +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import type {TimeAxisLabelFormatter, TimelineTick} from './timeline-types'; + +// ===== TIME FORMATTERS ===== + +export const timeAxisFormatters = { + seconds: (timeMs: number): string => (timeMs / 1000).toFixed(1) + 's', + timestamp: (timeMs: number): string => new Date(timeMs).toLocaleTimeString(), + minutesSeconds: (timeMs: number): string => { + const totalSeconds = Math.floor(timeMs / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${minutes}:${seconds.toString().padStart(2, '0')}`; + }, + hoursMinutesSeconds: (timeMs: number): string => { + const totalSeconds = Math.floor(timeMs / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; + } +}; + +// ===== TIMELINE HELPERS ===== + +/** + * Generate timeline axis ticks + */ +export function generateTimelineTicks( + startMs: number, + endMs: number, + timelineX: number, + timelineWidth: number, + tickCount: number, + formatter: TimeAxisLabelFormatter +): TimelineTick[] { + const ticks: TimelineTick[] = []; + const timeRange = endMs - startMs; + const step = timeRange / (tickCount - 1); + + for (let i = 0; i < tickCount; i++) { + const timeMs = startMs + i * step; + const position = timelineX + (i / (tickCount - 1)) * timelineWidth; + const label = formatter(timeMs); + + ticks.push({position, timeMs, label}); + } + + return ticks; +} + +/** + * Convert mouse position to time + */ +export function positionToTime( + x: number, + timelineX: number, + timelineWidth: number, + startMs: number, + endMs: number +): number { + const ratio = (x - timelineX) / timelineWidth; + const clampedRatio = Math.max(0, Math.min(1, ratio)); + return startMs + clampedRatio * (endMs - startMs); +} + +/** + * Convert time to position + */ +export function timeToPosition( + timeMs: number, + timelineX: number, + timelineWidth: number, + startMs: number, + endMs: number +): number { + const timeRatio = (timeMs - startMs) / (endMs - startMs); + const clampedRatio = Math.max(0, Math.min(1, timeRatio)); + return timelineX + clampedRatio * timelineWidth; +} diff --git a/tsconfig.json b/tsconfig.json index c0f55eab7..22aa85f6a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -31,6 +31,7 @@ "@deck.gl-community/layers": ["./modules/layers/src"], "@deck.gl-community/geo-layers": ["./modules/geo-layers/src"], "@deck.gl-community/infovis-layers": ["./modules/infovis-layers/src"], + "@deck.gl-community/timeline-layers": ["./modules/timeline-layers/src"], "@deck.gl-community/react": ["./modules/react/src"], "@deck.gl-community/template": ["./modules/template/src"], "@deck.gl-community/graph-layers": ["./modules/graph-layers/src"] diff --git a/yarn.lock b/yarn.lock index 03db3903b..bd2466253 100644 --- a/yarn.lock +++ b/yarn.lock @@ -19108,6 +19108,25 @@ __metadata: languageName: node linkType: hard +"timeline-layer-0054e4@workspace:examples/timeline-layers/timeline-layer": + version: 0.0.0-use.local + resolution: "timeline-layer-0054e4@workspace:examples/timeline-layers/timeline-layer" + dependencies: + "@deck.gl-community/timeline-layers": "workspace:*" + "@deck.gl/core": "npm:~9.2.1" + "@deck.gl/layers": "npm:~9.2.1" + "@deck.gl/react": "npm:~9.2.1" + "@deck.gl/widgets": "npm:~9.2.1" + "@loaders.gl/core": "npm:^4.2.0" + "@luma.gl/core": "npm:~9.2.0" + "@luma.gl/engine": "npm:~9.2.0" + "@luma.gl/shadertools": "npm:~9.2.0" + react: "npm:^18.3.1" + react-dom: "npm:^18.3.1" + vite: "npm:7.1.1" + languageName: unknown + linkType: soft + "timers-browserify@npm:^2.0.4": version: 2.0.12 resolution: "timers-browserify@npm:2.0.12"