From 25ddfa525d0c3cca8d311158d0894ba8105e6706 Mon Sep 17 00:00:00 2001 From: charlieforward9 Date: Sat, 8 Nov 2025 13:25:11 +0100 Subject: [PATCH 1/4] feat(timeline): add timeline demonstration with interactive clips --- examples/infovis-layers/timeline/app.tsx | 734 ++++++++++++++++++ examples/infovis-layers/timeline/index.html | 21 + examples/infovis-layers/timeline/index.tsx | 10 + examples/infovis-layers/timeline/package.json | 23 + .../infovis-layers/timeline/tsconfig.json | 6 + .../infovis-layers/timeline/useTimeline.ts | 437 +++++++++++ .../timeline/useTimelineDemo.ts | 327 ++++++++ 7 files changed, 1558 insertions(+) create mode 100644 examples/infovis-layers/timeline/app.tsx create mode 100644 examples/infovis-layers/timeline/index.html create mode 100644 examples/infovis-layers/timeline/index.tsx create mode 100644 examples/infovis-layers/timeline/package.json create mode 100644 examples/infovis-layers/timeline/tsconfig.json create mode 100644 examples/infovis-layers/timeline/useTimeline.ts create mode 100644 examples/infovis-layers/timeline/useTimelineDemo.ts diff --git a/examples/infovis-layers/timeline/app.tsx b/examples/infovis-layers/timeline/app.tsx new file mode 100644 index 000000000..61abc2a69 --- /dev/null +++ b/examples/infovis-layers/timeline/app.tsx @@ -0,0 +1,734 @@ +// deck.gl-community +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import type {OrthographicViewState} from '@deck.gl/core'; + +import React, {ReactElement, useCallback, useEffect} from 'react'; +import DeckGL from '@deck.gl/react'; +import {OrthographicView} from '@deck.gl/core'; +import {TextLayer, SolidPolygonLayer, LineLayer} from '@deck.gl/layers'; +import {useTimelineCore, timeAxisFormatters} from './useTimeline'; +import {useTimelineDemo} from './useTimelineDemo'; + +// Calculate center dynamically based on available canvas space +const canvasWidth = (typeof window !== 'undefined' ? window.innerWidth : 1280) - 320; // Minus sidebar +const canvasHeight = typeof window !== 'undefined' ? window.innerHeight : 720; + +const INITIAL_VIEW_STATE: OrthographicViewState = { + target: [canvasWidth / 2, canvasHeight / 2, 0], // Center of available canvas + zoom: 0 +}; + +export default function App(): ReactElement { + // Use demo hook for all demo-specific logic + const demo = useTimelineDemo(); + + // Use core timeline hook for rendering + const timeline = useTimelineCore(demo.clips, demo.timelineConfig, { + labelFormatter: demo.labelFormatter, + currentTimeMs: demo.currentTimeMs, + onCurrentTimeChange: demo.setCurrentTimeMs + }); + + const { + selectedClip, + setSelectedClip, + setHoveredClip, + isDraggingScrubber, + setIsDraggingScrubber, + trackBackgrounds, + trackLabels, + clipPolygons, + clipLabels, + topAxisLines, + topAxisLabels, + verticalGridLines, + scrubberLine, + scrubberHandle, + scrubberTimeLabel, + positionToTime + } = timeline; + + // Mouse event handlers for scrubber dragging and panning + const handleMouseMove = useCallback( + (e: React.MouseEvent) => { + const rect = e.currentTarget.getBoundingClientRect(); + const mouseX = e.clientX - rect.left; + + if (isDraggingScrubber) { + e.preventDefault(); + e.stopPropagation(); + const newTime = positionToTime(mouseX); + demo.setCurrentTimeMs(newTime); + } else if (demo.isPanning) { + e.preventDefault(); + e.stopPropagation(); + demo.updatePan(mouseX); + } + }, + [isDraggingScrubber, demo.isPanning, positionToTime, demo] + ); + + const handleMouseDown = useCallback( + (e: React.MouseEvent) => { + const rect = e.currentTarget.getBoundingClientRect(); + const mouseX = e.clientX - rect.left; + const mouseY = e.clientY - rect.top; + + // Check if clicking on scrubber handle (priority interaction) + const scrubberHandleLeft = timeline.scrubberPosition - 8; + const scrubberHandleRight = timeline.scrubberPosition + 8; + const scrubberHandleTop = demo.timelineConfig.timelineY - 45; + const scrubberHandleBottom = demo.timelineConfig.timelineY - 25; + + const isOnScrubberHandle = + mouseX >= scrubberHandleLeft && + mouseX <= scrubberHandleRight && + mouseY >= scrubberHandleTop && + mouseY <= scrubberHandleBottom; + + if (isOnScrubberHandle) { + e.preventDefault(); + e.stopPropagation(); + setIsDraggingScrubber(true); + return; + } + + // Check if clicking anywhere in the timeline area for seeking (but not on clips) + const timelineLeft = demo.timelineConfig.timelineX; + const timelineRight = demo.timelineConfig.timelineX + timeline.effectiveTimelineWidth; + const timelineTop = demo.timelineConfig.timelineY - 50; + const timelineBottom = demo.timelineConfig.timelineY + timeline.totalTimelineHeight + 30; + + const isInTimeline = + mouseX >= timelineLeft && + mouseX <= timelineRight && + mouseY >= timelineTop && + mouseY <= timelineBottom; + + if (isInTimeline) { + // Check if we're clicking within the timeline area for panning vs seeking + const timelineLeft = demo.timelineConfig.timelineX; + const timelineRight = demo.timelineConfig.timelineX + timeline.effectiveTimelineWidth; + + if (mouseX >= timelineLeft && mouseX <= timelineRight) { + // If zoomed in and in timeline area, start panning + if (demo.zoomLevel > 1.0) { + demo.startPan(mouseX); + return; + } + // Otherwise, seek to that position + const newTime = positionToTime(mouseX); + demo.setCurrentTimeMs(newTime); + } + return; + } + }, + [demo, timeline, positionToTime, setIsDraggingScrubber] + ); + + // Handle trackpad horizontal scrolling for scrubber + const handleWheel = useCallback( + (e: React.WheelEvent) => { + // Check if this is horizontal scrolling (trackpad two-finger drag) + if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) { + // Horizontal scroll - move scrubber + e.preventDefault(); + e.stopPropagation(); + const scrollSpeed = 50; // milliseconds per pixel + const timeChange = e.deltaX * scrollSpeed; + const newTime = demo.currentTimeMs + timeChange; + demo.setCurrentTimeMs(newTime); + } else { + // Vertical scroll - zoom timeline with mouse position awareness + e.preventDefault(); + const rect = e.currentTarget.getBoundingClientRect(); + const mouseX = e.clientX - rect.left; + const zoomFactor = e.deltaY > 0 ? 0.8 : 1.2; // Zoom in/out + demo.zoomToPoint(zoomFactor, mouseX); + } + }, + [demo] + ); + + const handleMouseUp = useCallback(() => { + setIsDraggingScrubber(false); + demo.endPan(); + }, [setIsDraggingScrubber, demo]); + + // Global mouse event handlers + useEffect(() => { + const handleGlobalMouseMove = (e: MouseEvent) => { + const timelineContainer = document.querySelector( + '[data-timeline-container="true"]' + ) as HTMLElement; + if (timelineContainer) { + const rect = timelineContainer.getBoundingClientRect(); + const mouseX = e.clientX - rect.left; + + if (isDraggingScrubber) { + const newTime = positionToTime(mouseX); + demo.setCurrentTimeMs(newTime); + } else if (demo.isPanning) { + demo.updatePan(mouseX); + } + } + }; + + const handleGlobalMouseUp = () => { + setIsDraggingScrubber(false); + demo.endPan(); + }; + + if (isDraggingScrubber || demo.isPanning) { + document.addEventListener('mousemove', handleGlobalMouseMove); + document.addEventListener('mouseup', handleGlobalMouseUp); + return () => { + document.removeEventListener('mousemove', handleGlobalMouseMove); + document.removeEventListener('mouseup', handleGlobalMouseUp); + }; + } + }, [isDraggingScrubber, demo.isPanning, setIsDraggingScrubber, positionToTime, demo, timeline]); + + const layers = [ + // Track backgrounds + new SolidPolygonLayer({ + id: 'track-backgrounds', + data: trackBackgrounds, + getPolygon: (d: any) => d.polygon, + getFillColor: (d: any) => d.color, + stroked: true, + getLineColor: [200, 200, 200, 255], + getLineWidth: 1 + }), + + // Timeline axis lines (top) + new LineLayer({ + id: 'timeline-axis-lines-top', + data: topAxisLines, + getSourcePosition: (d: any) => d.sourcePosition, + getTargetPosition: (d: any) => d.targetPosition, + getColor: [50, 50, 50, 255], + getWidth: 2 + }), + + // Timeline axis labels (top) + new TextLayer({ + id: 'timeline-axis-labels-top', + data: topAxisLabels, + getText: (d: any) => d.text, + getPosition: (d: any) => d.position, + getSize: (d: any) => d.size, + getColor: (d: any) => d.color, + getTextAnchor: (d: any) => d.textAnchor, + getAlignmentBaseline: (d: any) => d.alignmentBaseline, + fontFamily: 'Arial, sans-serif' + }), + + // Vertical grid lines + new LineLayer({ + id: 'vertical-grid', + data: verticalGridLines, + getSourcePosition: (d: any) => d.sourcePosition, + getTargetPosition: (d: any) => d.targetPosition, + getColor: [200, 200, 200, 128], + getWidth: 1 + }), + + // Track labels + new TextLayer({ + id: 'track-labels', + data: trackLabels, + getText: (d: any) => d.text, + getPosition: (d: any) => d.position, + getSize: (d: any) => d.size, + getColor: (d: any) => d.color, + getTextAnchor: (d: any) => d.textAnchor, + getAlignmentBaseline: (d: any) => d.alignmentBaseline, + fontFamily: 'Arial, sans-serif', + fontWeight: 'bold' + }), + + // Clip rectangles + new SolidPolygonLayer({ + id: 'timeline-clips', + data: clipPolygons, + getPolygon: (d: any) => d.polygon, + getFillColor: (d: any) => d.color, + stroked: true, + getLineColor: [255, 255, 255, 200], + getLineWidth: 2, + pickable: true, // Re-enable picking for clips + onClick: (info: any) => { + if (info.object) { + setSelectedClip(info.object.clip); + } + }, + onHover: (info: any) => { + if (info.object) { + setHoveredClip(info.object.clip); + } else { + setHoveredClip(null); + } + } + }), + + // Clip labels + new TextLayer({ + id: 'clip-labels', + data: clipLabels, + getText: (d: any) => d.text, + getPosition: (d: any) => d.position, + getSize: (d: any) => d.size, + getColor: (d: any) => d.color, + getTextAnchor: (d: any) => d.textAnchor, + getAlignmentBaseline: (d: any) => d.alignmentBaseline, + fontFamily: 'Arial, sans-serif', + fontWeight: 'bold' + }), + + // Scrubber line + new LineLayer({ + id: 'scrubber-line', + data: scrubberLine, + getSourcePosition: (d: any) => d.sourcePosition, + getTargetPosition: (d: any) => d.targetPosition, + getColor: [255, 69, 0, 255], + getWidth: 3 + }), + + // Scrubber handle + new SolidPolygonLayer({ + id: 'scrubber-handle', + data: scrubberHandle, + getPolygon: (d: any) => d.polygon, + getFillColor: (d: any) => d.color, + stroked: true, + getLineColor: [255, 255, 255, 255], + getLineWidth: 2, + pickable: false // Handle via container events only + }), + + // Scrubber time label + new TextLayer({ + id: 'scrubber-time-label', + data: scrubberTimeLabel, + getText: (d: any) => d.text, + getPosition: (d: any) => d.position, + getSize: (d: any) => d.size, + getColor: (d: any) => d.color, + getTextAnchor: (d: any) => d.textAnchor, + getAlignmentBaseline: (d: any) => d.alignmentBaseline, + fontFamily: 'Arial, sans-serif', + fontWeight: 'bold' + }) + ]; + + return ( +
+
+
+

