diff --git a/examples/timeline-layers/timeline-layer/README.md b/examples/timeline-layers/timeline-layer/README.md
new file mode 100644
index 000000000..aa57866e0
--- /dev/null
+++ b/examples/timeline-layers/timeline-layer/README.md
@@ -0,0 +1,38 @@
+# Timeline Layer Example
+
+An interactive timeline visualization built with `@deck.gl-community/timeline-layers`.
+
+## Features
+
+- Multi-track timeline with clip visualization
+- Collision detection for overlapping clips (automatic subtrack assignment)
+- Interactive playhead scrubber with drag support
+- Zoom (mouse wheel) and pan (click + drag when zoomed)
+- Clip and track selection with hover highlighting
+- Multiple time formatting options
+
+## Usage
+
+```bash
+yarn start
+```
+
+Open [http://localhost:5173](http://localhost:5173)
+
+## API
+
+Import `TimelineLayer` from `@deck.gl-community/timeline-layers`:
+
+```ts
+import {TimelineLayer} from '@deck.gl-community/timeline-layers';
+
+const layer = new TimelineLayer({
+ data: tracks, // TimelineTrack[]
+ timelineStart: 0, // start of full range (ms)
+ timelineEnd: 60000, // end of full range (ms)
+ currentTimeMs: 5000, // current playhead position
+ onClipClick: ({clip, track}) => console.log('clicked', clip.label)
+});
+```
+
+See `TimelineLayerProps` for the full API reference.
diff --git a/examples/timeline-layers/timeline-layer/app.tsx b/examples/timeline-layers/timeline-layer/app.tsx
new file mode 100644
index 000000000..d3765d9c6
--- /dev/null
+++ b/examples/timeline-layers/timeline-layer/app.tsx
@@ -0,0 +1,174 @@
+// deck.gl-community
+// SPDX-License-Identifier: MIT
+// Copyright (c) vis.gl contributors
+
+import React, {ReactElement, useMemo, useEffect} from 'react';
+import DeckGL from '@deck.gl/react';
+import {OrthographicView, type OrthographicViewState} 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 ORTHOGRAPHIC_VIEW = new OrthographicView();
+
+const CONTROLLER_CONFIG = {
+ scrollZoom: false,
+ doubleClickZoom: false,
+ touchZoom: false,
+ dragPan: false,
+ dragRotate: false,
+ keyboard: false
+} as const;
+
+export default function App(): ReactElement {
+ const {state, controls, trackCount, clipsPerTrack, labelFormatterType} = useTimelineControls();
+ const {state: interactionState, setState} = useTimelineInteractionState();
+ const {timelineLayerRef, containerRef} = useTimelineRefs();
+
+ const initialViewState = useMemo((): OrthographicViewState => {
+ const w = (typeof window !== 'undefined' ? window.innerWidth : 1280) - 320;
+ const h = typeof window !== 'undefined' ? window.innerHeight : 720;
+ return {target: [w / 2, h / 2, 0], zoom: 0};
+ }, []);
+
+ 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);
+
+ const viewport = useMemo(
+ () => ({startMs: state.viewportStartMs, endMs: state.viewportEndMs}),
+ [state.viewportStartMs, state.viewportEndMs]
+ );
+
+ 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 timelineLayer = useMemo(
+ () =>
+ new TimelineLayer({
+ 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
+ }),
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [
+ 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
+ ]
+ );
+
+ useEffect(() => {
+ timelineLayerRef.current = timelineLayer;
+ }, [timelineLayer, timelineLayerRef]);
+
+ return (
+
+ );
+}
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..212907cb8
--- /dev/null
+++ b/examples/timeline-layers/timeline-layer/demo-controls.tsx
@@ -0,0 +1,520 @@
+// 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
+} from '@deck.gl-community/timeline-layers';
+import {generateRandomTracks} from './demo-utils';
+
+const PANEL_WIDTH = 320;
+const INITIAL_CANVAS_WIDTH = (typeof window !== 'undefined' ? window.innerWidth : 1280) - PANEL_WIDTH;
+
+export type TimelineControlsState = {
+ tracks: TimelineTrack[];
+ timelineStart: number;
+ timelineEnd: number;
+ currentTimeMs: number;
+ zoomLevel: number;
+ trackHeight: number;
+ trackSpacing: number;
+ timelineX: number;
+ timelineY: number;
+ timelineWidth: number;
+ viewportStartMs?: number;
+ viewportEndMs?: number;
+ labelFormatter: (timeMs: number) => string;
+ selectedClip: TimelineClip | null;
+ hoveredClip: TimelineClip | null;
+ selectedTrack: TimelineTrack | null;
+ hoveredTrack: TimelineTrack | null;
+ selectedLineWidth: number;
+ hoveredLineWidth: number;
+ showSubtrackSeparators: boolean;
+};
+
+export function useTimelineControls() {
+ 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);
+
+ const [trackHeight, setTrackHeight] = useState(40);
+ const [trackSpacing, setTrackSpacing] = useState(8);
+ const [timelineX] = useState(150);
+ const [timelineY] = useState(80);
+ const [timelineWidth, setTimelineWidth] = useState(INITIAL_CANVAS_WIDTH - 200);
+
+ const [viewportStartMs, setViewportStartMs] = useState(undefined);
+ const [viewportEndMs, setViewportEndMs] = useState(undefined);
+
+ const [selectedClip, setSelectedClip] = useState(null);
+ const [hoveredClip, setHoveredClip] = useState(null);
+ const [selectedTrack, setSelectedTrack] = useState(null);
+ const [hoveredTrack, setHoveredTrack] = useState(null);
+
+ const [labelFormatterType, setLabelFormatterType] =
+ useState('seconds');
+ const labelFormatter = timeAxisFormatters[labelFormatterType];
+
+ const [selectedLineWidth, setSelectedLineWidth] = useState(3);
+ const [hoveredLineWidth, setHoveredLineWidth] = useState(2);
+ const [showSubtrackSeparators, setShowSubtrackSeparators] = useState(true);
+
+ const tracks = useMemo(
+ () => generateRandomTracks(trackCount, clipsPerTrack, timelineStart, timelineEnd),
+ [trackCount, clipsPerTrack, timelineStart, timelineEnd]
+ );
+
+ const state: TimelineControlsState = useMemo(
+ () => ({
+ tracks,
+ timelineStart,
+ timelineEnd,
+ currentTimeMs,
+ zoomLevel,
+ trackHeight,
+ trackSpacing,
+ timelineX,
+ timelineY,
+ timelineWidth,
+ viewportStartMs,
+ viewportEndMs,
+ labelFormatter,
+ selectedClip,
+ hoveredClip,
+ selectedTrack,
+ hoveredTrack,
+ selectedLineWidth,
+ hoveredLineWidth,
+ showSubtrackSeparators
+ }),
+ [
+ tracks,
+ timelineStart,
+ timelineEnd,
+ currentTimeMs,
+ zoomLevel,
+ trackHeight,
+ trackSpacing,
+ timelineX,
+ timelineY,
+ timelineWidth,
+ viewportStartMs,
+ viewportEndMs,
+ labelFormatter,
+ selectedClip,
+ hoveredClip,
+ selectedTrack,
+ hoveredTrack,
+ selectedLineWidth,
+ hoveredLineWidth,
+ showSubtrackSeparators
+ ]
+ );
+
+ const controls = useMemo(
+ () => ({
+ setTrackCount,
+ setClipsPerTrack,
+ setTimelineEnd,
+ setCurrentTimeMs,
+ setZoomLevel,
+ setTrackHeight,
+ setTrackSpacing,
+ setTimelineWidth,
+ setViewportStartMs,
+ setViewportEndMs,
+ setLabelFormatterType,
+ setSelectedClip,
+ setHoveredClip,
+ setSelectedTrack,
+ setHoveredTrack,
+ setSelectedLineWidth,
+ setHoveredLineWidth,
+ setShowSubtrackSeparators
+ }),
+ []
+ );
+
+ 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
+
+
+
+
+
+
+ controls.setTimelineEnd(state.timelineStart + v)}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {state.selectedClip && (
+
controls.setSelectedClip(null)} />
+ )}
+
+ {state.selectedTrack && (
+ controls.setSelectedTrack(null)} />
+ )}
+
+
+ );
+}
+
+function Section({title, children}: {title: string; children: React.ReactNode}): ReactElement {
+ return (
+
+
{title}
+ {children}
+
+ );
+}
+
+function RangeField({
+ label,
+ min,
+ max,
+ step = 1,
+ value,
+ onChange
+}: {
+ label: string;
+ min: number;
+ max: number;
+ step?: number;
+ value: number;
+ onChange: (v: number) => void;
+}): ReactElement {
+ return (
+
+
+ onChange(Number(e.target.value))}
+ style={{width: '100%'}}
+ />
+
+ );
+}
+
+function SelectField({
+ label,
+ value,
+ options,
+ onChange
+}: {
+ label: string;
+ value: number;
+ options: number[];
+ onChange: (v: number) => void;
+}): ReactElement {
+ return (
+
+
+
+
+ );
+}
+
+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 && (
+
+
+
+
+
+ )}
+
+ );
+}
+
+function ClipDetails({
+ clip,
+ formatter,
+ onClear
+}: {
+ clip: TimelineClip;
+ formatter: (timeMs: number) => string;
+ onClear: () => void;
+}): ReactElement {
+ return (
+
+
+ Selected Clip
+
+
+
+ Label: {clip.label}
+
+
+ Start: {formatter(clip.startMs)}
+
+
+ Duration: {formatter(clip.endMs - clip.startMs)}
+
+
+
+
+ );
+}
+
+function TrackDetails({
+ track,
+ onClear
+}: {
+ track: TimelineTrack;
+ onClear: () => void;
+}): ReactElement {
+ return (
+
+
+ Selected Track
+
+
+
+ Name: {track.name}
+
+
+ Clips: {track.clips.length}
+
+
+
+
+ );
+}
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..012dcd143
--- /dev/null
+++ b/examples/timeline-layers/timeline-layer/demo-utils.ts
@@ -0,0 +1,64 @@
+// deck.gl-community
+// SPDX-License-Identifier: MIT
+// Copyright (c) vis.gl contributors
+
+import type {TimelineClip, TimelineTrack} from '@deck.gl-community/timeline-layers';
+
+/**
+ * Generate random tracks with clips for demo purposes.
+ */
+export function generateRandomTracks(
+ trackCount: number,
+ clipsPerTrack: number,
+ timelineStart: number,
+ timelineEnd: number
+): TimelineTrack[] {
+ const colors: [number, number, number, number][] = [
+ [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]
+ ];
+
+ const tracks: TimelineTrack[] = [];
+
+ 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;
+ let startMs: number;
+
+ // 30% chance of an overlapping clip (tests collision detection)
+ if (clipIdx > 0 && Math.random() < 0.3) {
+ const prevClip = clips[clips.length - 1];
+ const overlapPoint =
+ prevClip.startMs + (prevClip.endMs - prevClip.startMs) * (0.3 + Math.random() * 0.4);
+ startMs = overlapPoint;
+ } else {
+ startMs = Math.random() * (timelineEnd - timelineStart - duration) + timelineStart;
+ }
+
+ clips.push({
+ id: `track-${trackIdx}-clip-${clipIdx}`,
+ startMs,
+ endMs: startMs + duration,
+ 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..550c87aba
--- /dev/null
+++ b/examples/timeline-layers/timeline-layer/index.html
@@ -0,0 +1,17 @@
+
+
+
+
+ Timeline Layer Example
+
+
+
+
+
+
diff --git a/examples/timeline-layers/timeline-layer/index.tsx b/examples/timeline-layers/timeline-layer/index.tsx
new file mode 100644
index 000000000..b884876b3
--- /dev/null
+++ b/examples/timeline-layers/timeline-layer/index.tsx
@@ -0,0 +1,10 @@
+// deck.gl-community
+// SPDX-License-Identifier: MIT
+// Copyright (c) vis.gl contributors
+
+import React from 'react';
+import {createRoot} from 'react-dom/client';
+import App from './app';
+
+const root = createRoot(document.body.appendChild(document.createElement('div')));
+root.render();
diff --git a/examples/timeline-layers/timeline-layer/package.json b/examples/timeline-layers/timeline-layer/package.json
new file mode 100644
index 000000000..0307a6900
--- /dev/null
+++ b/examples/timeline-layers/timeline-layer/package.json
@@ -0,0 +1,19 @@
+{
+ "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",
+ "@luma.gl/core": "~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..82e866dc2
--- /dev/null
+++ b/examples/timeline-layers/timeline-layer/timeline-hooks.ts
@@ -0,0 +1,467 @@
+// 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 '@deck.gl-community/timeline-layers';
+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 | undefined) => void;
+ setViewportEndMs: (endMs: number | undefined) => 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) => 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);
+
+ const state = useMemo(
+ () => ({isDraggingScrubber, isPanning, panStartX, panStartViewport, mouseDownPos, hoveredObjectId}),
+ [isDraggingScrubber, isPanning, panStartX, panStartViewport, mouseDownPos, hoveredObjectId]
+ );
+
+ const setState = useMemo(
+ () => ({
+ 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);
+ return useMemo(() => ({timelineLayerRef, containerRef}), []);
+}
+
+/** Hook for global mouse-up cleanup */
+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 mouse-wheel zoom */
+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 event 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;
+} {
+ 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]);
+
+ return useMemo(
+ () => ({
+ handleClipClick,
+ handleClipHover,
+ handleTrackClick,
+ handleTrackHover,
+ handleScrubberDrag,
+ handleTimelineClick,
+ handleViewportChange,
+ handleZoomChange
+ }),
+ [
+ handleClipClick,
+ handleClipHover,
+ handleTrackClick,
+ handleTrackHover,
+ handleScrubberDrag,
+ handleTimelineClick,
+ handleViewportChange,
+ handleZoomChange
+ ]
+ );
+}
+
+/** Hook for container mouse event handlers (scrubber drag + pan) */
+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;
+} {
+ 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();
+ setMouseDownPos({x: e.clientX - rect.left, y: e.clientY - rect.top});
+ }
+ },
+ [interactionState.hoveredObjectId, state.zoomLevel, setIsDraggingScrubber, setMouseDownPos]
+ );
+
+ const handleContainerMouseMove = useCallback(
+ (e: React.MouseEvent) => {
+ const rect = e.currentTarget.getBoundingClientRect();
+ const mouseX = e.clientX - rect.left;
+ const mouseY = e.clientY - rect.top;
+
+ // Scrubber dragging
+ if (interactionState.isDraggingScrubber) {
+ 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;
+ }
+
+ // Pan start detection
+ if (
+ interactionState.mouseDownPos &&
+ !interactionState.isPanning &&
+ !interactionState.isDraggingScrubber
+ ) {
+ const dx = mouseX - interactionState.mouseDownPos.x;
+ const dy = mouseY - interactionState.mouseDownPos.y;
+ if (Math.sqrt(dx * dx + dy * dy) > DRAG_THRESHOLD && state.zoomLevel > 1.0) {
+ setIsPanning(true);
+ setPanStartX(interactionState.mouseDownPos.x);
+ setPanStartViewport({
+ start: state.viewportStartMs ?? state.timelineStart,
+ end: state.viewportEndMs ?? state.timelineEnd
+ });
+ setMouseDownPos(null);
+ }
+ }
+
+ // Pan update
+ if (interactionState.isPanning && interactionState.panStartViewport) {
+ e.preventDefault();
+ const deltaX = mouseX - interactionState.panStartX;
+ const currentRange = interactionState.panStartViewport.end - interactionState.panStartViewport.start;
+ const timeDelta = -(deltaX / state.timelineWidth) * currentRange;
+
+ let newStart = interactionState.panStartViewport.start + timeDelta;
+ let newEnd = interactionState.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;
+ }
+
+ setViewportStartMs(newStart);
+ setViewportEndMs(newEnd);
+ }
+ },
+ [
+ interactionState,
+ 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
+ ]);
+
+ return useMemo(
+ () => ({handleContainerMouseDown, handleContainerMouseMove, handleContainerMouseUp}),
+ [handleContainerMouseDown, handleContainerMouseMove, handleContainerMouseUp]
+ );
+}
+
+/** Hook for DeckGL hover/click handlers */
+export function useDeckGLHandlers(
+ setState: ReturnType['setState'],
+ _timelineLayerRef: React.RefObject,
+ controls: TimelineControls,
+ state: TimelineControlsState
+): {
+ handleDeckGLHover: (info: any) => void;
+ handleDeckGLClick: (info: any) => void;
+} {
+ const {setCurrentTimeMs, setSelectedClip, setSelectedTrack, setHoveredClip, setHoveredTrack} =
+ controls;
+ const {setHoveredObjectId, setIsDraggingScrubber, setMouseDownPos} = setState;
+
+ const handleDeckGLHover = useCallback(
+ (info: any) => {
+ if (!info.object) {
+ setHoveredObjectId(null);
+ setHoveredClip(null);
+ setHoveredTrack(null);
+ return;
+ }
+
+ const objectId = String(info.object.id);
+ setHoveredObjectId(objectId);
+
+ if (objectId.startsWith('track-') && objectId.includes('-clip-')) {
+ if (info.object.clip) setHoveredClip(info.object.clip);
+ setHoveredTrack(null);
+ } else if (objectId.startsWith('track-bg-')) {
+ setHoveredClip(null);
+ if (info.object.track) setHoveredTrack(info.object.track);
+ } else {
+ setHoveredClip(null);
+ setHoveredTrack(null);
+ }
+ },
+ [setHoveredObjectId, setHoveredClip, setHoveredTrack]
+ );
+
+ const handleDeckGLClick = useCallback(
+ (info: any) => {
+ if (!info.object) {
+ if (info.coordinate) {
+ const effectiveStartMs = state.viewportStartMs ?? state.timelineStart;
+ const effectiveEndMs = state.viewportEndMs ?? state.timelineEnd;
+ const timeMs = positionToTime(
+ info.coordinate[0] || 0,
+ state.timelineX,
+ state.timelineWidth,
+ effectiveStartMs,
+ effectiveEndMs
+ );
+ setCurrentTimeMs(timeMs);
+ }
+ setSelectedClip(null);
+ setSelectedTrack(null);
+ return;
+ }
+
+ const objectId = String(info.object.id);
+
+ if (objectId.startsWith('track-') && objectId.includes('-clip-')) {
+ if (info.object.clip) setSelectedClip(info.object.clip);
+ setSelectedTrack(null);
+ } else if (objectId === 'scrubber-handle') {
+ setIsDraggingScrubber(true);
+ setMouseDownPos(null);
+ setSelectedClip(null);
+ setSelectedTrack(null);
+ } else if (objectId.startsWith('track-bg-')) {
+ if (info.coordinate) {
+ const effectiveStartMs = state.viewportStartMs ?? state.timelineStart;
+ const effectiveEndMs = state.viewportEndMs ?? state.timelineEnd;
+ const timeMs = positionToTime(
+ info.coordinate[0] || 0,
+ state.timelineX,
+ state.timelineWidth,
+ effectiveStartMs,
+ effectiveEndMs
+ );
+ setCurrentTimeMs(timeMs);
+ }
+ setSelectedClip(null);
+ if (info.object.track) setSelectedTrack(info.object.track);
+ } else {
+ setSelectedClip(null);
+ setSelectedTrack(null);
+ }
+ },
+ [
+ state.timelineX,
+ state.timelineWidth,
+ state.viewportStartMs,
+ state.viewportEndMs,
+ state.timelineStart,
+ state.timelineEnd,
+ setCurrentTimeMs,
+ setSelectedClip,
+ setSelectedTrack,
+ setIsDraggingScrubber,
+ setMouseDownPos
+ ]
+ );
+
+ return useMemo(
+ () => ({handleDeckGLHover, handleDeckGLClick}),
+ [handleDeckGLHover, handleDeckGLClick]
+ );
+}
+
+/** Hook for cursor style */
+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 update timeline width on window resize */
+export function useTimelineResize(setTimelineWidth: (width: number) => void): void {
+ useEffect(() => {
+ const handleResize = () => {
+ setTimelineWidth(window.innerWidth - 320 - 200);
+ };
+ 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..61f3747df
--- /dev/null
+++ b/examples/timeline-layers/timeline-layer/tsconfig.json
@@ -0,0 +1,4 @@
+{
+ "extends": "../../../tsconfig.json",
+ "include": ["./*.tsx", "./*.ts"]
+}
diff --git a/modules/timeline-layers/src/index.ts b/modules/timeline-layers/src/index.ts
index d65c9b9fe..31f419259 100644
--- a/modules/timeline-layers/src/index.ts
+++ b/modules/timeline-layers/src/index.ts
@@ -2,12 +2,54 @@
// 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,
+ ClipWithSubtrack,
+ 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 type {SelectionStyle, TimelineLayout} from './layers/timeline-layer/timeline-layout';
+export {DEFAULT_TIMELINE_LAYOUT} from './layers/timeline-layer/timeline-layout';
+
+export {
+ positionToTime,
+ timeToPosition,
+ timeAxisFormatters,
+ type GenerateTicksOptions
+} from './layers/timeline-layer/timeline-utils';
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..2062d139e
--- /dev/null
+++ b/modules/timeline-layers/src/layers/timeline-layer/timeline-collision.ts
@@ -0,0 +1,51 @@
+// deck.gl-community
+// SPDX-License-Identifier: MIT
+// Copyright (c) vis.gl contributors
+
+import type {TimelineClip, ClipWithSubtrack} from './timeline-types';
+
+export type {ClipWithSubtrack};
+/**
+ * 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) {
+ let assignedSubtrack = -1;
+
+ for (let i = 0; i < subtrackEndTimes.length; i++) {
+ if (clip.startMs >= subtrackEndTimes[i]) {
+ assignedSubtrack = i;
+ subtrackEndTimes[i] = clip.endMs;
+ break;
+ }
+ }
+
+ 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..8348e93c1
--- /dev/null
+++ b/modules/timeline-layers/src/layers/timeline-layer/timeline-layer.ts
@@ -0,0 +1,868 @@
+// deck.gl-community
+// SPDX-License-Identifier: MIT
+// Copyright (c) vis.gl contributors
+
+import {CompositeLayer, COORDINATE_SYSTEM, type PickingInfo, type 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,
+ TimelineTrackInfo,
+ TimelineTrack,
+ TrackWithSubtracks,
+ TrackPosition,
+ TrackBackgroundData,
+ TrackLabelData,
+ ClipPolygonData,
+ ClipLabelData,
+ ClipWithSubtrack,
+ SeparatorLineData,
+ AxisLineData,
+ AxisLabelData,
+ ScrubberLineData,
+ ScrubberHandleData,
+ ScrubberLabelData,
+ TimeAxisLabelFormatter
+} from './timeline-types';
+
+import type {SelectionStyle} from './timeline-layout';
+
+import {
+ timeAxisFormatters,
+ generateTimelineTicks,
+ timeToPosition,
+ positionToTime
+} from './timeline-utils';
+import {assignClipsToSubtracks, calculateSubtrackCount} from './timeline-collision';
+
+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
+ }
+};
+
+export type TimelineLayerProps = CompositeLayerProps & {
+ /** Array of timeline tracks, each containing clips */
+ data: TimelineTrack[];
+ /** Start of the full timeline range in milliseconds */
+ timelineStart: number;
+ /** End of the full timeline range in milliseconds */
+ timelineEnd: number;
+
+ /** X offset of the timeline in canvas coordinates */
+ x?: number;
+ /** Y offset of the timeline in canvas coordinates */
+ y?: number;
+ /** Width of the timeline in canvas coordinates */
+ width?: number;
+ /** Height of each track row in canvas coordinates */
+ trackHeight?: number;
+ /** Spacing between tracks in canvas coordinates */
+ trackSpacing?: number;
+
+ /** Current playhead time in milliseconds */
+ currentTimeMs?: number;
+ /** Optional zoomed viewport range */
+ viewport?: {startMs?: number; endMs?: number};
+ /** Formatter for time axis labels */
+ timeFormatter?: TimeAxisLabelFormatter;
+
+ /** ID of the currently selected clip */
+ selectedClipId?: string | number | null;
+ /** ID of the currently hovered clip */
+ hoveredClipId?: string | number | null;
+ /** ID of the currently selected track */
+ selectedTrackId?: string | number | null;
+ /** ID of the currently hovered track */
+ hoveredTrackId?: string | number | null;
+ /** Colors and line widths for selected/hovered states */
+ selectionStyle?: SelectionStyle;
+
+ /** Whether to show the playhead scrubber */
+ showScrubber?: boolean;
+ /** Whether to show labels on clips */
+ showClipLabels?: boolean;
+ /** Whether to show labels on tracks */
+ showTrackLabels?: boolean;
+ /** Whether to show the time axis */
+ showAxis?: boolean;
+ /** Whether to show separators between collision subtracks */
+ showSubtrackSeparators?: boolean;
+
+ /** Override props for the clip polygon sub-layer */
+ clipProps?: Partial>;
+ /** Override props for the track background sub-layer */
+ trackProps?: Partial>;
+ /** Override props for the track label sub-layer */
+ trackLabelProps?: Partial>;
+ /** Override props for the clip label sub-layer */
+ clipLabelProps?: Partial>;
+ /** Override props for the axis line sub-layer */
+ axisLineProps?: Partial>;
+ /** Override props for the axis label sub-layer */
+ axisLabelProps?: Partial>;
+ /** Override props for the scrubber line sub-layer */
+ scrubberLineProps?: Partial>;
+
+ /** Callback when a clip is clicked */
+ onClipClick?: (info: TimelineClipInfo, event: PickingInfo) => void;
+ /** Callback when a clip is hovered */
+ onClipHover?: (info: TimelineClipInfo | null, event: PickingInfo) => void;
+ /** Callback when a track is clicked */
+ onTrackClick?: (info: TimelineTrackInfo, event: PickingInfo) => void;
+ /** Callback when a track is hovered */
+ onTrackHover?: (info: TimelineTrackInfo | null, event: PickingInfo) => void;
+ /** Callback when the scrubber handle is hovered */
+ onScrubberHover?: (isHovering: boolean, event: PickingInfo) => void;
+ /** Callback when a scrubber drag begins */
+ onScrubberDragStart?: (event: PickingInfo) => void;
+ /** Callback when the scrubber is dragged to a new time */
+ onScrubberDrag?: (timeMs: number, event: PickingInfo) => void;
+ /** Callback when the timeline background is clicked */
+ onTimelineClick?: (timeMs: number, event: PickingInfo) => void;
+
+ /** Callback when the current time changes */
+ onCurrentTimeChange?: (timeMs: number) => void;
+ /** Callback when the viewport (zoom/pan) changes */
+ onViewportChange?: (startMs: number, endMs: number) => void;
+ /** Callback when the zoom level changes */
+ onZoomChange?: (zoomLevel: number) => void;
+};
+
+export class TimelineLayer extends CompositeLayer {
+ static layerName = 'TimelineLayer';
+ static defaultProps = defaultProps;
+
+ /** Convert a canvas X coordinate to a time in milliseconds */
+ 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);
+ }
+
+ /** Zoom the timeline viewport around a canvas X coordinate */
+ 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);
+ }
+
+ // ===== LAYOUT CALCULATION =====
+
+ private _calculateTrackPositions(
+ tracksWithSubtracks: TrackWithSubtracks[],
+ y: number,
+ trackHeight: number,
+ trackSpacing: number
+ ): {trackPositions: TrackPosition[]; totalTimelineHeight: number} {
+ let currentY = y;
+ const trackPositions: TrackPosition[] = [];
+ const subtrackSpacing = 2;
+
+ for (const {subtrackCount} of tracksWithSubtracks) {
+ 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};
+ }
+
+ // ===== DATA GENERATION =====
+
+ private _generateTrackBackgrounds(
+ tracksWithSubtracks: TrackWithSubtracks[],
+ trackPositions: TrackPosition[]
+ ): TrackBackgroundData[] {
+ const {
+ x = 150,
+ width = 800,
+ selectedTrackId,
+ hoveredTrackId,
+ selectionStyle = defaultProps.selectionStyle
+ } = this.props;
+
+ return tracksWithSubtracks.map(({track, trackIndex}, i) => {
+ const {y: trackY, height} = trackPositions[i];
+ const isSelected = selectedTrackId === track.id;
+ const isHovered = hoveredTrackId === track.id;
+
+ let color: [number, number, number, number] = [60, 60, 60, 255];
+ if (isSelected) {
+ color = selectionStyle.selectedTrackColor!;
+ } else if (isHovered) {
+ color = lightenColor([60, 60, 60, 255], 20);
+ }
+
+ return {
+ id: `track-bg-${track.id}`,
+ track,
+ trackIndex,
+ polygon: [
+ [x, trackY],
+ [x + width, trackY],
+ [x + width, trackY + height],
+ [x, trackY + height]
+ ],
+ color
+ };
+ });
+ }
+
+ private _generateTrackLabels(
+ tracksWithSubtracks: TrackWithSubtracks[],
+ trackPositions: TrackPosition[]
+ ): TrackLabelData[] {
+ const {x = 150, showTrackLabels = true} = this.props;
+ 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 _buildClipPolygon(
+ clip: ClipWithSubtrack,
+ opts: {
+ track: TimelineTrack;
+ trackIndex: number;
+ clipIndex: number;
+ subtrackHeight: number;
+ baseTrackY: number;
+ x: number;
+ width: number;
+ effectiveStartMs: number;
+ effectiveEndMs: number;
+ selectedClipId: string | number | null | undefined;
+ hoveredClipId: string | number | null | undefined;
+ selectionStyle: SelectionStyle;
+ }
+ ): ClipPolygonData | null {
+ const {id: clipId, startMs, endMs, subtrackIndex = 0} = clip;
+ const {
+ track,
+ trackIndex,
+ clipIndex,
+ subtrackHeight,
+ baseTrackY,
+ x,
+ width,
+ effectiveStartMs,
+ effectiveEndMs,
+ selectedClipId,
+ hoveredClipId,
+ selectionStyle
+ } = opts;
+
+ if (endMs <= effectiveStartMs || startMs >= effectiveEndMs) return null;
+
+ const clipPadding = 2;
+ const subtrackSpacing = 2;
+ 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 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) {
+ color = lightenColor(baseColor, 40);
+ }
+
+ return {
+ id: clipId,
+ clip,
+ track,
+ clipIndex,
+ trackIndex,
+ subtrackIndex,
+ polygon: [
+ [clipStartX, clipTrackY + clipPadding],
+ [clipEndX, clipTrackY + clipPadding],
+ [clipEndX, clipTrackY + subtrackHeight - clipPadding],
+ [clipStartX, clipTrackY + subtrackHeight - clipPadding]
+ ],
+ color,
+ label: clip.label || '',
+ labelPosition: [clipStartX + (clipEndX - clipStartX) / 2, clipTrackY + subtrackHeight / 2, 0]
+ };
+ }
+
+ private _generateClipPolygons(
+ tracksWithSubtracks: TrackWithSubtracks[],
+ trackPositions: TrackPosition[],
+ effectiveStartMs: number,
+ effectiveEndMs: number
+ ): ClipPolygonData[] {
+ const {
+ x = 150,
+ width = 800,
+ selectedClipId,
+ hoveredClipId,
+ selectionStyle = defaultProps.selectionStyle
+ } = this.props;
+
+ const subtrackSpacing = 2;
+ const clipPolygons: ClipPolygonData[] = [];
+
+ for (let i = 0; i < tracksWithSubtracks.length; i++) {
+ const {track, trackIndex, clips, subtrackCount} = tracksWithSubtracks[i];
+ const {y: baseTrackY, height: trackTotalHeight} = trackPositions[i];
+ const subtrackHeight =
+ (trackTotalHeight - (subtrackCount - 1) * subtrackSpacing) / subtrackCount;
+
+ for (let clipIndex = 0; clipIndex < clips.length; clipIndex++) {
+ const polygon = this._buildClipPolygon(clips[clipIndex], {
+ track,
+ trackIndex,
+ clipIndex,
+ subtrackHeight,
+ baseTrackY,
+ x,
+ width,
+ effectiveStartMs,
+ effectiveEndMs,
+ selectedClipId,
+ hoveredClipId,
+ selectionStyle
+ });
+ if (polygon) {
+ clipPolygons.push(polygon);
+ }
+ }
+ }
+
+ return clipPolygons;
+ }
+
+ private _generateSubtrackSeparators(
+ tracksWithSubtracks: TrackWithSubtracks[],
+ trackPositions: TrackPosition[]
+ ): SeparatorLineData[] {
+ const {x = 150, width = 800, showSubtrackSeparators = true} = this.props;
+ if (!showSubtrackSeparators) return [];
+
+ const subtrackSpacing = 2;
+ const separatorLines: SeparatorLineData[] = [];
+
+ for (let i = 0; i < tracksWithSubtracks.length; i++) {
+ const {subtrackCount} = tracksWithSubtracks[i];
+ if (subtrackCount <= 1) {
+ // No separators needed for single-subtrack rows
+ } else {
+ const {y: baseTrackY, height: trackTotalHeight} = trackPositions[i];
+ const subtrackHeight =
+ (trackTotalHeight - (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(
+ totalTimelineHeight: number,
+ effectiveStartMs: number,
+ effectiveEndMs: number
+ ): {axisLines: AxisLineData[]; axisLabels: AxisLabelData[]} {
+ const {
+ x = 150,
+ y = 100,
+ width = 800,
+ showAxis = true,
+ timeFormatter = timeAxisFormatters.seconds
+ } = this.props;
+
+ const axisLines: AxisLineData[] = [];
+ const axisLabels: AxisLabelData[] = [];
+
+ if (!showAxis) return {axisLines, axisLabels};
+
+ const axisHeight = 30;
+ const tickCount = Math.max(4, Math.min(10, Math.floor(width / 80)));
+
+ const timelineTicks = generateTimelineTicks({
+ startMs: effectiveStartMs,
+ endMs: effectiveEndMs,
+ timelineX: x,
+ timelineWidth: width,
+ tickCount,
+ formatter: 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(
+ totalTimelineHeight: number,
+ effectiveStartMs: number,
+ effectiveEndMs: number
+ ): {
+ scrubberLine: ScrubberLineData[];
+ scrubberHandle: ScrubberHandleData[];
+ scrubberLabel: ScrubberLabelData[];
+ } {
+ const {
+ x = 150,
+ y = 100,
+ width = 800,
+ showScrubber = true,
+ currentTimeMs = 0,
+ timeFormatter = timeAxisFormatters.seconds
+ } = this.props;
+
+ if (!showScrubber) return {scrubberLine: [], scrubberHandle: [], scrubberLabel: []};
+
+ const scrubberPosition = timeToPosition(
+ currentTimeMs,
+ x,
+ width,
+ effectiveStartMs,
+ effectiveEndMs
+ );
+
+ const scrubberLine: ScrubberLineData[] = [
+ {
+ sourcePosition: [scrubberPosition, y - 30],
+ targetPosition: [scrubberPosition, y + totalTimelineHeight + 30]
+ }
+ ];
+
+ const scrubberHandle: ScrubberHandleData[] = [
+ {
+ id: 'scrubber-handle',
+ polygon: [
+ [scrubberPosition - 8, y - 35],
+ [scrubberPosition + 8, y - 35],
+ [scrubberPosition + 8, y - 20],
+ [scrubberPosition - 8, y - 20]
+ ],
+ color: [255, 100, 100, 255]
+ }
+ ];
+
+ const scrubberLabel: ScrubberLabelData[] = [
+ {text: timeFormatter(currentTimeMs), position: [scrubberPosition, y - 40, 0]}
+ ];
+
+ return {scrubberLine, scrubberHandle, scrubberLabel};
+ }
+
+ // ===== LAYER CREATION =====
+
+ private _createTrackLayers(
+ trackBackgrounds: TrackBackgroundData[],
+ trackLabels: TrackLabelData[]
+ ): Layer[] {
+ const {trackProps, showTrackLabels = true, onTrackClick, onTrackHover} = 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[]
+ ): Layer[] {
+ const {
+ clipProps,
+ showClipLabels = true,
+ selectedClipId,
+ selectionStyle = defaultProps.selectionStyle,
+ onClipClick,
+ onClipHover
+ } = 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(separators: SeparatorLineData[]): Layer | null {
+ const {showSubtrackSeparators = true} = this.props;
+ if (!showSubtrackSeparators) return null;
+
+ return new LineLayer({
+ id: `${this.props.id}-subtrack-separators`,
+ data: separators,
+ 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[]): Layer[] {
+ const {axisLineProps, axisLabelProps, showAxis = true} = this.props;
+ if (!showAxis) return [];
+
+ return [
+ 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
+ })
+ ),
+ 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'
+ })
+ )
+ ];
+ }
+
+ private _createScrubberLayers(
+ scrubberLine: ScrubberLineData[],
+ scrubberHandle: ScrubberHandleData[],
+ scrubberLabel: ScrubberLabelData[]
+ ): Layer[] {
+ const {
+ scrubberLineProps,
+ showScrubber = true,
+ onScrubberDragStart,
+ onScrubberHover
+ } = this.props;
+ if (!showScrubber) return [];
+
+ return [
+ 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
+ })
+ ),
+ 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);
+ }
+ }
+ })
+ ),
+ 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'
+ })
+ )
+ ];
+ }
+
+ // ===== MAIN RENDER =====
+
+ renderLayers(): Layer[] {
+ const {
+ data: tracks,
+ timelineStart,
+ timelineEnd,
+ viewport,
+ y = 100,
+ trackHeight = 40,
+ trackSpacing = 10
+ } = 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);
+ const trackLabels = this._generateTrackLabels(tracksWithSubtracks, trackPositions);
+ const clipPolygons = this._generateClipPolygons(
+ tracksWithSubtracks,
+ trackPositions,
+ effectiveStartMs,
+ effectiveEndMs
+ );
+ const clipLabelsData = clipPolygons.map((clip) => ({
+ text: clip.label,
+ position: clip.labelPosition
+ }));
+ const subtrackSeparators = this._generateSubtrackSeparators(
+ tracksWithSubtracks,
+ trackPositions
+ );
+ const {axisLines, axisLabels} = this._generateAxis(
+ totalTimelineHeight,
+ effectiveStartMs,
+ effectiveEndMs
+ );
+ const {scrubberLine, scrubberHandle, scrubberLabel} = this._generateScrubber(
+ totalTimelineHeight,
+ effectiveStartMs,
+ effectiveEndMs
+ );
+
+ const layers = [
+ ...this._createTrackLayers(trackBackgrounds, trackLabels),
+ ...this._createClipLayers(clipPolygons, clipLabelsData),
+ this._createSubtrackSeparatorLayer(subtrackSeparators),
+ ...this._createAxisLayers(axisLines, axisLabels),
+ ...this._createScrubberLayers(scrubberLine, scrubberHandle, scrubberLabel)
+ ].filter((layer): layer is Layer => layer !== null);
+
+ return layers;
+ }
+}
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..554ac513f
--- /dev/null
+++ b/modules/timeline-layers/src/layers/timeline-layer/timeline-layout.ts
@@ -0,0 +1,80 @@
+// deck.gl-community
+// SPDX-License-Identifier: MIT
+// Copyright (c) vis.gl contributors
+
+// ===== 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..161d91da2
--- /dev/null
+++ b/modules/timeline-layers/src/layers/timeline-layer/timeline-types.ts
@@ -0,0 +1,146 @@
+// 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 =====
+
+/** A clip that has been assigned to a specific subtrack by the collision-detection algorithm */
+export type ClipWithSubtrack = TimelineClip & {
+ subtrackIndex: number;
+};
+
+export type TrackWithSubtracks = {
+ track: TimelineTrack;
+ trackIndex: number;
+ clips: ClipWithSubtrack[];
+ 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..2b1becc09
--- /dev/null
+++ b/modules/timeline-layers/src/layers/timeline-layer/timeline-utils.ts
@@ -0,0 +1,85 @@
+// 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 =====
+
+export type GenerateTicksOptions = {
+ startMs: number;
+ endMs: number;
+ timelineX: number;
+ timelineWidth: number;
+ tickCount: number;
+ formatter: TimeAxisLabelFormatter;
+};
+
+/**
+ * Generate timeline axis ticks
+ */
+export function generateTimelineTicks(opts: GenerateTicksOptions): TimelineTick[] {
+ const {startMs, endMs, timelineX, timelineWidth, tickCount, formatter} = opts;
+ 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 canvas/pixel 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 canvas/pixel 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/modules/timeline-layers/test/index.spec.ts b/modules/timeline-layers/test/index.spec.ts
index 62b62609f..712c4c1ce 100644
--- a/modules/timeline-layers/test/index.spec.ts
+++ b/modules/timeline-layers/test/index.spec.ts
@@ -4,7 +4,11 @@ import {
MultiHorizonGraphLayer,
TimeAxisLayer,
VerticalGridLayer,
- formatTimeMs
+ TimelineLayer,
+ formatTimeMs,
+ timeAxisFormatters,
+ positionToTime,
+ timeToPosition
} from '../src';
describe('@deck.gl-community/timeline-layers', () => {
@@ -18,4 +22,29 @@ describe('@deck.gl-community/timeline-layers', () => {
expect(VerticalGridLayer).toBeDefined();
expect(formatTimeMs).toBeTypeOf('function');
});
+
+ it('exports TimelineLayer', () => {
+ expect(TimelineLayer).toBeDefined();
+ });
+
+ it('exports time axis formatters', () => {
+ expect(timeAxisFormatters.seconds).toBeTypeOf('function');
+ expect(timeAxisFormatters.seconds(5000)).toBe('5.0s');
+ expect(timeAxisFormatters.minutesSeconds(90000)).toBe('1:30');
+ });
+
+ it('exports position/time conversion utilities', () => {
+ expect(positionToTime).toBeTypeOf('function');
+ expect(timeToPosition).toBeTypeOf('function');
+
+ // positionToTime: x=0 maps to startMs, x=width maps to endMs
+ expect(positionToTime(0, 0, 100, 0, 1000)).toBe(0);
+ expect(positionToTime(100, 0, 100, 0, 1000)).toBe(1000);
+ expect(positionToTime(50, 0, 100, 0, 1000)).toBe(500);
+
+ // timeToPosition: startMs maps to x=0, endMs maps to x=width
+ expect(timeToPosition(0, 0, 100, 0, 1000)).toBe(0);
+ expect(timeToPosition(1000, 0, 100, 0, 1000)).toBe(100);
+ expect(timeToPosition(500, 0, 100, 0, 1000)).toBe(50);
+ });
});
diff --git a/modules/timeline-layers/test/timeline-layer.spec.ts b/modules/timeline-layers/test/timeline-layer.spec.ts
new file mode 100644
index 000000000..057d603bb
--- /dev/null
+++ b/modules/timeline-layers/test/timeline-layer.spec.ts
@@ -0,0 +1,391 @@
+// deck.gl-community
+// SPDX-License-Identifier: MIT
+// Copyright (c) vis.gl contributors
+
+import {describe, it, expect, vi} from 'vitest';
+import {
+ assignClipsToSubtracks,
+ calculateSubtrackCount
+} from '../src/layers/timeline-layer/timeline-collision';
+import {
+ positionToTime,
+ timeToPosition,
+ timeAxisFormatters,
+ generateTimelineTicks
+} from '../src/layers/timeline-layer/timeline-utils';
+import {TimelineLayer} from '../src/layers/timeline-layer/timeline-layer';
+import type {TimelineClip, TimelineTrack} from '../src/layers/timeline-layer/timeline-types';
+
+// ===== COLLISION DETECTION =====
+
+describe('assignClipsToSubtracks', () => {
+ it('returns empty array for no clips', () => {
+ expect(assignClipsToSubtracks([])).toEqual([]);
+ });
+
+ it('assigns a single clip to subtrack 0', () => {
+ const clips: TimelineClip[] = [{id: 'a', startMs: 0, endMs: 1000}];
+ const result = assignClipsToSubtracks(clips);
+ expect(result).toHaveLength(1);
+ expect(result[0].subtrackIndex).toBe(0);
+ });
+
+ it('places non-overlapping clips on the same subtrack', () => {
+ const clips: TimelineClip[] = [
+ {id: 'a', startMs: 0, endMs: 500},
+ {id: 'b', startMs: 500, endMs: 1000}
+ ];
+ const result = assignClipsToSubtracks(clips);
+ const byId = Object.fromEntries(result.map((c) => [c.id, c.subtrackIndex]));
+ expect(byId.a).toBe(0);
+ expect(byId.b).toBe(0);
+ });
+
+ it('places overlapping clips on different subtracks', () => {
+ const clips: TimelineClip[] = [
+ {id: 'a', startMs: 0, endMs: 1000},
+ {id: 'b', startMs: 500, endMs: 1500}
+ ];
+ const result = assignClipsToSubtracks(clips);
+ const byId = Object.fromEntries(result.map((c) => [c.id, c.subtrackIndex]));
+ expect(byId.a).not.toBe(byId.b);
+ });
+
+ it('packs three clips into two subtracks when third fits in first lane', () => {
+ // a: 0-500, b: 0-500 (overlaps a), c: 600-1000 (fits after a)
+ const clips: TimelineClip[] = [
+ {id: 'a', startMs: 0, endMs: 500},
+ {id: 'b', startMs: 0, endMs: 500},
+ {id: 'c', startMs: 600, endMs: 1000}
+ ];
+ const result = assignClipsToSubtracks(clips);
+ const subtracks = result.map((c) => c.subtrackIndex);
+ const uniqueSubtracks = new Set(subtracks);
+ expect(uniqueSubtracks.size).toBe(2);
+ });
+
+ it('sorts clips by startMs before assigning subtracks', () => {
+ // Provide clips in reverse order; the algorithm must still work correctly
+ const clips: TimelineClip[] = [
+ {id: 'b', startMs: 1000, endMs: 2000},
+ {id: 'a', startMs: 0, endMs: 500}
+ ];
+ const result = assignClipsToSubtracks(clips);
+ const byId = Object.fromEntries(result.map((c) => [c.id, c.subtrackIndex]));
+ // Both fit on subtrack 0 since they don't overlap
+ expect(byId.a).toBe(0);
+ expect(byId.b).toBe(0);
+ });
+
+ it('preserves original clip properties', () => {
+ const clip: TimelineClip = {
+ id: 'x',
+ startMs: 100,
+ endMs: 200,
+ label: 'hello',
+ color: [1, 2, 3, 4]
+ };
+ const [result] = assignClipsToSubtracks([clip]);
+ expect(result.id).toBe('x');
+ expect(result.label).toBe('hello');
+ expect(result.color).toEqual([1, 2, 3, 4]);
+ expect(result.subtrackIndex).toBeTypeOf('number');
+ });
+});
+
+describe('calculateSubtrackCount', () => {
+ it('returns 1 for empty clips array', () => {
+ expect(calculateSubtrackCount([])).toBe(1);
+ });
+
+ it('returns 1 for non-overlapping clips', () => {
+ const clips: TimelineClip[] = [
+ {id: 'a', startMs: 0, endMs: 500},
+ {id: 'b', startMs: 500, endMs: 1000}
+ ];
+ expect(calculateSubtrackCount(clips)).toBe(1);
+ });
+
+ it('returns 2 for two fully overlapping clips', () => {
+ const clips: TimelineClip[] = [
+ {id: 'a', startMs: 0, endMs: 1000},
+ {id: 'b', startMs: 0, endMs: 1000}
+ ];
+ expect(calculateSubtrackCount(clips)).toBe(2);
+ });
+
+ it('returns 3 for three mutually overlapping clips', () => {
+ const clips: TimelineClip[] = [
+ {id: 'a', startMs: 0, endMs: 1000},
+ {id: 'b', startMs: 0, endMs: 1000},
+ {id: 'c', startMs: 0, endMs: 1000}
+ ];
+ expect(calculateSubtrackCount(clips)).toBe(3);
+ });
+});
+
+// ===== TIME / POSITION MATH =====
+
+describe('positionToTime', () => {
+ it('maps left edge to startMs', () => {
+ expect(positionToTime(0, 0, 100, 0, 1000)).toBe(0);
+ });
+
+ it('maps right edge to endMs', () => {
+ expect(positionToTime(100, 0, 100, 0, 1000)).toBe(1000);
+ });
+
+ it('maps midpoint correctly', () => {
+ expect(positionToTime(50, 0, 100, 0, 1000)).toBe(500);
+ });
+
+ it('clamps to startMs when x is before timeline', () => {
+ expect(positionToTime(-10, 0, 100, 0, 1000)).toBe(0);
+ });
+
+ it('clamps to endMs when x is beyond timeline', () => {
+ expect(positionToTime(200, 0, 100, 0, 1000)).toBe(1000);
+ });
+
+ it('respects non-zero timelineX offset', () => {
+ // Timeline starts at x=50, width=100 → x=100 is midpoint
+ expect(positionToTime(100, 50, 100, 0, 1000)).toBe(500);
+ });
+
+ it('works with non-zero startMs', () => {
+ expect(positionToTime(50, 0, 100, 5000, 6000)).toBe(5500);
+ });
+});
+
+describe('timeToPosition', () => {
+ it('maps startMs to left edge', () => {
+ expect(timeToPosition(0, 0, 100, 0, 1000)).toBe(0);
+ });
+
+ it('maps endMs to right edge', () => {
+ expect(timeToPosition(1000, 0, 100, 0, 1000)).toBe(100);
+ });
+
+ it('maps midpoint correctly', () => {
+ expect(timeToPosition(500, 0, 100, 0, 1000)).toBe(50);
+ });
+
+ it('clamps when time is before startMs', () => {
+ expect(timeToPosition(-100, 0, 100, 0, 1000)).toBe(0);
+ });
+
+ it('clamps when time is after endMs', () => {
+ expect(timeToPosition(9000, 0, 100, 0, 1000)).toBe(100);
+ });
+
+ it('round-trips with positionToTime', () => {
+ const timeMs = 750;
+ const x = timeToPosition(timeMs, 0, 800, 0, 60000);
+ expect(positionToTime(x, 0, 800, 0, 60000)).toBeCloseTo(timeMs);
+ });
+});
+
+// ===== TIME AXIS FORMATTERS =====
+
+describe('timeAxisFormatters', () => {
+ describe('seconds', () => {
+ it('formats 0ms as 0.0s', () => {
+ expect(timeAxisFormatters.seconds(0)).toBe('0.0s');
+ });
+
+ it('formats 5000ms as 5.0s', () => {
+ expect(timeAxisFormatters.seconds(5000)).toBe('5.0s');
+ });
+
+ it('formats 1500ms as 1.5s', () => {
+ expect(timeAxisFormatters.seconds(1500)).toBe('1.5s');
+ });
+ });
+
+ describe('minutesSeconds', () => {
+ it('formats 0ms as 0:00', () => {
+ expect(timeAxisFormatters.minutesSeconds(0)).toBe('0:00');
+ });
+
+ it('formats 90000ms as 1:30', () => {
+ expect(timeAxisFormatters.minutesSeconds(90000)).toBe('1:30');
+ });
+
+ it('formats 3600000ms as 60:00', () => {
+ expect(timeAxisFormatters.minutesSeconds(3600000)).toBe('60:00');
+ });
+
+ it('pads seconds with leading zero', () => {
+ expect(timeAxisFormatters.minutesSeconds(65000)).toBe('1:05');
+ });
+ });
+
+ describe('hoursMinutesSeconds', () => {
+ it('formats 0ms as 0:00:00', () => {
+ expect(timeAxisFormatters.hoursMinutesSeconds(0)).toBe('0:00:00');
+ });
+
+ it('formats 3661000ms as 1:01:01', () => {
+ expect(timeAxisFormatters.hoursMinutesSeconds(3661000)).toBe('1:01:01');
+ });
+
+ it('pads minutes and seconds with leading zeros', () => {
+ expect(timeAxisFormatters.hoursMinutesSeconds(3600000)).toBe('1:00:00');
+ });
+ });
+});
+
+// ===== TICK GENERATION =====
+
+describe('generateTimelineTicks', () => {
+ it('generates the requested number of ticks', () => {
+ const ticks = generateTimelineTicks({
+ startMs: 0,
+ endMs: 1000,
+ timelineX: 0,
+ timelineWidth: 100,
+ tickCount: 5,
+ formatter: timeAxisFormatters.seconds
+ });
+ expect(ticks).toHaveLength(5);
+ });
+
+ it('first tick is at timelineX with startMs', () => {
+ const ticks = generateTimelineTicks({
+ startMs: 0,
+ endMs: 1000,
+ timelineX: 50,
+ timelineWidth: 200,
+ tickCount: 3,
+ formatter: timeAxisFormatters.seconds
+ });
+ expect(ticks[0].position).toBe(50);
+ expect(ticks[0].timeMs).toBe(0);
+ });
+
+ it('last tick is at timelineX + width with endMs', () => {
+ const ticks = generateTimelineTicks({
+ startMs: 0,
+ endMs: 1000,
+ timelineX: 50,
+ timelineWidth: 200,
+ tickCount: 3,
+ formatter: timeAxisFormatters.seconds
+ });
+ expect(ticks[2].position).toBe(250);
+ expect(ticks[2].timeMs).toBe(1000);
+ });
+
+ it('applies the formatter to produce labels', () => {
+ const fmt = (ms: number) => `t=${ms}`;
+ const ticks = generateTimelineTicks({
+ startMs: 0,
+ endMs: 1000,
+ timelineX: 0,
+ timelineWidth: 100,
+ tickCount: 3,
+ formatter: fmt
+ });
+ expect(ticks.map((t) => t.label)).toEqual(['t=0', 't=500', 't=1000']);
+ });
+});
+
+// ===== TIMELINE LAYER =====
+
+describe('TimelineLayer', () => {
+ it('has correct static layerName', () => {
+ expect(TimelineLayer.layerName).toBe('TimelineLayer');
+ });
+
+ it('has default props', () => {
+ expect(TimelineLayer.defaultProps).toBeDefined();
+ expect(TimelineLayer.defaultProps.x).toBe(150);
+ expect(TimelineLayer.defaultProps.y).toBe(100);
+ expect(TimelineLayer.defaultProps.width).toBe(800);
+ expect(TimelineLayer.defaultProps.trackHeight).toBe(40);
+ expect(TimelineLayer.defaultProps.trackSpacing).toBe(10);
+ expect(TimelineLayer.defaultProps.showScrubber).toBe(true);
+ expect(TimelineLayer.defaultProps.showAxis).toBe(true);
+ expect(TimelineLayer.defaultProps.showClipLabels).toBe(true);
+ expect(TimelineLayer.defaultProps.showTrackLabels).toBe(true);
+ expect(TimelineLayer.defaultProps.showSubtrackSeparators).toBe(true);
+ });
+});
+
+describe('TimelineLayer.zoomToPoint', () => {
+ function makeLayer(
+ overrides: Partial> = {}
+ ): {
+ layer: TimelineLayer;
+ viewportSpy: ReturnType;
+ zoomSpy: ReturnType;
+ } {
+ const viewportSpy = vi.fn();
+ const zoomSpy = vi.fn();
+
+ const tracks: TimelineTrack[] = [];
+ const layer = new TimelineLayer({
+ id: 'test',
+ data: tracks,
+ timelineStart: 0,
+ timelineEnd: 60000,
+ x: 0,
+ width: 600,
+ onViewportChange: viewportSpy,
+ onZoomChange: zoomSpy
+ });
+
+ return {layer, viewportSpy, zoomSpy};
+ }
+
+ it('fires onZoomChange with new zoom level', () => {
+ const {layer, zoomSpy} = makeLayer();
+ layer.zoomToPoint(2.0, 300, 1.0); // zoom in 2× from centre
+ expect(zoomSpy).toHaveBeenCalledOnce();
+ expect(zoomSpy.mock.calls[0][0]).toBeCloseTo(2.0);
+ });
+
+ it('clamps zoom level to a maximum of 100', () => {
+ const {layer, zoomSpy} = makeLayer();
+ layer.zoomToPoint(1000, 300, 50.0);
+ expect(zoomSpy.mock.calls[0][0]).toBe(100);
+ });
+
+ it('clamps zoom level to a minimum of 1.0', () => {
+ const {layer, zoomSpy} = makeLayer();
+ layer.zoomToPoint(0.0001, 300, 1.0);
+ expect(zoomSpy.mock.calls[0][0]).toBe(1.0);
+ });
+
+ it('fires onViewportChange when zooming in', () => {
+ const {layer, viewportSpy} = makeLayer();
+ layer.zoomToPoint(2.0, 300, 1.0);
+ expect(viewportSpy).toHaveBeenCalledOnce();
+ const [startMs, endMs] = viewportSpy.mock.calls[0];
+ expect(endMs - startMs).toBeCloseTo(30000); // half the range
+ });
+
+ it('fires onViewportChange with full range when zooming back to 1×', () => {
+ const {layer, viewportSpy} = makeLayer();
+ // Force zoom back to exactly 1.0 (factor 0.5 × 2.0 = 1.0)
+ layer.zoomToPoint(0.5, 300, 2.0);
+ expect(viewportSpy).toHaveBeenCalledOnce();
+ const [startMs, endMs] = viewportSpy.mock.calls[0];
+ expect(startMs).toBe(0);
+ expect(endMs).toBe(60000);
+ });
+
+ it('viewport stays within timeline bounds when zooming near left edge', () => {
+ const {layer, viewportSpy} = makeLayer();
+ layer.zoomToPoint(4.0, 0, 1.0); // zoom in 4× at left edge
+ const [startMs] = viewportSpy.mock.calls[0];
+ expect(startMs).toBeGreaterThanOrEqual(0);
+ });
+
+ it('viewport stays within timeline bounds when zooming near right edge', () => {
+ const {layer, viewportSpy} = makeLayer();
+ layer.zoomToPoint(4.0, 600, 1.0); // zoom in 4× at right edge
+ const [, endMs] = viewportSpy.mock.calls[0];
+ expect(endMs).toBeLessThanOrEqual(60000);
+ });
+});
diff --git a/yarn.lock b/yarn.lock
index 00f8a469d..86984d159 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -8817,7 +8817,7 @@ __metadata:
languageName: node
linkType: hard
-"fdir@npm:^6.4.3, fdir@npm:^6.5.0":
+"fdir@npm:^6.4.3, fdir@npm:^6.4.6, fdir@npm:^6.5.0":
version: 6.5.0
resolution: "fdir@npm:6.5.0"
peerDependencies:
@@ -15590,6 +15590,21 @@ __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"
+ "@luma.gl/core": "npm:~9.2.0"
+ react: "npm:^18.3.1"
+ react-dom: "npm:^18.3.1"
+ vite: "npm:7.1.1"
+ languageName: unknown
+ linkType: soft
+
"tiny-invariant@npm:^1.0.2":
version: 1.3.3
resolution: "tiny-invariant@npm:1.3.3"
@@ -15637,7 +15652,7 @@ __metadata:
languageName: node
linkType: hard
-"tinyglobby@npm:^0.2.12, tinyglobby@npm:^0.2.15":
+"tinyglobby@npm:^0.2.12, tinyglobby@npm:^0.2.14, tinyglobby@npm:^0.2.15":
version: 0.2.15
resolution: "tinyglobby@npm:0.2.15"
dependencies:
@@ -16289,6 +16304,61 @@ __metadata:
languageName: node
linkType: hard
+"vite@npm:7.1.1":
+ version: 7.1.1
+ resolution: "vite@npm:7.1.1"
+ dependencies:
+ esbuild: "npm:^0.25.0"
+ fdir: "npm:^6.4.6"
+ fsevents: "npm:~2.3.3"
+ picomatch: "npm:^4.0.3"
+ postcss: "npm:^8.5.6"
+ rollup: "npm:^4.43.0"
+ tinyglobby: "npm:^0.2.14"
+ peerDependencies:
+ "@types/node": ^20.19.0 || >=22.12.0
+ jiti: ">=1.21.0"
+ less: ^4.0.0
+ lightningcss: ^1.21.0
+ sass: ^1.70.0
+ sass-embedded: ^1.70.0
+ stylus: ">=0.54.8"
+ sugarss: ^5.0.0
+ terser: ^5.16.0
+ tsx: ^4.8.1
+ yaml: ^2.4.2
+ dependenciesMeta:
+ fsevents:
+ optional: true
+ peerDependenciesMeta:
+ "@types/node":
+ optional: true
+ jiti:
+ optional: true
+ less:
+ optional: true
+ lightningcss:
+ optional: true
+ sass:
+ optional: true
+ sass-embedded:
+ optional: true
+ stylus:
+ optional: true
+ sugarss:
+ optional: true
+ terser:
+ optional: true
+ tsx:
+ optional: true
+ yaml:
+ optional: true
+ bin:
+ vite: bin/vite.js
+ checksum: 10c0/391a5c8b8f287b7b1653dedbe952fb4cb93bf7c99b19dab915cf63497892427198fef637e943a3391eacfecf7f2e8f55c40d0fa065fabdd885641430d0b74af7
+ languageName: node
+ linkType: hard
+
"vite@npm:^4.5.0":
version: 4.5.14
resolution: "vite@npm:4.5.14"