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
+
+
+
+
+ Number of Tracks: {trackCount}
+
+ controls.setTrackCount(Number(e.target.value))}
+ style={{width: '100%'}}
+ />
+
+
+
+
+ Clips per Track: {clipsPerTrack}
+
+ controls.setClipsPerTrack(Number(e.target.value))}
+ style={{width: '100%', padding: '5px'}}
+ >
+ 3 (Demo)
+ 5
+ 10
+ 20
+ 50
+ 100
+
+
+
+
+
+ Timeline Duration (seconds): {(state.timelineEnd - state.timelineStart) / 1000}
+
+ controls.setTimelineEnd(state.timelineStart + Number(e.target.value))}
+ style={{width: '100%'}}
+ />
+
+
+
+
+
+ Timeline Axis Labels
+
+
+
+
+ Label Format:
+
+ controls.setLabelFormatterType(e.target.value as keyof typeof timeAxisFormatters)}
+ style={{width: '100%', padding: '5px'}}
+ >
+ Seconds (5.2s)
+ Minutes:Seconds (1:23)
+ Hours:Minutes:Seconds (0:01:23)
+ Real Timestamp
+
+
+
+
+
+
+ Track Appearance
+
+
+
+
+ Track Height: {state.trackHeight}px
+
+ controls.setTrackHeight(Number(e.target.value))}
+ style={{width: '100%'}}
+ />
+
+
+
+
+ Track Spacing: {state.trackSpacing}px
+
+ 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
+
+
controls.setSelectedClip(null)}
+ style={{
+ marginTop: '10px',
+ padding: '4px 8px',
+ fontSize: '11px',
+ border: 'none',
+ background: '#d4af37',
+ color: 'white',
+ borderRadius: '3px',
+ cursor: 'pointer'
+ }}
+ >
+ Clear Selection
+
+
+
+ )}
+
+ {state.selectedTrack && (
+
+
+ Selected Track
+
+
+
+ Name: {state.selectedTrack.name}
+
+
+ ID: {state.selectedTrack.id}
+
+
+ Clips: {state.selectedTrack.clips.length}
+
+
controls.setSelectedTrack(null)}
+ style={{
+ marginTop: '10px',
+ padding: '4px 8px',
+ fontSize: '11px',
+ border: 'none',
+ background: '#4a90e2',
+ color: 'white',
+ borderRadius: '3px',
+ cursor: 'pointer'
+ }}
+ >
+ Clear Selection
+
+
+
+ )}
+
+
+
+ Timeline Scrubber
+
+
+
+
+ Current Time: {state.labelFormatter(state.currentTimeMs)}
+
+ controls.setCurrentTimeMs(Number(e.target.value))}
+ style={{width: '100%'}}
+ />
+
+
+
+
+
+ Timeline Zoom
+
+
+
+
+ Zoom Level: {state.zoomLevel.toFixed(2)}x
+
+ 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
+
+
+
+
+ Selected Line Width: {state.selectedLineWidth}px
+
+ controls.setSelectedLineWidth(Number(e.target.value))}
+ style={{width: '100%'}}
+ />
+
+
+
+
+ Hovered Line Width: {state.hoveredLineWidth}px
+
+ controls.setHoveredLineWidth(Number(e.target.value))}
+ style={{width: '100%'}}
+ />
+
+
+
+
+
+ Subtrack Separators
+
+
+ controls.setShowSubtrackSeparators(e.target.checked)}
+ style={{marginRight: '8px'}}
+ />
+ Show subtrack separators
+
+
+
+
+
Track Labels
+
+
+ Font Size: {state.trackLabelFontSize}px
+
+ controls.setTrackLabelFontSize(Number(e.target.value))}
+ style={{width: '100%'}}
+ />
+
+
+
+
+
Clip Labels
+
+
+ Font Size: {state.clipLabelFontSize}px
+
+ 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"