Timeline Controls

+
+ +
+
+

+ 🚀 Performance Stats +

+
+
+ Clips Rendered: {clipPolygons.length.toLocaleString()} +
+
+ Labels Shown: {clipLabels.length.toLocaleString()} +
+
+ Data Generation: {demo.renderTime.toFixed(2)}ms +
+
+ Zoom Level: {demo.zoomLevel.toFixed(2)}x +
+
+
+ +
+

+ Timeline Configuration +

+ +
+ + demo.setTrackCount(Number(e.target.value))} + style={{width: '100%'}} + /> +
+ +
+ + +
+ Performance test: Try 100k+ clips! +
+
+ +
+ + demo.setTimelineEnd(demo.timelineStart + Number(e.target.value))} + style={{width: '100%'}} + /> +
+
+ +
+

+ Timeline Axis Labels +

+ +
+ + +
+
+ +
+

+ Track Appearance +

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

+ Position & Size +

+ +
+ + demo.setTimelineWidth(Math.max(400, Number(e.target.value)))} + style={{width: '100%'}} + /> +
+ +
+ + demo.setTimelineX(Number(e.target.value))} + style={{width: '100%'}} + /> +
+ +
+ + demo.setTimelineY(Number(e.target.value))} + style={{width: '100%'}} + /> +
+
+ + {selectedClip && ( +
+

+ Selected Clip +

+
+
+ Label: {selectedClip.label} +
+
+ Track: {selectedClip.trackId + 1} +
+
+ Start: {(selectedClip.startMs / 1000).toFixed(2)}s +
+
+ End: {(selectedClip.endMs / 1000).toFixed(2)}s +
+
+ Duration:{' '} + {((selectedClip.endMs - selectedClip.startMs) / 1000).toFixed(2)}s +
+
+ Color: + +
+ +
+
+ )} + +
+

+ Timeline Scrubber +

+ +
+ + demo.setCurrentTimeMs(Number(e.target.value))} + style={{width: '100%'}} + /> +
+ Click or drag on timeline to scrub +
+
+
+ +
+

+ Timeline Zoom +

