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"