+ +
+ + demo.setZoomLevel(Number(e.target.value))} + style={{width: '100%'}} + /> +
+ Use mouse wheel over timeline for horizontal zoom +
+
+
+ +
+ Instructions: +
+ • Use mouse wheel over timeline for horizontal zoom +
+ • Click or drag anywhere on timeline to scrub/seek +
+ • Click clips to select them +
+ • Hover over clips to highlight them +
+ • Pan by dragging the viewport +
• Selected clip info appears above +
+
+
+ +
+ +
+
+ ); +} diff --git a/examples/infovis-layers/timeline/index.html b/examples/infovis-layers/timeline/index.html new file mode 100644 index 000000000..6c5145301 --- /dev/null +++ b/examples/infovis-layers/timeline/index.html @@ -0,0 +1,21 @@ + + + + Timeline Tracks + + + + +
+ + + \ No newline at end of file diff --git a/examples/infovis-layers/timeline/index.tsx b/examples/infovis-layers/timeline/index.tsx new file mode 100644 index 000000000..42d60b876 --- /dev/null +++ b/examples/infovis-layers/timeline/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.getElementById('app')!); +root.render(); \ No newline at end of file diff --git a/examples/infovis-layers/timeline/package.json b/examples/infovis-layers/timeline/package.json new file mode 100644 index 000000000..4d2fb61f0 --- /dev/null +++ b/examples/infovis-layers/timeline/package.json @@ -0,0 +1,23 @@ +{ + "license": "MIT", + "scripts": { + "start": "vite --open", + "start-local": "vite --config ../../vite.config.local.mjs" + }, + "dependencies": { + "@deck.gl-community/infovis-layers": "file:../../../modules/infovis-layers", + "@deck.gl/core": "~9.2.1", + "@deck.gl/layers": "~9.2.1", + "@deck.gl/react": "~9.2.1", + "@deck.gl/widgets": "~9.2.1", + "@loaders.gl/core": "^4.2.0", + "@luma.gl/core": "~9.2.0", + "@luma.gl/engine": "~9.2.0", + "@luma.gl/shadertools": "~9.2.0", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "vite": "7.1.1" + } +} diff --git a/examples/infovis-layers/timeline/tsconfig.json b/examples/infovis-layers/timeline/tsconfig.json new file mode 100644 index 000000000..e57204e9c --- /dev/null +++ b/examples/infovis-layers/timeline/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "jsx": "react-jsx" + } +} \ No newline at end of file diff --git a/examples/infovis-layers/timeline/useTimeline.ts b/examples/infovis-layers/timeline/useTimeline.ts new file mode 100644 index 000000000..15c19d882 --- /dev/null +++ b/examples/infovis-layers/timeline/useTimeline.ts @@ -0,0 +1,437 @@ +// deck.gl-community +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import {useState, useMemo} from 'react'; + +// Timeline Clip Interface - implement this interface for your clips +export interface TimelineClip { + id: string; + trackId: number; + startMs: number; + endMs: number; + label: string; + color: [number, number, number, number]; // RGBA +} + +// Timeline configuration interface +export interface TimelineConfig { + // Core timeline settings + trackCount: number; + trackHeight: number; + trackSpacing: number; + + // Timeline positioning + timelineX: number; + timelineY: number; + timelineWidth: number; + + // Time range + timelineStart: number; + timelineEnd: number; + + // Zoom level + zoomLevel: number; + + // Viewport (visible time range within fixed timeline width) + viewportStartMs?: number; + viewportEndMs?: number; +} + +// Timeline axis label formatter type +export type TimeAxisLabelFormatter = (timeMs: number) => string; + +// Default label 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 tick generation +const generateTimelineTicks = ( + startMs: number, + endMs: number, + timelineX: number, + timelineWidth: number, + tickCount: number, + formatter: TimeAxisLabelFormatter +): {position: number; timeMs: number; label: string}[] => { + const ticks = []; + 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; +}; + +// Core timeline hook for reusable timeline functionality +export function useTimelineCore( + clips: TimelineClip[], + config: TimelineConfig, + options: { + labelFormatter?: TimeAxisLabelFormatter; + showLabelsMinZoom?: number; + maxLabelsToShow?: number; + currentTimeMs?: number; + onCurrentTimeChange?: (timeMs: number) => void; + } = {} +) { + const { + labelFormatter = timeAxisFormatters.seconds, + showLabelsMinZoom = 1.5, + maxLabelsToShow = 1000, + currentTimeMs = config.timelineStart, + onCurrentTimeChange + } = options; + + // Interaction state + const [selectedClip, setSelectedClip] = useState(null); + const [hoveredClip, setHoveredClip] = useState(null); + const [isDraggingScrubber, setIsDraggingScrubber] = useState(false); + + // Calculate derived values + const totalTimelineHeight = + config.trackCount * (config.trackHeight + config.trackSpacing) - config.trackSpacing; + const minTimelineWidth = 50; // Enforce minimum width + const actualTimelineWidth = Math.max(minTimelineWidth, config.timelineWidth); + + // Use viewport range if available, otherwise use full timeline range + const effectiveStartMs = config.viewportStartMs ?? config.timelineStart; + const effectiveEndMs = config.viewportEndMs ?? config.timelineEnd; + const effectiveTimelineWidth = actualTimelineWidth; + + // Generate track background rectangles + const trackBackgrounds = useMemo(() => { + const backgrounds = []; + for (let i = 0; i < config.trackCount; i++) { + const y = config.timelineY + i * (config.trackHeight + config.trackSpacing); + backgrounds.push({ + id: `track-bg-${i}`, + polygon: [ + [config.timelineX, y], + [config.timelineX + effectiveTimelineWidth, y], + [config.timelineX + effectiveTimelineWidth, y + config.trackHeight], + [config.timelineX, y + config.trackHeight] + ], + color: [240, 240, 240, 100] as [number, number, number, number] + }); + } + return backgrounds; + }, [ + config.trackCount, + config.trackHeight, + config.trackSpacing, + config.timelineX, + config.timelineY, + effectiveTimelineWidth + ]); + + // Generate track labels + const trackLabels = useMemo(() => { + const labels = []; + for (let i = 0; i < config.trackCount; i++) { + const y = + config.timelineY + i * (config.trackHeight + config.trackSpacing) + config.trackHeight / 2; + labels.push({ + id: `track-label-${i}`, + text: `Track ${i + 1}`, + position: [config.timelineX - 10, y, 0], + size: 12, + color: [60, 60, 60, 255], + textAnchor: 'end', + alignmentBaseline: 'center' + }); + } + return labels; + }, [ + config.trackCount, + config.trackHeight, + config.trackSpacing, + config.timelineX, + config.timelineY + ]); + + // Convert clips to polygons for rendering + const clipPolygons = useMemo(() => { + return clips + .filter((clip) => { + // Only render clips that overlap with the current viewport + return clip.endMs > effectiveStartMs && clip.startMs < effectiveEndMs; + }) + .map((clip) => { + const trackY = config.timelineY + clip.trackId * (config.trackHeight + config.trackSpacing); + + // Calculate clip positions, clamping to visible timeline bounds + const clipStartRatio = (clip.startMs - effectiveStartMs) / (effectiveEndMs - effectiveStartMs); + const clipEndRatio = (clip.endMs - effectiveStartMs) / (effectiveEndMs - effectiveStartMs); + + const clipStartX = config.timelineX + Math.max(0, clipStartRatio) * effectiveTimelineWidth; + const clipEndX = config.timelineX + Math.min(1, clipEndRatio) * effectiveTimelineWidth; + + const isSelected = selectedClip?.id === clip.id; + const isHovered = hoveredClip?.id === clip.id; + + let color = clip.color; + if (isSelected) { + color = [255, 255, 0, 220] as [number, number, number, number]; + } else if (isHovered) { + color = [clip.color[0], clip.color[1], clip.color[2], 255] as [ + number, + number, + number, + number + ]; + } + + return { + id: clip.id, + clip, + polygon: [ + [clipStartX, trackY + 5], + [clipEndX, trackY + 5], + [clipEndX, trackY + config.trackHeight - 5], + [clipStartX, trackY + config.trackHeight - 5] + ], + color, + label: clip.label, + labelPosition: [ + clipStartX + (clipEndX - clipStartX) / 2, + trackY + config.trackHeight / 2, + 0 + ] + }; + }); + }, [clips, config, effectiveTimelineWidth, effectiveStartMs, effectiveEndMs, selectedClip, hoveredClip]); + + // Generate clip labels (performance optimized) + const clipLabels = useMemo(() => { + if (config.zoomLevel < showLabelsMinZoom) return []; + + return clipPolygons + .filter((clip) => { + const [x1] = clip.polygon[0]; + const [x2] = clip.polygon[1]; + return x2 - x1 > 60; + }) + .slice(0, maxLabelsToShow) + .map((clip) => ({ + id: `${clip.id}-label`, + text: clip.label, + position: clip.labelPosition, + size: 10, + color: [255, 255, 255, 255], + textAnchor: 'middle', + alignmentBaseline: 'center' + })); + }, [clipPolygons, config.zoomLevel, showLabelsMinZoom, maxLabelsToShow]); + + // Generate timeline ticks for custom axes + const timelineTicks = useMemo(() => { + // Keep tick count reasonable regardless of zoom level + // Base it on the timeline width in pixels, not zoom level + const idealTickSpacing = 80; // pixels between ticks + const maxTicks = Math.floor(effectiveTimelineWidth / idealTickSpacing); + const tickCount = Math.max(4, Math.min(10, maxTicks)); // Between 4-10 ticks max + + return generateTimelineTicks( + effectiveStartMs, + effectiveEndMs, + config.timelineX, + effectiveTimelineWidth, + tickCount, + labelFormatter + ); + }, [ + effectiveStartMs, + effectiveEndMs, + config.timelineX, + effectiveTimelineWidth, + config.zoomLevel, + labelFormatter + ]); + + // Generate axis lines and labels + const topAxisLines = useMemo(() => { + const axisY = config.timelineY - 30; + const tickLines = timelineTicks.map((tick) => ({ + sourcePosition: [tick.position, axisY - 5], + targetPosition: [tick.position, axisY + 5] + })); + + tickLines.push({ + sourcePosition: [config.timelineX, axisY], + targetPosition: [config.timelineX + effectiveTimelineWidth, axisY] + }); + + return tickLines; + }, [timelineTicks, config.timelineY, config.timelineX, effectiveTimelineWidth]); + + const bottomAxisLines = useMemo(() => { + const axisY = config.timelineY + totalTimelineHeight + 20; + const tickLines = timelineTicks.map((tick) => ({ + sourcePosition: [tick.position, axisY - 5], + targetPosition: [tick.position, axisY + 5] + })); + + tickLines.push({ + sourcePosition: [config.timelineX, axisY], + targetPosition: [config.timelineX + effectiveTimelineWidth, axisY] + }); + + return tickLines; + }, [ + timelineTicks, + config.timelineY, + totalTimelineHeight, + config.timelineX, + effectiveTimelineWidth + ]); + + const topAxisLabels = useMemo(() => { + const axisY = config.timelineY - 30; + return timelineTicks.map((tick) => ({ + text: tick.label, + position: [tick.position, axisY - 15, 0], + size: 11, + color: [50, 50, 50, 255], + textAnchor: 'middle', + alignmentBaseline: 'center' + })); + }, [timelineTicks, config.timelineY]); + + const bottomAxisLabels = useMemo(() => { + const axisY = config.timelineY + totalTimelineHeight + 20; + return timelineTicks.map((tick) => ({ + text: tick.label, + position: [tick.position, axisY + 15, 0], + size: 11, + color: [50, 50, 50, 255], + textAnchor: 'middle', + alignmentBaseline: 'center' + })); + }, [timelineTicks, config.timelineY, totalTimelineHeight]); + + const verticalGridLines = useMemo(() => { + return timelineTicks.map((tick) => ({ + sourcePosition: [tick.position, config.timelineY - 40], + targetPosition: [tick.position, config.timelineY + totalTimelineHeight + 30] + })); + }, [timelineTicks, config.timelineY, totalTimelineHeight]); + + // Generate scrubber/playhead position and elements + const scrubberPosition = useMemo(() => { + const timeRatio = (currentTimeMs - effectiveStartMs) / (effectiveEndMs - effectiveStartMs); + const clampedRatio = Math.max(0, Math.min(1, timeRatio)); + return config.timelineX + clampedRatio * effectiveTimelineWidth; + }, [ + currentTimeMs, + effectiveStartMs, + effectiveEndMs, + config.timelineX, + effectiveTimelineWidth + ]); + + const scrubberLine = useMemo(() => { + return [ + { + sourcePosition: [scrubberPosition, config.timelineY - 40], + targetPosition: [scrubberPosition, config.timelineY + totalTimelineHeight + 30] + } + ]; + }, [scrubberPosition, config.timelineY, totalTimelineHeight]); + + const scrubberHandle = useMemo(() => { + return [ + { + id: 'scrubber-handle', + polygon: [ + [scrubberPosition - 8, config.timelineY - 45], + [scrubberPosition + 8, config.timelineY - 45], + [scrubberPosition + 8, config.timelineY - 25], + [scrubberPosition - 8, config.timelineY - 25] + ], + color: [255, 69, 0, 255] as [number, number, number, number] + } + ]; + }, [scrubberPosition, config.timelineY]); + + const scrubberTimeLabel = useMemo(() => { + return [ + { + text: labelFormatter(currentTimeMs), + position: [scrubberPosition, config.timelineY - 50, 0], + size: 10, + color: [255, 69, 0, 255], + textAnchor: 'middle', + alignmentBaseline: 'bottom' + } + ]; + }, [scrubberPosition, config.timelineY, currentTimeMs, labelFormatter]); + + // Helper function to convert mouse position to time + const positionToTime = (x: number): number => { + const ratio = (x - config.timelineX) / effectiveTimelineWidth; + const clampedRatio = Math.max(0, Math.min(1, ratio)); + return effectiveStartMs + clampedRatio * (effectiveEndMs - effectiveStartMs); + }; + + return { + // Interaction state + selectedClip, + setSelectedClip, + hoveredClip, + setHoveredClip, + isDraggingScrubber, + setIsDraggingScrubber, + + // Computed values + totalTimelineHeight, + effectiveTimelineWidth, + currentTimeMs, + scrubberPosition, + + // Render data + trackBackgrounds, + trackLabels, + clipPolygons, + clipLabels, + + // Axis data + topAxisLines, + bottomAxisLines, + topAxisLabels, + bottomAxisLabels, + verticalGridLines, + + // Scrubber data + scrubberLine, + scrubberHandle, + scrubberTimeLabel, + + // Helper functions + positionToTime, + + // Raw data for custom usage + timelineTicks + }; +} diff --git a/examples/infovis-layers/timeline/useTimelineDemo.ts b/examples/infovis-layers/timeline/useTimelineDemo.ts new file mode 100644 index 000000000..abf347210 --- /dev/null +++ b/examples/infovis-layers/timeline/useTimelineDemo.ts @@ -0,0 +1,327 @@ +// deck.gl-community +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import {useState, useMemo} from 'react'; +import { + TimelineClip, + TimelineConfig, + timeAxisFormatters, + TimeAxisLabelFormatter +} from './useTimeline'; + +// Timeline constants for demo +const TIMELINE_START_MS = 0; +const TIMELINE_END_MS = 60000; // 60 seconds +const TRACK_HEIGHT = 60; +const TRACK_SPACING = 10; + +// Generate random clips for demo purposes +const generateRandomClips = ( + trackCount: number, + clipCount: number, + timelineStart: number, + timelineEnd: number +): TimelineClip[] => { + const clips: TimelineClip[] = []; + const colors = [ + [255, 99, 132, 200], + [54, 162, 235, 200], + [255, 205, 86, 200], + [75, 192, 192, 200], + [153, 102, 255, 200], + [255, 159, 64, 200], + [199, 199, 199, 200], + [83, 102, 255, 200] + ] as [number, number, number, number][]; + + for (let i = 0; i < clipCount; i++) { + const trackId = Math.floor(Math.random() * trackCount); + const duration = Math.random() * (timelineEnd - timelineStart) * 0.1; // Max 10% of timeline + const startMs = Math.random() * (timelineEnd - timelineStart - duration) + timelineStart; + const endMs = startMs + duration; + + clips.push({ + id: `clip-${i}`, + trackId, + startMs, + endMs, + label: `Clip ${i + 1}`, + color: colors[i % colors.length] + }); + } + + return clips; +}; + +// Demo hook with all the controls and demo-specific logic +export function useTimelineDemo() { + // Demo control state + const [trackCount, setTrackCount] = useState(4); + const [clipCount, setClipCount] = useState(12); + const [timelineStart, setTimelineStart] = useState(TIMELINE_START_MS); + const [timelineEnd, setTimelineEnd] = useState(TIMELINE_END_MS); + + // Timeline appearance + const [trackHeight, setTrackHeight] = useState(TRACK_HEIGHT); + const [trackSpacing, setTrackSpacing] = useState(TRACK_SPACING); + + // Timeline positioning - use full canvas size (accounting for 320px sidebar) + // Calculate canvas dimensions: assuming 1280px total width - 320px sidebar = 960px available + const canvasWidth = window.innerWidth - 320; // Full width minus sidebar + const canvasHeight = window.innerHeight; + + const [timelineX, setTimelineX] = useState(50); // Small margin from left edge + const [timelineY, setTimelineY] = useState(100); // Small margin from top + const [timelineWidth, setTimelineWidth] = useState(canvasWidth - 100); // Full width minus margins + + // Zoom level + const [zoomLevel, setZoomLevel] = useState(1); + + // Viewport range - what time range is currently visible within the fixed timeline width + const [viewportStartMs, setViewportStartMs] = useState(undefined); + const [viewportEndMs, setViewportEndMs] = useState(undefined); + + // Pan functionality + const [isPanning, setIsPanning] = useState(false); + const [panStartX, setPanStartX] = useState(0); + const [panStartViewport, setPanStartViewport] = useState<{start: number, end: number} | null>(null); + + // Current time / scrubber position + const [currentTimeMs, setCurrentTimeMs] = useState(timelineStart); + + + // Label formatter selection + const [labelFormatterType, setLabelFormatterType] = + useState('seconds'); + const labelFormatter: TimeAxisLabelFormatter = timeAxisFormatters[labelFormatterType]; + + // Performance tracking + const [renderTime, setRenderTime] = useState(0); + + // Generate clips data with performance timing + const clips = useMemo(() => { + const startTime = performance.now(); + const result = generateRandomClips(trackCount, clipCount, timelineStart, timelineEnd); + const endTime = performance.now(); + setRenderTime(endTime - startTime); + return result; + }, [trackCount, clipCount, timelineStart, timelineEnd]); + + // Create timeline configuration + const timelineConfig: TimelineConfig = useMemo( + () => ({ + trackCount, + trackHeight, + trackSpacing, + timelineX, + timelineY, + timelineWidth, + timelineStart, + timelineEnd, + zoomLevel, + viewportStartMs, + viewportEndMs + }), + [ + trackCount, + trackHeight, + trackSpacing, + timelineX, + timelineY, + timelineWidth, + timelineStart, + timelineEnd, + zoomLevel, + viewportStartMs, + viewportEndMs + ] + ); + + // Handle currentTime changes to keep it within timeline bounds (not viewport bounds) + const handleCurrentTimeChange = (newTimeMs: number) => { + const clampedTime = Math.max(timelineStart, Math.min(timelineEnd, newTimeMs)); + setCurrentTimeMs(clampedTime); + }; + + // Zoom to point function that maintains fixed timeline width but changes viewport range + const zoomToPoint = (zoomFactor: number, mouseX?: number) => { + // Calculate new zoom level with proper limits + // Minimum zoom (1.0) shows the full timeline, maximum zoom allows extreme granularity + const newZoomLevel = Math.max(1.0, Math.min(100, zoomLevel * zoomFactor)); + + if (mouseX !== undefined) { + // Convert DOM mouseX to timeline-relative position + // mouseX is in DOM coordinates, we need to map it to timeline coordinates + // Since timeline spans from timelineX to timelineX+timelineWidth in world coords, + // but mouseX is in DOM coords, we need to calculate the relative position differently + + + // Simple approach: use mouseX directly relative to the timeline configuration + // The timeline is configured with timelineX and timelineWidth + // We'll assume mouseX is in the same coordinate space and map it directly + let mouseRatio = (mouseX - timelineX) / timelineWidth; + mouseRatio = Math.max(0, Math.min(1, mouseRatio)); // Clamp to 0-1 + + // Current viewport or full timeline range + const currentStartMs = viewportStartMs ?? timelineStart; + const currentEndMs = viewportEndMs ?? timelineEnd; + + // Time at mouse position in current viewport + const mouseTimeMs = currentStartMs + mouseRatio * (currentEndMs - currentStartMs); + + // Calculate new viewport range based on zoom level + const fullTimeRange = timelineEnd - timelineStart; + const newViewportRange = fullTimeRange / newZoomLevel; + + // Center the new viewport around the mouse time, maintaining the mouse position + // The key insight: mouseTimeMs should remain at the same relative position (mouseRatio) in the new viewport + const newStartMs = mouseTimeMs - mouseRatio * newViewportRange; + const newEndMs = newStartMs + newViewportRange; + + // Clamp viewport to timeline bounds + let clampedStartMs = newStartMs; + let clampedEndMs = newEndMs; + + if (newStartMs < timelineStart) { + clampedStartMs = timelineStart; + clampedEndMs = timelineStart + newViewportRange; + } else if (newEndMs > timelineEnd) { + clampedEndMs = timelineEnd; + clampedStartMs = timelineEnd - newViewportRange; + } + + // Only set viewport if we're zoomed in (zoom > 1) + if (newZoomLevel > 1.0) { + setViewportStartMs(clampedStartMs); + setViewportEndMs(clampedEndMs); + } else { + // At zoom level 1.0, reset to show full timeline + setViewportStartMs(undefined); + setViewportEndMs(undefined); + } + } else { + // No mouse position provided or mouse outside timeline - zoom from center + if (newZoomLevel <= 1.0) { + // Reset to full timeline view + setViewportStartMs(undefined); + setViewportEndMs(undefined); + } else { + // Zoom from center of current viewport + const currentStartMs = viewportStartMs ?? timelineStart; + const currentEndMs = viewportEndMs ?? timelineEnd; + const currentCenterMs = (currentStartMs + currentEndMs) / 2; + + const fullTimeRange = timelineEnd - timelineStart; + const newViewportRange = fullTimeRange / newZoomLevel; + + const newStartMs = currentCenterMs - newViewportRange / 2; + const newEndMs = currentCenterMs + newViewportRange / 2; + + // Clamp to timeline bounds + let clampedStartMs = Math.max(timelineStart, newStartMs); + let clampedEndMs = Math.min(timelineEnd, newEndMs); + + // Ensure we maintain the viewport range + if (clampedEndMs - clampedStartMs < newViewportRange) { + if (clampedStartMs === timelineStart) { + clampedEndMs = Math.min(timelineEnd, clampedStartMs + newViewportRange); + } else { + clampedStartMs = Math.max(timelineStart, clampedEndMs - newViewportRange); + } + } + + setViewportStartMs(clampedStartMs); + setViewportEndMs(clampedEndMs); + } + } + + setZoomLevel(newZoomLevel); + }; + + // Pan functions for horizontal dragging + const startPan = (mouseX: number) => { + if (zoomLevel > 1.0) { // Only allow panning when zoomed in + setIsPanning(true); + setPanStartX(mouseX); + const currentStart = viewportStartMs ?? timelineStart; + const currentEnd = viewportEndMs ?? timelineEnd; + setPanStartViewport({ start: currentStart, end: currentEnd }); + } + }; + + const updatePan = (mouseX: number) => { + if (isPanning && panStartViewport && zoomLevel > 1.0) { + const deltaX = mouseX - panStartX; + const currentRange = panStartViewport.end - panStartViewport.start; + + // Convert pixel movement to time movement + const timePerPixel = currentRange / timelineWidth; + const timeDelta = -deltaX * timePerPixel; // Negative for intuitive drag direction + + let newStart = panStartViewport.start + timeDelta; + let newEnd = panStartViewport.end + timeDelta; + + // Clamp to timeline bounds + if (newStart < timelineStart) { + newStart = timelineStart; + newEnd = timelineStart + currentRange; + } else if (newEnd > timelineEnd) { + newEnd = timelineEnd; + newStart = timelineEnd - currentRange; + } + + setViewportStartMs(newStart); + setViewportEndMs(newEnd); + } + }; + + const endPan = () => { + setIsPanning(false); + setPanStartViewport(null); + }; + + return { + // Generated data + clips, + timelineConfig, + labelFormatter, + renderTime, + + // State and setters for controls + trackCount, + setTrackCount, + clipCount, + setClipCount, + timelineStart, + setTimelineStart, + timelineEnd, + setTimelineEnd, + trackHeight, + setTrackHeight, + trackSpacing, + setTrackSpacing, + timelineX, + setTimelineX, + timelineY, + setTimelineY, + timelineWidth, + setTimelineWidth, + zoomLevel, + setZoomLevel, + currentTimeMs, + setCurrentTimeMs: handleCurrentTimeChange, + labelFormatterType, + setLabelFormatterType, + zoomToPoint, + + // Pan functionality + isPanning, + startPan, + updatePan, + endPan, + + // Available formatters for UI + availableFormatters: Object.keys(timeAxisFormatters) as (keyof typeof timeAxisFormatters)[] + }; +} From 3b194e304fc36fc62457cc236a970566c7657ed1 Mon Sep 17 00:00:00 2001 From: charlieforward9 Date: Tue, 25 Nov 2025 02:04:26 -0500 Subject: [PATCH 2/4] wip feat(TimelineLayer): implement exploratory CompositeLayer --- examples/infovis-layers/timeline/README.md | 108 +++ examples/infovis-layers/timeline/app.tsx | 847 ++++------------- .../infovis-layers/timeline/demo-controls.tsx | 701 +++++++++++++++ .../infovis-layers/timeline/demo-utils.ts | 68 ++ examples/infovis-layers/timeline/index.html | 5 +- examples/infovis-layers/timeline/index.tsx | 4 +- examples/infovis-layers/timeline/package.json | 2 +- .../timeline/timeline-collision.ts | 60 ++ .../infovis-layers/timeline/timeline-hooks.ts | 651 ++++++++++++++ .../infovis-layers/timeline/timeline-layer.ts | 849 ++++++++++++++++++ .../infovis-layers/timeline/timeline-types.ts | 280 ++++++ .../infovis-layers/timeline/timeline-utils.ts | 83 ++ .../infovis-layers/timeline/tsconfig.json | 6 +- .../timeline/use-timeline-interactions.ts | 224 +++++ .../infovis-layers/timeline/useTimeline.ts | 437 --------- .../timeline/useTimelineDemo.ts | 327 ------- yarn.lock | 19 + 17 files changed, 3210 insertions(+), 1461 deletions(-) create mode 100644 examples/infovis-layers/timeline/README.md create mode 100644 examples/infovis-layers/timeline/demo-controls.tsx create mode 100644 examples/infovis-layers/timeline/demo-utils.ts create mode 100644 examples/infovis-layers/timeline/timeline-collision.ts create mode 100644 examples/infovis-layers/timeline/timeline-hooks.ts create mode 100644 examples/infovis-layers/timeline/timeline-layer.ts create mode 100644 examples/infovis-layers/timeline/timeline-types.ts create mode 100644 examples/infovis-layers/timeline/timeline-utils.ts create mode 100644 examples/infovis-layers/timeline/use-timeline-interactions.ts delete mode 100644 examples/infovis-layers/timeline/useTimeline.ts delete mode 100644 examples/infovis-layers/timeline/useTimelineDemo.ts diff --git a/examples/infovis-layers/timeline/README.md b/examples/infovis-layers/timeline/README.md new file mode 100644 index 000000000..e41580529 --- /dev/null +++ b/examples/infovis-layers/timeline/README.md @@ -0,0 +1,108 @@ +# Timeline Layer - Prototype + +An interactive timeline visualization layer for deck.gl. This is a **working prototype** demonstrating core timeline functionality for video editing, animation sequencing, and temporal data visualization. + +## Current Status + +This prototype successfully demonstrates: + +- Multi-track timeline rendering with clip visualization +- Interactive scrubber with drag support +- Zoom and pan controls for temporal navigation +- Clip and track selection with hover states +- Collision detection for overlapping clips (subtrack assignment) +- Multiple time formatting options +- Performance-optimized rendering with memoization + +## Remaining Work + +### 1. Critical Bugs to Fix + +- **Scrubber Position After Window Resize**: When the window is resized and the user immediately drags the scrubber, the scrubber position jumps incorrectly. The coordinate system calculation needs investigation - likely related to how mouse coordinates are translated to timeline coordinates after dimension changes. + +### 2. API Design & Internal Interactivity + +**Critical Priority**: The current implementation exposes significant internal complexity to users. A production-ready timeline layer requires: + +- **Simplified Props Interface**: Reduce the number of exposed props while maintaining flexibility +- **Internal State Management**: Move interaction state (panning, zooming, scrubber dragging) inside the layer +- **Hover State Management**: Currently hover states (hoveredClipId, hoveredTrackId) are managed externally. The layer should handle hover state internally and only expose necessary callbacks. +- **Callback Consolidation**: Streamline the event callback system (currently: onClipClick, onClipHover, onTrackClick, onTrackHover, onScrubberDrag, onViewportChange, onZoomChange) +- **Declarative Configuration**: Users should specify what they want, not how to achieve it +- **Default Behaviors**: Provide sensible defaults for common use cases (video editor vs. analytics timeline) + +The goal: Users should be able to create a functional timeline with 5-10 props, not 30+. + +### 3. Official Documentation + +This layer needs comprehensive documentation matching the standards of other deck.gl layers: + +- **API Reference**: Complete PropTypes documentation with descriptions, types, and defaults +- **Usage Guide**: Common patterns and best practices +- **Examples**: Multiple real-world use cases (video editing, analytics, scheduling) +- **Architecture Overview**: How the layer works internally (collision detection, sublayers, viewport management) +- **Migration Guide**: How to integrate into existing deck.gl projects + +Reference: [deck.gl Layer Catalog](https://deck.gl/docs/api-reference/layers) + +### 4. Testing & Validation + +- Unit tests for collision detection and time calculations +- Integration tests for interaction behaviors +- Performance benchmarks with large datasets (1000+ clips) +- Cross-browser compatibility testing + +### 5. Additional Features + +- Clip resizing (drag edges to extend/trim) +- Multi-selection support +- Keyboard shortcuts (arrow keys, space for play/pause) +- Clip drag-and-drop between tracks +- Undo/redo support +- Timeline markers and regions +- Customizable track heights +- Nested timeline groups + +## Running the Demo + +```bash +yarn install +yarn start +``` + +Open [http://localhost:5173](http://localhost:5173) + +## Architecture + +``` +timeline-layer.ts # Main CompositeLayer implementation +timeline-types.ts # TypeScript type definitions +timeline-utils.ts # Time formatting and position calculations +timeline-collision.ts # Subtrack assignment algorithm +timeline-hooks.ts # React hooks for interaction state +demo-controls.tsx # Demo control panel +app.tsx # Demo application +``` + +## Core Interactions Implemented + +- **Mouse Wheel**: Zoom timeline in/out +- **Click + Drag**: Pan when zoomed (zoom level > 1.0) +- **Click Clip**: Select clip and show details +- **Click Track Background**: Deselect clip, select track +- **Click Empty Space**: Deselect both clip and track +- **Drag Scrubber**: Scrub through timeline +- **Hover**: Clips and tracks lighten on hover + +## Contributing + +This prototype demonstrates feasibility and core functionality. To make it production-ready: + +1. Review the "Remaining Work" section above +2. Start with API simplification - gather feedback on ideal developer experience +3. Create comprehensive documentation following deck.gl standards +4. Add tests and validate performance at scale + +--- + +**Note**: This is experimental code intended for evaluation and feedback. Not recommended for production use without addressing the items in "Remaining Work". diff --git a/examples/infovis-layers/timeline/app.tsx b/examples/infovis-layers/timeline/app.tsx index 61abc2a69..7f2abcfeb 100644 --- a/examples/infovis-layers/timeline/app.tsx +++ b/examples/infovis-layers/timeline/app.tsx @@ -4,729 +4,200 @@ import type {OrthographicViewState} from '@deck.gl/core'; -import React, {ReactElement, useCallback, useEffect} from 'react'; +import React, {ReactElement, useMemo} from 'react'; import DeckGL from '@deck.gl/react'; import {OrthographicView} from '@deck.gl/core'; -import {TextLayer, SolidPolygonLayer, LineLayer} from '@deck.gl/layers'; -import {useTimelineCore, timeAxisFormatters} from './useTimeline'; -import {useTimelineDemo} from './useTimelineDemo'; - -// Calculate center dynamically based on available canvas space -const canvasWidth = (typeof window !== 'undefined' ? window.innerWidth : 1280) - 320; // Minus sidebar +import {TimelineLayer} from './timeline-layer'; +import {useTimelineControls, TimelineControls} from './demo-controls'; +import { + useTimelineInteractionState, + useTimelineRefs, + useGlobalMouseUpCleanup, + useWheelZoom, + useTimelineCallbacks, + useContainerHandlers, + useDeckGLHandlers, + useCursorGetter, + useTimelineResize +} from './timeline-hooks'; + +const canvasWidth = (typeof window !== 'undefined' ? window.innerWidth : 1280) - 320; const canvasHeight = typeof window !== 'undefined' ? window.innerHeight : 720; const INITIAL_VIEW_STATE: OrthographicViewState = { - target: [canvasWidth / 2, canvasHeight / 2, 0], // Center of available canvas + target: [canvasWidth / 2, canvasHeight / 2, 0], zoom: 0 }; -export default function App(): ReactElement { - // Use demo hook for all demo-specific logic - const demo = useTimelineDemo(); +// Memoized constants +const ORTHOGRAPHIC_VIEW = new OrthographicView(); +const CONTROLLER_CONFIG = { + scrollZoom: false, + doubleClickZoom: false, + touchZoom: false, + dragPan: false, + dragRotate: false, + keyboard: false +} as const; + +const CONTAINER_OUTER_STYLE = { + display: 'flex', + width: '100vw', + height: '100vh', + overflow: 'hidden' +} as const; + +const CONTAINER_INNER_STYLE = { + flex: 1, + height: '100vh', + position: 'relative' as const, + overflow: 'hidden', + border: '3px solid black' +} as const; - // Use core timeline hook for rendering - const timeline = useTimelineCore(demo.clips, demo.timelineConfig, { - labelFormatter: demo.labelFormatter, - currentTimeMs: demo.currentTimeMs, - onCurrentTimeChange: demo.setCurrentTimeMs - }); +export default function App(): ReactElement { + const {state, controls, trackCount, clipsPerTrack, labelFormatterType} = useTimelineControls(); - const { - selectedClip, - setSelectedClip, - setHoveredClip, - isDraggingScrubber, - setIsDraggingScrubber, - trackBackgrounds, - trackLabels, - clipPolygons, - clipLabels, - topAxisLines, - topAxisLabels, - verticalGridLines, - scrubberLine, - scrubberHandle, - scrubberTimeLabel, - positionToTime - } = timeline; + const {state: interactionState, setState} = useTimelineInteractionState(); + const {timelineLayerRef, containerRef} = useTimelineRefs(); - // Mouse event handlers for scrubber dragging and panning - const handleMouseMove = useCallback( - (e: React.MouseEvent) => { - const rect = e.currentTarget.getBoundingClientRect(); - const mouseX = e.clientX - rect.left; - - if (isDraggingScrubber) { - e.preventDefault(); - e.stopPropagation(); - const newTime = positionToTime(mouseX); - demo.setCurrentTimeMs(newTime); - } else if (demo.isPanning) { - e.preventDefault(); - e.stopPropagation(); - demo.updatePan(mouseX); - } - }, - [isDraggingScrubber, demo.isPanning, positionToTime, demo] + useGlobalMouseUpCleanup( + setState.setIsDraggingScrubber, + setState.setIsPanning, + setState.setPanStartViewport ); - const handleMouseDown = useCallback( - (e: React.MouseEvent) => { - const rect = e.currentTarget.getBoundingClientRect(); - const mouseX = e.clientX - rect.left; - const mouseY = e.clientY - rect.top; - - // Check if clicking on scrubber handle (priority interaction) - const scrubberHandleLeft = timeline.scrubberPosition - 8; - const scrubberHandleRight = timeline.scrubberPosition + 8; - const scrubberHandleTop = demo.timelineConfig.timelineY - 45; - const scrubberHandleBottom = demo.timelineConfig.timelineY - 25; - - const isOnScrubberHandle = - mouseX >= scrubberHandleLeft && - mouseX <= scrubberHandleRight && - mouseY >= scrubberHandleTop && - mouseY <= scrubberHandleBottom; - - if (isOnScrubberHandle) { - e.preventDefault(); - e.stopPropagation(); - setIsDraggingScrubber(true); - return; - } - - // Check if clicking anywhere in the timeline area for seeking (but not on clips) - const timelineLeft = demo.timelineConfig.timelineX; - const timelineRight = demo.timelineConfig.timelineX + timeline.effectiveTimelineWidth; - const timelineTop = demo.timelineConfig.timelineY - 50; - const timelineBottom = demo.timelineConfig.timelineY + timeline.totalTimelineHeight + 30; + useWheelZoom(containerRef, timelineLayerRef, state.zoomLevel); + useTimelineResize(controls.setTimelineWidth); - const isInTimeline = - mouseX >= timelineLeft && - mouseX <= timelineRight && - mouseY >= timelineTop && - mouseY <= timelineBottom; + const timelineCallbacks = useTimelineCallbacks(controls); - if (isInTimeline) { - // Check if we're clicking within the timeline area for panning vs seeking - const timelineLeft = demo.timelineConfig.timelineX; - const timelineRight = demo.timelineConfig.timelineX + timeline.effectiveTimelineWidth; - - if (mouseX >= timelineLeft && mouseX <= timelineRight) { - // If zoomed in and in timeline area, start panning - if (demo.zoomLevel > 1.0) { - demo.startPan(mouseX); - return; - } - // Otherwise, seek to that position - const newTime = positionToTime(mouseX); - demo.setCurrentTimeMs(newTime); - } - return; - } - }, - [demo, timeline, positionToTime, setIsDraggingScrubber] + const containerHandlers = useContainerHandlers( + interactionState, + setState, + timelineLayerRef, + controls, + state ); - // Handle trackpad horizontal scrolling for scrubber - const handleWheel = useCallback( - (e: React.WheelEvent) => { - // Check if this is horizontal scrolling (trackpad two-finger drag) - if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) { - // Horizontal scroll - move scrubber - e.preventDefault(); - e.stopPropagation(); - const scrollSpeed = 50; // milliseconds per pixel - const timeChange = e.deltaX * scrollSpeed; - const newTime = demo.currentTimeMs + timeChange; - demo.setCurrentTimeMs(newTime); - } else { - // Vertical scroll - zoom timeline with mouse position awareness - e.preventDefault(); - const rect = e.currentTarget.getBoundingClientRect(); - const mouseX = e.clientX - rect.left; - const zoomFactor = e.deltaY > 0 ? 0.8 : 1.2; // Zoom in/out - demo.zoomToPoint(zoomFactor, mouseX); - } - }, - [demo] - ); - - const handleMouseUp = useCallback(() => { - setIsDraggingScrubber(false); - demo.endPan(); - }, [setIsDraggingScrubber, demo]); - - // Global mouse event handlers - useEffect(() => { - const handleGlobalMouseMove = (e: MouseEvent) => { - const timelineContainer = document.querySelector( - '[data-timeline-container="true"]' - ) as HTMLElement; - if (timelineContainer) { - const rect = timelineContainer.getBoundingClientRect(); - const mouseX = e.clientX - rect.left; - - if (isDraggingScrubber) { - const newTime = positionToTime(mouseX); - demo.setCurrentTimeMs(newTime); - } else if (demo.isPanning) { - demo.updatePan(mouseX); - } - } - }; + const deckGLHandlers = useDeckGLHandlers(setState, timelineLayerRef, controls, state); - const handleGlobalMouseUp = () => { - setIsDraggingScrubber(false); - demo.endPan(); - }; - - if (isDraggingScrubber || demo.isPanning) { - document.addEventListener('mousemove', handleGlobalMouseMove); - document.addEventListener('mouseup', handleGlobalMouseUp); - return () => { - document.removeEventListener('mousemove', handleGlobalMouseMove); - document.removeEventListener('mouseup', handleGlobalMouseUp); - }; - } - }, [isDraggingScrubber, demo.isPanning, setIsDraggingScrubber, positionToTime, demo, timeline]); - - const layers = [ - // Track backgrounds - new SolidPolygonLayer({ - id: 'track-backgrounds', - data: trackBackgrounds, - getPolygon: (d: any) => d.polygon, - getFillColor: (d: any) => d.color, - stroked: true, - getLineColor: [200, 200, 200, 255], - getLineWidth: 1 - }), + const getCursor = useCursorGetter(interactionState, state.zoomLevel); - // Timeline axis lines (top) - new LineLayer({ - id: 'timeline-axis-lines-top', - data: topAxisLines, - getSourcePosition: (d: any) => d.sourcePosition, - getTargetPosition: (d: any) => d.targetPosition, - getColor: [50, 50, 50, 255], - getWidth: 2 - }), - - // Timeline axis labels (top) - new TextLayer({ - id: 'timeline-axis-labels-top', - data: topAxisLabels, - getText: (d: any) => d.text, - getPosition: (d: any) => d.position, - getSize: (d: any) => d.size, - getColor: (d: any) => d.color, - getTextAnchor: (d: any) => d.textAnchor, - getAlignmentBaseline: (d: any) => d.alignmentBaseline, - fontFamily: 'Arial, sans-serif' - }), - - // Vertical grid lines - new LineLayer({ - id: 'vertical-grid', - data: verticalGridLines, - getSourcePosition: (d: any) => d.sourcePosition, - getTargetPosition: (d: any) => d.targetPosition, - getColor: [200, 200, 200, 128], - getWidth: 1 - }), + // Memoize viewport object + const viewport = useMemo( + () => ({startMs: state.viewportStartMs, endMs: state.viewportEndMs}), + [state.viewportStartMs, state.viewportEndMs] + ); - // Track labels - new TextLayer({ - id: 'track-labels', - data: trackLabels, - getText: (d: any) => d.text, - getPosition: (d: any) => d.position, - getSize: (d: any) => d.size, - getColor: (d: any) => d.color, - getTextAnchor: (d: any) => d.textAnchor, - getAlignmentBaseline: (d: any) => d.alignmentBaseline, - fontFamily: 'Arial, sans-serif', - fontWeight: 'bold' + // Memoize selectionStyle object + const selectionStyle = useMemo( + () => ({ + selectedClipColor: [255, 200, 0, 255] as [number, number, number, number], + hoveredClipColor: [200, 200, 200, 255] as [number, number, number, number], + selectedTrackColor: [80, 80, 80, 255] as [number, number, number, number], + hoveredTrackColor: [70, 70, 70, 255] as [number, number, number, number], + selectedLineWidth: state.selectedLineWidth, + hoveredLineWidth: state.hoveredLineWidth }), + [state.selectedLineWidth, state.hoveredLineWidth] + ); - // Clip rectangles - new SolidPolygonLayer({ - id: 'timeline-clips', - data: clipPolygons, - getPolygon: (d: any) => d.polygon, - getFillColor: (d: any) => d.color, - stroked: true, - getLineColor: [255, 255, 255, 200], - getLineWidth: 2, - pickable: true, // Re-enable picking for clips - onClick: (info: any) => { - if (info.object) { - setSelectedClip(info.object.clip); - } - }, - onHover: (info: any) => { - if (info.object) { - setHoveredClip(info.object.clip); - } else { - setHoveredClip(null); - } - } + const timelineLayerProps = useMemo( + () => ({ + id: 'timeline', + data: state.tracks, + timelineStart: state.timelineStart, + timelineEnd: state.timelineEnd, + x: state.timelineX, + y: state.timelineY, + width: state.timelineWidth, + trackHeight: state.trackHeight, + trackSpacing: state.trackSpacing, + currentTimeMs: state.currentTimeMs, + viewport, + timeFormatter: state.labelFormatter, + selectedClipId: state.selectedClip?.id, + hoveredClipId: state.hoveredClip?.id, + selectedTrackId: state.selectedTrack?.id, + hoveredTrackId: state.hoveredTrack?.id, + showTrackLabels: true, + showClipLabels: true, + showScrubber: true, + showAxis: true, + showSubtrackSeparators: state.showSubtrackSeparators, + selectionStyle, + onClipClick: timelineCallbacks.handleClipClick, + onClipHover: timelineCallbacks.handleClipHover, + onTrackClick: timelineCallbacks.handleTrackClick, + onTrackHover: timelineCallbacks.handleTrackHover, + onScrubberDrag: timelineCallbacks.handleScrubberDrag, + onTimelineClick: timelineCallbacks.handleTimelineClick, + onViewportChange: timelineCallbacks.handleViewportChange, + onZoomChange: timelineCallbacks.handleZoomChange }), + [ + state.tracks, + state.timelineStart, + state.timelineEnd, + state.timelineX, + state.timelineY, + state.timelineWidth, + state.trackHeight, + state.trackSpacing, + state.currentTimeMs, + viewport, + state.labelFormatter, + state.selectedClip?.id, + state.hoveredClip?.id, + state.selectedTrack?.id, + state.hoveredTrack?.id, + state.showSubtrackSeparators, + selectionStyle, + timelineCallbacks + ] + ); - // Clip labels - new TextLayer({ - id: 'clip-labels', - data: clipLabels, - getText: (d: any) => d.text, - getPosition: (d: any) => d.position, - getSize: (d: any) => d.size, - getColor: (d: any) => d.color, - getTextAnchor: (d: any) => d.textAnchor, - getAlignmentBaseline: (d: any) => d.alignmentBaseline, - fontFamily: 'Arial, sans-serif', - fontWeight: 'bold' - }), + const timelineLayer = useMemo( + () => new TimelineLayer(timelineLayerProps), + [timelineLayerProps] + ); - // Scrubber line - new LineLayer({ - id: 'scrubber-line', - data: scrubberLine, - getSourcePosition: (d: any) => d.sourcePosition, - getTargetPosition: (d: any) => d.targetPosition, - getColor: [255, 69, 0, 255], - getWidth: 3 - }), + // Update ref when layer changes + React.useEffect(() => { + timelineLayerRef.current = timelineLayer; + }, [timelineLayer]); - // Scrubber handle - new SolidPolygonLayer({ - id: 'scrubber-handle', - data: scrubberHandle, - getPolygon: (d: any) => d.polygon, - getFillColor: (d: any) => d.color, - stroked: true, - getLineColor: [255, 255, 255, 255], - getLineWidth: 2, - pickable: false // Handle via container events only - }), - - // Scrubber time label - new TextLayer({ - id: 'scrubber-time-label', - data: scrubberTimeLabel, - getText: (d: any) => d.text, - getPosition: (d: any) => d.position, - getSize: (d: any) => d.size, - getColor: (d: any) => d.color, - getTextAnchor: (d: any) => d.textAnchor, - getAlignmentBaseline: (d: any) => d.alignmentBaseline, - fontFamily: 'Arial, sans-serif', - fontWeight: 'bold' - }) - ]; + const layers = useMemo(() => [timelineLayer], [timelineLayer]); return ( -
-
-
-

Timeline Controls

-
- -
-
-

- 🚀 Performance Stats -

-
-
- Clips Rendered: {clipPolygons.length.toLocaleString()} -
-
- Labels Shown: {clipLabels.length.toLocaleString()} -
-
- Data Generation: {demo.renderTime.toFixed(2)}ms -
-
- Zoom Level: {demo.zoomLevel.toFixed(2)}x -
-
-
- -
-

- Timeline Configuration -

- -
- - demo.setTrackCount(Number(e.target.value))} - style={{width: '100%'}} - /> -
- -
- - -
- Performance test: Try 100k+ clips! -
-
- -
- - demo.setTimelineEnd(demo.timelineStart + Number(e.target.value))} - style={{width: '100%'}} - /> -
-
- -
-

- Timeline Axis Labels -

- -
- - -
-
- -
-

- Track Appearance -

- -
- - demo.setTrackHeight(Number(e.target.value))} - style={{width: '100%'}} - /> -
- -
- - demo.setTrackSpacing(Number(e.target.value))} - style={{width: '100%'}} - /> -
-
- -
-

- Position & Size -

- -
- - demo.setTimelineWidth(Math.max(400, Number(e.target.value)))} - style={{width: '100%'}} - /> -
- -
- - demo.setTimelineX(Number(e.target.value))} - style={{width: '100%'}} - /> -
- -
- - demo.setTimelineY(Number(e.target.value))} - style={{width: '100%'}} - /> -
-
- - {selectedClip && ( -
-

- Selected Clip -

-
-
- Label: {selectedClip.label} -
-
- Track: {selectedClip.trackId + 1} -
-
- Start: {(selectedClip.startMs / 1000).toFixed(2)}s -
-
- End: {(selectedClip.endMs / 1000).toFixed(2)}s -
-
- Duration:{' '} - {((selectedClip.endMs - selectedClip.startMs) / 1000).toFixed(2)}s -
-
- Color: - -
- -
-
- )} - -
-

- Timeline Scrubber -

- -
- - demo.setCurrentTimeMs(Number(e.target.value))} - style={{width: '100%'}} - /> -
- Click or drag on timeline to scrub -
-
-
- -
-

- Timeline Zoom -

- -
- - demo.setZoomLevel(Number(e.target.value))} - style={{width: '100%'}} - /> -
- Use mouse wheel over timeline for horizontal zoom -
-
-
- -
- Instructions: -
- • Use mouse wheel over timeline for horizontal zoom -
- • Click or drag anywhere on timeline to scrub/seek -
- • Click clips to select them -
- • Hover over clips to highlight them -
- • Pan by dragging the viewport -
• Selected clip info appears above -
-
-
+
+
diff --git a/examples/infovis-layers/timeline/demo-controls.tsx b/examples/infovis-layers/timeline/demo-controls.tsx new file mode 100644 index 000000000..cb3735c64 --- /dev/null +++ b/examples/infovis-layers/timeline/demo-controls.tsx @@ -0,0 +1,701 @@ +// deck.gl-community +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import React, {ReactElement, useMemo, useState} from 'react'; +import {timeAxisFormatters, TimelineTrack, TimelineClip} from './timeline-utils'; +import {generateRandomTracks} from './demo-utils'; +import type {SelectionStyle} from './timeline-layer'; + +const canvasWidth = (typeof window !== 'undefined' ? window.innerWidth : 1280) - 320; + +export interface TimelineControlsState { + // Data + tracks: TimelineTrack[]; + + // Timeline config + timelineStart: number; + timelineEnd: number; + currentTimeMs: number; + zoomLevel: number; + + // Layout + trackHeight: number; + trackSpacing: number; + timelineX: number; + timelineY: number; + timelineWidth: number; + + // Viewport + viewportStartMs?: number; + viewportEndMs?: number; + + // Formatter + labelFormatter: (timeMs: number) => string; + + // Selection + selectedClip: TimelineClip | null; + hoveredClip: TimelineClip | null; + selectedTrack: TimelineTrack | null; + hoveredTrack: TimelineTrack | null; + + // Advanced configuration + selectedLineWidth: number; + hoveredLineWidth: number; + showSubtrackSeparators: boolean; + trackLabelFontSize: number; + clipLabelFontSize: number; + + // Performance + renderTime: number; + visibleClipsCount: number; + visibleLabelsCount: number; +} + +export function useTimelineControls() { + // Timeline configuration + const [trackCount, setTrackCount] = useState(4); + const [clipsPerTrack, setClipsPerTrack] = useState(3); + const [timelineStart] = useState(0); + const [timelineEnd, setTimelineEnd] = useState(60000); + const [currentTimeMs, setCurrentTimeMs] = useState(0); + const [zoomLevel, setZoomLevel] = useState(1); + + // Layout configuration + const [trackHeight, setTrackHeight] = useState(40); + const [trackSpacing, setTrackSpacing] = useState(8); + const [timelineX, setTimelineX] = useState(150); + const [timelineY, setTimelineY] = useState(80); + const [timelineWidth, setTimelineWidth] = useState(canvasWidth - 200); + + // Viewport for zoom/pan + const [viewportStartMs, setViewportStartMs] = useState(undefined); + const [viewportEndMs, setViewportEndMs] = useState(undefined); + + // Interaction state + const [selectedClip, setSelectedClip] = useState(null); + const [hoveredClip, setHoveredClip] = useState(null); + const [selectedTrack, setSelectedTrack] = useState(null); + const [hoveredTrack, setHoveredTrack] = useState(null); + + // Label formatter + const [labelFormatterType, setLabelFormatterType] = useState('seconds'); + const labelFormatter = timeAxisFormatters[labelFormatterType]; + + // Advanced configuration + const [selectedLineWidth, setSelectedLineWidth] = useState(3); + const [hoveredLineWidth, setHoveredLineWidth] = useState(2); + const [showSubtrackSeparators, setShowSubtrackSeparators] = useState(true); + const [trackLabelFontSize, setTrackLabelFontSize] = useState(12); + const [clipLabelFontSize, setClipLabelFontSize] = useState(10); + + // Generate data + const [renderTime, setRenderTime] = useState(0); + const tracks = useMemo(() => { + const startTime = performance.now(); + const result = generateRandomTracks(trackCount, clipsPerTrack, timelineStart, timelineEnd); + const endTime = performance.now(); + setRenderTime(endTime - startTime); + return result; + }, [trackCount, clipsPerTrack, timelineStart, timelineEnd]); + + // Memoize visible clips calculation + const {visibleClipsCount, visibleLabelsCount} = useMemo(() => { + const effectiveStartMs = viewportStartMs ?? timelineStart; + const effectiveEndMs = viewportEndMs ?? timelineEnd; + + let count = 0; + tracks.forEach((track) => { + track.clips.forEach((clip) => { + if (clip.endMs > effectiveStartMs && clip.startMs < effectiveEndMs) { + count++; + } + }); + }); + + const labels = zoomLevel >= 1.5 ? Math.min(1000, count) : 0; + return {visibleClipsCount: count, visibleLabelsCount: labels}; + }, [tracks, viewportStartMs, viewportEndMs, timelineStart, timelineEnd, zoomLevel]); + + // Memoize state object + const state: TimelineControlsState = useMemo( + () => ({ + tracks, + timelineStart, + timelineEnd, + currentTimeMs, + zoomLevel, + trackHeight, + trackSpacing, + timelineX, + timelineY, + timelineWidth, + viewportStartMs, + viewportEndMs, + labelFormatter, + selectedClip, + hoveredClip, + selectedTrack, + hoveredTrack, + selectedLineWidth, + hoveredLineWidth, + showSubtrackSeparators, + trackLabelFontSize, + clipLabelFontSize, + renderTime, + visibleClipsCount, + visibleLabelsCount + }), + [ + tracks, + timelineStart, + timelineEnd, + currentTimeMs, + zoomLevel, + trackHeight, + trackSpacing, + timelineX, + timelineY, + timelineWidth, + viewportStartMs, + viewportEndMs, + labelFormatter, + selectedClip, + hoveredClip, + selectedTrack, + hoveredTrack, + selectedLineWidth, + hoveredLineWidth, + showSubtrackSeparators, + trackLabelFontSize, + clipLabelFontSize, + renderTime, + visibleClipsCount, + visibleLabelsCount + ] + ); + + // Memoize controls object + const controls = useMemo( + () => ({ + setTrackCount, + setClipsPerTrack, + setTimelineEnd, + setCurrentTimeMs, + setZoomLevel, + setTrackHeight, + setTrackSpacing, + setTimelineX, + setTimelineY, + setTimelineWidth, + setViewportStartMs, + setViewportEndMs, + setLabelFormatterType, + setSelectedClip, + setHoveredClip, + setSelectedTrack, + setHoveredTrack, + setSelectedLineWidth, + setHoveredLineWidth, + setShowSubtrackSeparators, + setTrackLabelFontSize, + setClipLabelFontSize + }), + [ + setTrackCount, + setClipsPerTrack, + setTimelineEnd, + setCurrentTimeMs, + setZoomLevel, + setTrackHeight, + setTrackSpacing, + setTimelineX, + setTimelineY, + setTimelineWidth, + setViewportStartMs, + setViewportEndMs, + setLabelFormatterType, + setSelectedClip, + setHoveredClip, + setSelectedTrack, + setHoveredTrack, + setSelectedLineWidth, + setHoveredLineWidth, + setShowSubtrackSeparators, + setTrackLabelFontSize, + setClipLabelFontSize + ] + ); + + return {state, controls, trackCount, clipsPerTrack, labelFormatterType}; +} + +export function TimelineControls({ + state, + controls, + trackCount, + clipsPerTrack, + labelFormatterType +}: { + state: TimelineControlsState; + controls: ReturnType['controls']; + trackCount: number; + clipsPerTrack: number; + labelFormatterType: string; +}): ReactElement { + return ( +
+
+

Timeline Controls

+
+ +
+
+

+ Performance Stats +

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

+ Timeline Configuration +

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

+ Timeline Axis Labels +

+ +
+ + +
+
+ +
+

+ Track Appearance +

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

+ Selected Clip +

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

+ Selected Track +

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

+ Timeline Scrubber +

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

+ Timeline Zoom +

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

Advanced Settings

+ {isExpanded ? 'â–¼' : 'â–¶'} +
+ + {isExpanded && ( +
+
+

+ Selection & Hover +

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

+ Subtrack Separators +

+ +
+ +
+

Track Labels

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

Clip Labels

+
+ + controls.setClipLabelFontSize(Number(e.target.value))} + style={{width: '100%'}} + /> +
+
+
+ )} +
+ ); +} diff --git a/examples/infovis-layers/timeline/demo-utils.ts b/examples/infovis-layers/timeline/demo-utils.ts new file mode 100644 index 000000000..cada22007 --- /dev/null +++ b/examples/infovis-layers/timeline/demo-utils.ts @@ -0,0 +1,68 @@ +// deck.gl-community +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import type {TimelineClip, TimelineTrack} from './timeline-utils'; + +/** + * Generate random tracks with clips for demo purposes + * This is a demo utility and should not be used in production + */ +export const generateRandomTracks = ( + trackCount: number, + clipsPerTrack: number, + timelineStart: number, + timelineEnd: number +): TimelineTrack[] => { + const tracks: TimelineTrack[] = []; + // More subtle, professional colors + const colors = [ + [80, 120, 160, 220], + [120, 160, 180, 220], + [100, 140, 120, 220], + [140, 120, 140, 220], + [160, 140, 100, 220], + [120, 100, 140, 220], + [100, 120, 120, 220], + [140, 120, 100, 220] + ] as [number, number, number, number][]; + + for (let trackIdx = 0; trackIdx < trackCount; trackIdx++) { + const clips: TimelineClip[] = []; + + for (let clipIdx = 0; clipIdx < clipsPerTrack; clipIdx++) { + const duration = Math.random() * (timelineEnd - timelineStart) * 0.1; // Max 10% of timeline + let startMs: number; + + // 30% chance of creating an overlapping clip (if not the first clip) + if (clipIdx > 0 && Math.random() < 0.3) { + const prevClip = clips[clips.length - 1]; + // Start somewhere in the middle of the previous clip + const overlapPoint = + prevClip.startMs + (prevClip.endMs - prevClip.startMs) * (0.3 + Math.random() * 0.4); + startMs = overlapPoint; + } else { + startMs = Math.random() * (timelineEnd - timelineStart - duration) + timelineStart; + } + + const endMs = startMs + duration; + + clips.push({ + id: `track-${trackIdx}-clip-${clipIdx}`, + startMs, + endMs, + label: `Clip ${clipIdx + 1}`, + color: colors[clipIdx % colors.length] + }); + } + + tracks.push({ + id: trackIdx, + name: `Track ${trackIdx + 1}`, + visible: true, + clips + }); + } + + return tracks; +}; diff --git a/examples/infovis-layers/timeline/index.html b/examples/infovis-layers/timeline/index.html index 6c5145301..f6dd66b79 100644 --- a/examples/infovis-layers/timeline/index.html +++ b/examples/infovis-layers/timeline/index.html @@ -1,7 +1,8 @@ + - Timeline Tracks + deck.gl-community/info-vis TimelineLayer