@deck.gl-community/timeline-layers 9.2.8 → 9.3.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/package.json +9 -9
  2. package/src/index.ts +42 -0
  3. package/src/layers/timeline-layer/timeline-collision.ts +51 -0
  4. package/src/layers/timeline-layer/timeline-layer.ts +868 -0
  5. package/src/layers/timeline-layer/timeline-layout.ts +80 -0
  6. package/src/layers/timeline-layer/timeline-types.ts +146 -0
  7. package/src/layers/timeline-layer/timeline-utils.ts +85 -0
  8. package/dist/index.cjs +0 -538
  9. package/dist/index.cjs.map +0 -7
  10. package/dist/index.d.ts +0 -10
  11. package/dist/index.d.ts.map +0 -1
  12. package/dist/index.js +0 -9
  13. package/dist/index.js.map +0 -1
  14. package/dist/layers/horizon-graph-layer/horizon-graph-layer-uniforms.d.ts +0 -23
  15. package/dist/layers/horizon-graph-layer/horizon-graph-layer-uniforms.d.ts.map +0 -1
  16. package/dist/layers/horizon-graph-layer/horizon-graph-layer-uniforms.js +0 -33
  17. package/dist/layers/horizon-graph-layer/horizon-graph-layer-uniforms.js.map +0 -1
  18. package/dist/layers/horizon-graph-layer/horizon-graph-layer.d.ts +0 -38
  19. package/dist/layers/horizon-graph-layer/horizon-graph-layer.d.ts.map +0 -1
  20. package/dist/layers/horizon-graph-layer/horizon-graph-layer.fs.d.ts +0 -3
  21. package/dist/layers/horizon-graph-layer/horizon-graph-layer.fs.d.ts.map +0 -1
  22. package/dist/layers/horizon-graph-layer/horizon-graph-layer.fs.js +0 -53
  23. package/dist/layers/horizon-graph-layer/horizon-graph-layer.fs.js.map +0 -1
  24. package/dist/layers/horizon-graph-layer/horizon-graph-layer.js +0 -138
  25. package/dist/layers/horizon-graph-layer/horizon-graph-layer.js.map +0 -1
  26. package/dist/layers/horizon-graph-layer/horizon-graph-layer.vs.d.ts +0 -3
  27. package/dist/layers/horizon-graph-layer/horizon-graph-layer.vs.d.ts.map +0 -1
  28. package/dist/layers/horizon-graph-layer/horizon-graph-layer.vs.js +0 -24
  29. package/dist/layers/horizon-graph-layer/horizon-graph-layer.vs.js.map +0 -1
  30. package/dist/layers/horizon-graph-layer/multi-horizon-graph-layer.d.ts +0 -23
  31. package/dist/layers/horizon-graph-layer/multi-horizon-graph-layer.d.ts.map +0 -1
  32. package/dist/layers/horizon-graph-layer/multi-horizon-graph-layer.js +0 -100
  33. package/dist/layers/horizon-graph-layer/multi-horizon-graph-layer.js.map +0 -1
  34. package/dist/layers/time-axis-layer.d.ts +0 -56
  35. package/dist/layers/time-axis-layer.d.ts.map +0 -1
  36. package/dist/layers/time-axis-layer.js +0 -78
  37. package/dist/layers/time-axis-layer.js.map +0 -1
  38. package/dist/layers/vertical-grid-layer.d.ts +0 -41
  39. package/dist/layers/vertical-grid-layer.d.ts.map +0 -1
  40. package/dist/layers/vertical-grid-layer.js +0 -43
  41. package/dist/layers/vertical-grid-layer.js.map +0 -1
  42. package/dist/utils/format-utils.d.ts +0 -7
  43. package/dist/utils/format-utils.d.ts.map +0 -1
  44. package/dist/utils/format-utils.js +0 -75
  45. package/dist/utils/format-utils.js.map +0 -1
  46. package/dist/utils/tick-utils.d.ts +0 -10
  47. package/dist/utils/tick-utils.d.ts.map +0 -1
  48. package/dist/utils/tick-utils.js +0 -32
  49. package/dist/utils/tick-utils.js.map +0 -1
@@ -0,0 +1,868 @@
1
+ // deck.gl-community
2
+ // SPDX-License-Identifier: MIT
3
+ // Copyright (c) vis.gl contributors
4
+
5
+ import {CompositeLayer, COORDINATE_SYSTEM, type PickingInfo, type Layer} from '@deck.gl/core';
6
+ import {SolidPolygonLayer, LineLayer, TextLayer} from '@deck.gl/layers';
7
+ import type {CompositeLayerProps} from '@deck.gl/core';
8
+ import type {LineLayerProps, SolidPolygonLayerProps, TextLayerProps} from '@deck.gl/layers';
9
+
10
+ import type {
11
+ TimelineClipInfo,
12
+ TimelineTrackInfo,
13
+ TimelineTrack,
14
+ TrackWithSubtracks,
15
+ TrackPosition,
16
+ TrackBackgroundData,
17
+ TrackLabelData,
18
+ ClipPolygonData,
19
+ ClipLabelData,
20
+ ClipWithSubtrack,
21
+ SeparatorLineData,
22
+ AxisLineData,
23
+ AxisLabelData,
24
+ ScrubberLineData,
25
+ ScrubberHandleData,
26
+ ScrubberLabelData,
27
+ TimeAxisLabelFormatter
28
+ } from './timeline-types';
29
+
30
+ import type {SelectionStyle} from './timeline-layout';
31
+
32
+ import {
33
+ timeAxisFormatters,
34
+ generateTimelineTicks,
35
+ timeToPosition,
36
+ positionToTime
37
+ } from './timeline-utils';
38
+ import {assignClipsToSubtracks, calculateSubtrackCount} from './timeline-collision';
39
+
40
+ function lightenColor(
41
+ color: [number, number, number, number],
42
+ amount: number = 30
43
+ ): [number, number, number, number] {
44
+ return [
45
+ Math.min(255, color[0] + amount),
46
+ Math.min(255, color[1] + amount),
47
+ Math.min(255, color[2] + amount),
48
+ color[3]
49
+ ];
50
+ }
51
+
52
+ const defaultProps = {
53
+ x: 150,
54
+ y: 100,
55
+ width: 800,
56
+ trackHeight: 40,
57
+ trackSpacing: 10,
58
+ currentTimeMs: 0,
59
+ showScrubber: true,
60
+ showClipLabels: true,
61
+ showTrackLabels: true,
62
+ showAxis: true,
63
+ showSubtrackSeparators: true,
64
+ timeFormatter: timeAxisFormatters.seconds,
65
+ selectionStyle: {
66
+ selectedClipColor: [255, 200, 0, 255] as [number, number, number, number],
67
+ hoveredClipColor: [200, 200, 200, 255] as [number, number, number, number],
68
+ selectedTrackColor: [80, 80, 80, 255] as [number, number, number, number],
69
+ hoveredTrackColor: [70, 70, 70, 255] as [number, number, number, number],
70
+ selectedLineWidth: 3,
71
+ hoveredLineWidth: 2
72
+ }
73
+ };
74
+
75
+ export type TimelineLayerProps = CompositeLayerProps & {
76
+ /** Array of timeline tracks, each containing clips */
77
+ data: TimelineTrack[];
78
+ /** Start of the full timeline range in milliseconds */
79
+ timelineStart: number;
80
+ /** End of the full timeline range in milliseconds */
81
+ timelineEnd: number;
82
+
83
+ /** X offset of the timeline in canvas coordinates */
84
+ x?: number;
85
+ /** Y offset of the timeline in canvas coordinates */
86
+ y?: number;
87
+ /** Width of the timeline in canvas coordinates */
88
+ width?: number;
89
+ /** Height of each track row in canvas coordinates */
90
+ trackHeight?: number;
91
+ /** Spacing between tracks in canvas coordinates */
92
+ trackSpacing?: number;
93
+
94
+ /** Current playhead time in milliseconds */
95
+ currentTimeMs?: number;
96
+ /** Optional zoomed viewport range */
97
+ viewport?: {startMs?: number; endMs?: number};
98
+ /** Formatter for time axis labels */
99
+ timeFormatter?: TimeAxisLabelFormatter;
100
+
101
+ /** ID of the currently selected clip */
102
+ selectedClipId?: string | number | null;
103
+ /** ID of the currently hovered clip */
104
+ hoveredClipId?: string | number | null;
105
+ /** ID of the currently selected track */
106
+ selectedTrackId?: string | number | null;
107
+ /** ID of the currently hovered track */
108
+ hoveredTrackId?: string | number | null;
109
+ /** Colors and line widths for selected/hovered states */
110
+ selectionStyle?: SelectionStyle;
111
+
112
+ /** Whether to show the playhead scrubber */
113
+ showScrubber?: boolean;
114
+ /** Whether to show labels on clips */
115
+ showClipLabels?: boolean;
116
+ /** Whether to show labels on tracks */
117
+ showTrackLabels?: boolean;
118
+ /** Whether to show the time axis */
119
+ showAxis?: boolean;
120
+ /** Whether to show separators between collision subtracks */
121
+ showSubtrackSeparators?: boolean;
122
+
123
+ /** Override props for the clip polygon sub-layer */
124
+ clipProps?: Partial<SolidPolygonLayerProps<ClipPolygonData>>;
125
+ /** Override props for the track background sub-layer */
126
+ trackProps?: Partial<SolidPolygonLayerProps<TrackBackgroundData>>;
127
+ /** Override props for the track label sub-layer */
128
+ trackLabelProps?: Partial<TextLayerProps<TrackLabelData>>;
129
+ /** Override props for the clip label sub-layer */
130
+ clipLabelProps?: Partial<TextLayerProps<ClipLabelData>>;
131
+ /** Override props for the axis line sub-layer */
132
+ axisLineProps?: Partial<LineLayerProps<AxisLineData>>;
133
+ /** Override props for the axis label sub-layer */
134
+ axisLabelProps?: Partial<TextLayerProps<AxisLabelData>>;
135
+ /** Override props for the scrubber line sub-layer */
136
+ scrubberLineProps?: Partial<LineLayerProps<ScrubberLineData>>;
137
+
138
+ /** Callback when a clip is clicked */
139
+ onClipClick?: (info: TimelineClipInfo, event: PickingInfo) => void;
140
+ /** Callback when a clip is hovered */
141
+ onClipHover?: (info: TimelineClipInfo | null, event: PickingInfo) => void;
142
+ /** Callback when a track is clicked */
143
+ onTrackClick?: (info: TimelineTrackInfo, event: PickingInfo) => void;
144
+ /** Callback when a track is hovered */
145
+ onTrackHover?: (info: TimelineTrackInfo | null, event: PickingInfo) => void;
146
+ /** Callback when the scrubber handle is hovered */
147
+ onScrubberHover?: (isHovering: boolean, event: PickingInfo) => void;
148
+ /** Callback when a scrubber drag begins */
149
+ onScrubberDragStart?: (event: PickingInfo) => void;
150
+ /** Callback when the scrubber is dragged to a new time */
151
+ onScrubberDrag?: (timeMs: number, event: PickingInfo) => void;
152
+ /** Callback when the timeline background is clicked */
153
+ onTimelineClick?: (timeMs: number, event: PickingInfo) => void;
154
+
155
+ /** Callback when the current time changes */
156
+ onCurrentTimeChange?: (timeMs: number) => void;
157
+ /** Callback when the viewport (zoom/pan) changes */
158
+ onViewportChange?: (startMs: number, endMs: number) => void;
159
+ /** Callback when the zoom level changes */
160
+ onZoomChange?: (zoomLevel: number) => void;
161
+ };
162
+
163
+ export class TimelineLayer extends CompositeLayer<TimelineLayerProps> {
164
+ static layerName = 'TimelineLayer';
165
+ static defaultProps = defaultProps;
166
+
167
+ /** Convert a canvas X coordinate to a time in milliseconds */
168
+ getTimeFromPosition(x: number): number {
169
+ const {timelineStart, timelineEnd, viewport, x: timelineX = 150, width = 800} = this.props;
170
+ const effectiveStartMs = viewport?.startMs ?? timelineStart;
171
+ const effectiveEndMs = viewport?.endMs ?? timelineEnd;
172
+ return positionToTime(x, timelineX, width, effectiveStartMs, effectiveEndMs);
173
+ }
174
+
175
+ /** Zoom the timeline viewport around a canvas X coordinate */
176
+ zoomToPoint(zoomFactor: number, mouseX: number, currentZoomLevel: number): void {
177
+ const {
178
+ timelineStart,
179
+ timelineEnd,
180
+ viewport,
181
+ x: timelineX = 150,
182
+ width = 800,
183
+ onViewportChange,
184
+ onZoomChange
185
+ } = this.props;
186
+
187
+ const newZoomLevel = Math.max(1.0, Math.min(100, currentZoomLevel * zoomFactor));
188
+ const mouseRatio = Math.max(0, Math.min(1, (mouseX - timelineX) / width));
189
+ const currentStartMs = viewport?.startMs ?? timelineStart;
190
+ const currentEndMs = viewport?.endMs ?? timelineEnd;
191
+ const mouseTimeMs = currentStartMs + mouseRatio * (currentEndMs - currentStartMs);
192
+
193
+ const fullTimeRange = timelineEnd - timelineStart;
194
+ const newViewportRange = fullTimeRange / newZoomLevel;
195
+
196
+ let newStartMs = mouseTimeMs - mouseRatio * newViewportRange;
197
+ let newEndMs = newStartMs + newViewportRange;
198
+
199
+ if (newStartMs < timelineStart) {
200
+ newStartMs = timelineStart;
201
+ newEndMs = timelineStart + newViewportRange;
202
+ } else if (newEndMs > timelineEnd) {
203
+ newEndMs = timelineEnd;
204
+ newStartMs = timelineEnd - newViewportRange;
205
+ }
206
+
207
+ if (newZoomLevel > 1.0) {
208
+ onViewportChange?.(newStartMs, newEndMs);
209
+ } else {
210
+ onViewportChange?.(timelineStart, timelineEnd);
211
+ }
212
+
213
+ onZoomChange?.(newZoomLevel);
214
+ }
215
+
216
+ // ===== LAYOUT CALCULATION =====
217
+
218
+ private _calculateTrackPositions(
219
+ tracksWithSubtracks: TrackWithSubtracks[],
220
+ y: number,
221
+ trackHeight: number,
222
+ trackSpacing: number
223
+ ): {trackPositions: TrackPosition[]; totalTimelineHeight: number} {
224
+ let currentY = y;
225
+ const trackPositions: TrackPosition[] = [];
226
+ const subtrackSpacing = 2;
227
+
228
+ for (const {subtrackCount} of tracksWithSubtracks) {
229
+ const trackTotalHeight = subtrackCount * trackHeight + (subtrackCount - 1) * subtrackSpacing;
230
+ trackPositions.push({y: currentY, height: trackTotalHeight, subtrackCount});
231
+ currentY += trackTotalHeight + trackSpacing;
232
+ }
233
+
234
+ const totalTimelineHeight = currentY - y - trackSpacing;
235
+ return {trackPositions, totalTimelineHeight};
236
+ }
237
+
238
+ // ===== DATA GENERATION =====
239
+
240
+ private _generateTrackBackgrounds(
241
+ tracksWithSubtracks: TrackWithSubtracks[],
242
+ trackPositions: TrackPosition[]
243
+ ): TrackBackgroundData[] {
244
+ const {
245
+ x = 150,
246
+ width = 800,
247
+ selectedTrackId,
248
+ hoveredTrackId,
249
+ selectionStyle = defaultProps.selectionStyle
250
+ } = this.props;
251
+
252
+ return tracksWithSubtracks.map(({track, trackIndex}, i) => {
253
+ const {y: trackY, height} = trackPositions[i];
254
+ const isSelected = selectedTrackId === track.id;
255
+ const isHovered = hoveredTrackId === track.id;
256
+
257
+ let color: [number, number, number, number] = [60, 60, 60, 255];
258
+ if (isSelected) {
259
+ color = selectionStyle.selectedTrackColor!;
260
+ } else if (isHovered) {
261
+ color = lightenColor([60, 60, 60, 255], 20);
262
+ }
263
+
264
+ return {
265
+ id: `track-bg-${track.id}`,
266
+ track,
267
+ trackIndex,
268
+ polygon: [
269
+ [x, trackY],
270
+ [x + width, trackY],
271
+ [x + width, trackY + height],
272
+ [x, trackY + height]
273
+ ],
274
+ color
275
+ };
276
+ });
277
+ }
278
+
279
+ private _generateTrackLabels(
280
+ tracksWithSubtracks: TrackWithSubtracks[],
281
+ trackPositions: TrackPosition[]
282
+ ): TrackLabelData[] {
283
+ const {x = 150, showTrackLabels = true} = this.props;
284
+ if (!showTrackLabels) return [];
285
+
286
+ return tracksWithSubtracks.map(({track}, i) => {
287
+ const label = track.name || `Track ${track.id}`;
288
+ const {y: trackY, height} = trackPositions[i];
289
+ return {text: label, position: [x - 10, trackY + height / 2, 0]};
290
+ });
291
+ }
292
+
293
+ private _buildClipPolygon(
294
+ clip: ClipWithSubtrack,
295
+ opts: {
296
+ track: TimelineTrack;
297
+ trackIndex: number;
298
+ clipIndex: number;
299
+ subtrackHeight: number;
300
+ baseTrackY: number;
301
+ x: number;
302
+ width: number;
303
+ effectiveStartMs: number;
304
+ effectiveEndMs: number;
305
+ selectedClipId: string | number | null | undefined;
306
+ hoveredClipId: string | number | null | undefined;
307
+ selectionStyle: SelectionStyle;
308
+ }
309
+ ): ClipPolygonData | null {
310
+ const {id: clipId, startMs, endMs, subtrackIndex = 0} = clip;
311
+ const {
312
+ track,
313
+ trackIndex,
314
+ clipIndex,
315
+ subtrackHeight,
316
+ baseTrackY,
317
+ x,
318
+ width,
319
+ effectiveStartMs,
320
+ effectiveEndMs,
321
+ selectedClipId,
322
+ hoveredClipId,
323
+ selectionStyle
324
+ } = opts;
325
+
326
+ if (endMs <= effectiveStartMs || startMs >= effectiveEndMs) return null;
327
+
328
+ const clipPadding = 2;
329
+ const subtrackSpacing = 2;
330
+ const clipTrackY = baseTrackY + subtrackIndex * (subtrackHeight + subtrackSpacing);
331
+ const clipStartRatio = (startMs - effectiveStartMs) / (effectiveEndMs - effectiveStartMs);
332
+ const clipEndRatio = (endMs - effectiveStartMs) / (effectiveEndMs - effectiveStartMs);
333
+ const clipStartX = x + Math.max(0, clipStartRatio) * width;
334
+ const clipEndX = x + Math.min(1, clipEndRatio) * width;
335
+
336
+ const baseColor = clip.color || ([80, 120, 160, 220] as [number, number, number, number]);
337
+ const isSelected = selectedClipId !== null && String(selectedClipId) === String(clipId);
338
+ const isHovered = hoveredClipId !== null && String(hoveredClipId) === String(clipId);
339
+
340
+ let color = baseColor;
341
+ if (isSelected) {
342
+ color = selectionStyle.selectedClipColor!;
343
+ } else if (isHovered) {
344
+ color = lightenColor(baseColor, 40);
345
+ }
346
+
347
+ return {
348
+ id: clipId,
349
+ clip,
350
+ track,
351
+ clipIndex,
352
+ trackIndex,
353
+ subtrackIndex,
354
+ polygon: [
355
+ [clipStartX, clipTrackY + clipPadding],
356
+ [clipEndX, clipTrackY + clipPadding],
357
+ [clipEndX, clipTrackY + subtrackHeight - clipPadding],
358
+ [clipStartX, clipTrackY + subtrackHeight - clipPadding]
359
+ ],
360
+ color,
361
+ label: clip.label || '',
362
+ labelPosition: [clipStartX + (clipEndX - clipStartX) / 2, clipTrackY + subtrackHeight / 2, 0]
363
+ };
364
+ }
365
+
366
+ private _generateClipPolygons(
367
+ tracksWithSubtracks: TrackWithSubtracks[],
368
+ trackPositions: TrackPosition[],
369
+ effectiveStartMs: number,
370
+ effectiveEndMs: number
371
+ ): ClipPolygonData[] {
372
+ const {
373
+ x = 150,
374
+ width = 800,
375
+ selectedClipId,
376
+ hoveredClipId,
377
+ selectionStyle = defaultProps.selectionStyle
378
+ } = this.props;
379
+
380
+ const subtrackSpacing = 2;
381
+ const clipPolygons: ClipPolygonData[] = [];
382
+
383
+ for (let i = 0; i < tracksWithSubtracks.length; i++) {
384
+ const {track, trackIndex, clips, subtrackCount} = tracksWithSubtracks[i];
385
+ const {y: baseTrackY, height: trackTotalHeight} = trackPositions[i];
386
+ const subtrackHeight =
387
+ (trackTotalHeight - (subtrackCount - 1) * subtrackSpacing) / subtrackCount;
388
+
389
+ for (let clipIndex = 0; clipIndex < clips.length; clipIndex++) {
390
+ const polygon = this._buildClipPolygon(clips[clipIndex], {
391
+ track,
392
+ trackIndex,
393
+ clipIndex,
394
+ subtrackHeight,
395
+ baseTrackY,
396
+ x,
397
+ width,
398
+ effectiveStartMs,
399
+ effectiveEndMs,
400
+ selectedClipId,
401
+ hoveredClipId,
402
+ selectionStyle
403
+ });
404
+ if (polygon) {
405
+ clipPolygons.push(polygon);
406
+ }
407
+ }
408
+ }
409
+
410
+ return clipPolygons;
411
+ }
412
+
413
+ private _generateSubtrackSeparators(
414
+ tracksWithSubtracks: TrackWithSubtracks[],
415
+ trackPositions: TrackPosition[]
416
+ ): SeparatorLineData[] {
417
+ const {x = 150, width = 800, showSubtrackSeparators = true} = this.props;
418
+ if (!showSubtrackSeparators) return [];
419
+
420
+ const subtrackSpacing = 2;
421
+ const separatorLines: SeparatorLineData[] = [];
422
+
423
+ for (let i = 0; i < tracksWithSubtracks.length; i++) {
424
+ const {subtrackCount} = tracksWithSubtracks[i];
425
+ if (subtrackCount <= 1) {
426
+ // No separators needed for single-subtrack rows
427
+ } else {
428
+ const {y: baseTrackY, height: trackTotalHeight} = trackPositions[i];
429
+ const subtrackHeight =
430
+ (trackTotalHeight - (subtrackCount - 1) * subtrackSpacing) / subtrackCount;
431
+
432
+ for (let j = 1; j < subtrackCount; j++) {
433
+ const separatorY =
434
+ baseTrackY + j * (subtrackHeight + subtrackSpacing) - subtrackSpacing / 2;
435
+ separatorLines.push({
436
+ sourcePosition: [x, separatorY],
437
+ targetPosition: [x + width, separatorY]
438
+ });
439
+ }
440
+ }
441
+ }
442
+
443
+ return separatorLines;
444
+ }
445
+
446
+ private _generateAxis(
447
+ totalTimelineHeight: number,
448
+ effectiveStartMs: number,
449
+ effectiveEndMs: number
450
+ ): {axisLines: AxisLineData[]; axisLabels: AxisLabelData[]} {
451
+ const {
452
+ x = 150,
453
+ y = 100,
454
+ width = 800,
455
+ showAxis = true,
456
+ timeFormatter = timeAxisFormatters.seconds
457
+ } = this.props;
458
+
459
+ const axisLines: AxisLineData[] = [];
460
+ const axisLabels: AxisLabelData[] = [];
461
+
462
+ if (!showAxis) return {axisLines, axisLabels};
463
+
464
+ const axisHeight = 30;
465
+ const tickCount = Math.max(4, Math.min(10, Math.floor(width / 80)));
466
+
467
+ const timelineTicks = generateTimelineTicks({
468
+ startMs: effectiveStartMs,
469
+ endMs: effectiveEndMs,
470
+ timelineX: x,
471
+ timelineWidth: width,
472
+ tickCount,
473
+ formatter: timeFormatter
474
+ });
475
+
476
+ const axisY = y + totalTimelineHeight + axisHeight;
477
+
478
+ axisLines.push({sourcePosition: [x, axisY], targetPosition: [x + width, axisY]});
479
+
480
+ for (const tick of timelineTicks) {
481
+ axisLines.push({
482
+ sourcePosition: [tick.position, axisY - 5],
483
+ targetPosition: [tick.position, axisY + 5]
484
+ });
485
+ axisLabels.push({text: tick.label, position: [tick.position, axisY + 15, 0]});
486
+ }
487
+
488
+ return {axisLines, axisLabels};
489
+ }
490
+
491
+ private _generateScrubber(
492
+ totalTimelineHeight: number,
493
+ effectiveStartMs: number,
494
+ effectiveEndMs: number
495
+ ): {
496
+ scrubberLine: ScrubberLineData[];
497
+ scrubberHandle: ScrubberHandleData[];
498
+ scrubberLabel: ScrubberLabelData[];
499
+ } {
500
+ const {
501
+ x = 150,
502
+ y = 100,
503
+ width = 800,
504
+ showScrubber = true,
505
+ currentTimeMs = 0,
506
+ timeFormatter = timeAxisFormatters.seconds
507
+ } = this.props;
508
+
509
+ if (!showScrubber) return {scrubberLine: [], scrubberHandle: [], scrubberLabel: []};
510
+
511
+ const scrubberPosition = timeToPosition(
512
+ currentTimeMs,
513
+ x,
514
+ width,
515
+ effectiveStartMs,
516
+ effectiveEndMs
517
+ );
518
+
519
+ const scrubberLine: ScrubberLineData[] = [
520
+ {
521
+ sourcePosition: [scrubberPosition, y - 30],
522
+ targetPosition: [scrubberPosition, y + totalTimelineHeight + 30]
523
+ }
524
+ ];
525
+
526
+ const scrubberHandle: ScrubberHandleData[] = [
527
+ {
528
+ id: 'scrubber-handle',
529
+ polygon: [
530
+ [scrubberPosition - 8, y - 35],
531
+ [scrubberPosition + 8, y - 35],
532
+ [scrubberPosition + 8, y - 20],
533
+ [scrubberPosition - 8, y - 20]
534
+ ],
535
+ color: [255, 100, 100, 255]
536
+ }
537
+ ];
538
+
539
+ const scrubberLabel: ScrubberLabelData[] = [
540
+ {text: timeFormatter(currentTimeMs), position: [scrubberPosition, y - 40, 0]}
541
+ ];
542
+
543
+ return {scrubberLine, scrubberHandle, scrubberLabel};
544
+ }
545
+
546
+ // ===== LAYER CREATION =====
547
+
548
+ private _createTrackLayers(
549
+ trackBackgrounds: TrackBackgroundData[],
550
+ trackLabels: TrackLabelData[]
551
+ ): Layer[] {
552
+ const {trackProps, showTrackLabels = true, onTrackClick, onTrackHover} = this.props;
553
+ const layers: Layer[] = [];
554
+
555
+ layers.push(
556
+ new SolidPolygonLayer(
557
+ this.getSubLayerProps({
558
+ ...trackProps,
559
+ id: 'tracks',
560
+ data: trackBackgrounds,
561
+ getPolygon: (d: TrackBackgroundData) => d.polygon,
562
+ getFillColor: (d: TrackBackgroundData) => d.color,
563
+ stroked: true,
564
+ getLineColor: [100, 100, 100, 255],
565
+ getLineWidth: 1,
566
+ pickable: Boolean(onTrackClick) || Boolean(onTrackHover),
567
+ onClick: (info: PickingInfo) => {
568
+ if (info.object && onTrackClick) {
569
+ const obj = info.object as TrackBackgroundData;
570
+ onTrackClick({track: obj.track, index: obj.trackIndex}, info);
571
+ }
572
+ },
573
+ onHover: (info: PickingInfo) => {
574
+ if (onTrackHover) {
575
+ const obj = info.object as TrackBackgroundData | undefined;
576
+ onTrackHover(obj ? {track: obj.track, index: obj.trackIndex} : null, info);
577
+ }
578
+ }
579
+ })
580
+ )
581
+ );
582
+
583
+ if (showTrackLabels) {
584
+ layers.push(
585
+ new TextLayer({
586
+ id: `${this.props.id}-track-labels`,
587
+ data: trackLabels,
588
+ getText: (d: TrackLabelData) => d.text,
589
+ getPosition: (d: TrackLabelData) => d.position,
590
+ getSize: 12,
591
+ getColor: [60, 60, 60, 255],
592
+ getTextAnchor: 'end',
593
+ getAlignmentBaseline: 'center',
594
+ fontFamily: 'Arial, sans-serif',
595
+ fontWeight: 'bold',
596
+ coordinateSystem: COORDINATE_SYSTEM.CARTESIAN
597
+ })
598
+ );
599
+ }
600
+
601
+ return layers;
602
+ }
603
+
604
+ private _createClipLayers(
605
+ clipPolygons: ClipPolygonData[],
606
+ clipLabelsData: ClipLabelData[]
607
+ ): Layer[] {
608
+ const {
609
+ clipProps,
610
+ showClipLabels = true,
611
+ selectedClipId,
612
+ selectionStyle = defaultProps.selectionStyle,
613
+ onClipClick,
614
+ onClipHover
615
+ } = this.props;
616
+ const layers: Layer[] = [];
617
+
618
+ layers.push(
619
+ new SolidPolygonLayer(
620
+ this.getSubLayerProps({
621
+ ...clipProps,
622
+ id: 'clips',
623
+ data: clipPolygons,
624
+ getPolygon: (d: ClipPolygonData) => d.polygon,
625
+ getFillColor: (d: ClipPolygonData) => d.color,
626
+ stroked: true,
627
+ getLineColor: [255, 255, 255, 200],
628
+ getLineWidth: (d: ClipPolygonData) => {
629
+ const isSelected = selectedClipId !== null && String(selectedClipId) === String(d.id);
630
+ return isSelected ? selectionStyle.selectedLineWidth || 3 : 2;
631
+ },
632
+ pickable: Boolean(onClipClick) || Boolean(onClipHover),
633
+ autoHighlight: true,
634
+ onClick: (info: PickingInfo) => {
635
+ if (info.object && onClipClick) {
636
+ const obj = info.object as ClipPolygonData;
637
+ onClipClick(
638
+ {
639
+ clip: obj.clip,
640
+ track: obj.track,
641
+ clipIndex: obj.clipIndex,
642
+ trackIndex: obj.trackIndex,
643
+ subtrackIndex: obj.subtrackIndex
644
+ },
645
+ info
646
+ );
647
+ }
648
+ },
649
+ onHover: (info: PickingInfo) => {
650
+ if (onClipHover) {
651
+ const obj = info.object as ClipPolygonData | undefined;
652
+ onClipHover(
653
+ obj
654
+ ? {
655
+ clip: obj.clip,
656
+ track: obj.track,
657
+ clipIndex: obj.clipIndex,
658
+ trackIndex: obj.trackIndex,
659
+ subtrackIndex: obj.subtrackIndex
660
+ }
661
+ : null,
662
+ info
663
+ );
664
+ }
665
+ }
666
+ })
667
+ )
668
+ );
669
+
670
+ if (showClipLabels) {
671
+ layers.push(
672
+ new TextLayer({
673
+ id: `${this.props.id}-clip-labels`,
674
+ data: clipLabelsData,
675
+ getText: (d: ClipLabelData) => d.text,
676
+ getPosition: (d: ClipLabelData) => d.position,
677
+ getSize: 10,
678
+ getColor: [255, 255, 255, 255],
679
+ getTextAnchor: 'middle',
680
+ getAlignmentBaseline: 'center',
681
+ fontFamily: 'Arial, sans-serif',
682
+ coordinateSystem: COORDINATE_SYSTEM.CARTESIAN
683
+ })
684
+ );
685
+ }
686
+
687
+ return layers;
688
+ }
689
+
690
+ private _createSubtrackSeparatorLayer(separators: SeparatorLineData[]): Layer | null {
691
+ const {showSubtrackSeparators = true} = this.props;
692
+ if (!showSubtrackSeparators) return null;
693
+
694
+ return new LineLayer({
695
+ id: `${this.props.id}-subtrack-separators`,
696
+ data: separators,
697
+ getSourcePosition: (d: SeparatorLineData) => d.sourcePosition,
698
+ getTargetPosition: (d: SeparatorLineData) => d.targetPosition,
699
+ getColor: [180, 180, 180, 128],
700
+ getWidth: 1,
701
+ coordinateSystem: COORDINATE_SYSTEM.CARTESIAN
702
+ });
703
+ }
704
+
705
+ private _createAxisLayers(axisLines: AxisLineData[], axisLabels: AxisLabelData[]): Layer[] {
706
+ const {axisLineProps, axisLabelProps, showAxis = true} = this.props;
707
+ if (!showAxis) return [];
708
+
709
+ return [
710
+ new LineLayer(
711
+ this.getSubLayerProps({
712
+ ...axisLineProps,
713
+ id: 'axis-lines',
714
+ data: axisLines,
715
+ getSourcePosition: (d: AxisLineData) => d.sourcePosition,
716
+ getTargetPosition: (d: AxisLineData) => d.targetPosition,
717
+ getColor: [150, 150, 150, 255],
718
+ getWidth: 2
719
+ })
720
+ ),
721
+ new TextLayer(
722
+ this.getSubLayerProps({
723
+ ...axisLabelProps,
724
+ id: 'axis-labels',
725
+ data: axisLabels,
726
+ getText: (d: AxisLabelData) => d.text,
727
+ getPosition: (d: AxisLabelData) => d.position,
728
+ getSize: 11,
729
+ getColor: [150, 150, 150, 255],
730
+ getTextAnchor: 'middle',
731
+ getAlignmentBaseline: 'top'
732
+ })
733
+ )
734
+ ];
735
+ }
736
+
737
+ private _createScrubberLayers(
738
+ scrubberLine: ScrubberLineData[],
739
+ scrubberHandle: ScrubberHandleData[],
740
+ scrubberLabel: ScrubberLabelData[]
741
+ ): Layer[] {
742
+ const {
743
+ scrubberLineProps,
744
+ showScrubber = true,
745
+ onScrubberDragStart,
746
+ onScrubberHover
747
+ } = this.props;
748
+ if (!showScrubber) return [];
749
+
750
+ return [
751
+ new LineLayer(
752
+ this.getSubLayerProps({
753
+ ...scrubberLineProps,
754
+ id: 'scrubber-line',
755
+ data: scrubberLine,
756
+ getSourcePosition: (d: ScrubberLineData) => d.sourcePosition,
757
+ getTargetPosition: (d: ScrubberLineData) => d.targetPosition,
758
+ getColor: [255, 100, 100, 255],
759
+ getWidth: 2
760
+ })
761
+ ),
762
+ new SolidPolygonLayer(
763
+ this.getSubLayerProps({
764
+ id: 'scrubber-handle',
765
+ data: scrubberHandle,
766
+ getPolygon: (d: ScrubberHandleData) => d.polygon,
767
+ getFillColor: (d: ScrubberHandleData) => d.color,
768
+ stroked: true,
769
+ getLineColor: [255, 255, 255, 255],
770
+ getLineWidth: 2,
771
+ pickable: true,
772
+ onClick: (info: PickingInfo) => {
773
+ if (onScrubberDragStart && info.object) {
774
+ onScrubberDragStart(info);
775
+ }
776
+ },
777
+ onHover: (info: PickingInfo) => {
778
+ if (onScrubberHover) {
779
+ onScrubberHover(Boolean(info.object), info);
780
+ }
781
+ }
782
+ })
783
+ ),
784
+ new TextLayer(
785
+ this.getSubLayerProps({
786
+ id: 'scrubber-label',
787
+ data: scrubberLabel,
788
+ getText: (d: ScrubberLabelData) => d.text,
789
+ getPosition: (d: ScrubberLabelData) => d.position,
790
+ getSize: 11,
791
+ getColor: [255, 100, 100, 255],
792
+ getTextAnchor: 'middle',
793
+ getAlignmentBaseline: 'bottom'
794
+ })
795
+ )
796
+ ];
797
+ }
798
+
799
+ // ===== MAIN RENDER =====
800
+
801
+ renderLayers(): Layer[] {
802
+ const {
803
+ data: tracks,
804
+ timelineStart,
805
+ timelineEnd,
806
+ viewport,
807
+ y = 100,
808
+ trackHeight = 40,
809
+ trackSpacing = 10
810
+ } = this.props;
811
+
812
+ const effectiveStartMs = viewport?.startMs ?? timelineStart;
813
+ const effectiveEndMs = viewport?.endMs ?? timelineEnd;
814
+
815
+ const visibleTracks = tracks.filter((track) => track.visible !== false);
816
+
817
+ const tracksWithSubtracks: TrackWithSubtracks[] = visibleTracks.map((track, trackIndex) => {
818
+ const clips = track.clips || [];
819
+ const clipsWithSubtracks = assignClipsToSubtracks(clips);
820
+ const subtrackCount = Math.max(1, calculateSubtrackCount(clips));
821
+ return {track, trackIndex, clips: clipsWithSubtracks, subtrackCount};
822
+ });
823
+
824
+ const {trackPositions, totalTimelineHeight} = this._calculateTrackPositions(
825
+ tracksWithSubtracks,
826
+ y,
827
+ trackHeight,
828
+ trackSpacing
829
+ );
830
+
831
+ const trackBackgrounds = this._generateTrackBackgrounds(tracksWithSubtracks, trackPositions);
832
+ const trackLabels = this._generateTrackLabels(tracksWithSubtracks, trackPositions);
833
+ const clipPolygons = this._generateClipPolygons(
834
+ tracksWithSubtracks,
835
+ trackPositions,
836
+ effectiveStartMs,
837
+ effectiveEndMs
838
+ );
839
+ const clipLabelsData = clipPolygons.map((clip) => ({
840
+ text: clip.label,
841
+ position: clip.labelPosition
842
+ }));
843
+ const subtrackSeparators = this._generateSubtrackSeparators(
844
+ tracksWithSubtracks,
845
+ trackPositions
846
+ );
847
+ const {axisLines, axisLabels} = this._generateAxis(
848
+ totalTimelineHeight,
849
+ effectiveStartMs,
850
+ effectiveEndMs
851
+ );
852
+ const {scrubberLine, scrubberHandle, scrubberLabel} = this._generateScrubber(
853
+ totalTimelineHeight,
854
+ effectiveStartMs,
855
+ effectiveEndMs
856
+ );
857
+
858
+ const layers = [
859
+ ...this._createTrackLayers(trackBackgrounds, trackLabels),
860
+ ...this._createClipLayers(clipPolygons, clipLabelsData),
861
+ this._createSubtrackSeparatorLayer(subtrackSeparators),
862
+ ...this._createAxisLayers(axisLines, axisLabels),
863
+ ...this._createScrubberLayers(scrubberLine, scrubberHandle, scrubberLabel)
864
+ ].filter((layer): layer is Layer => layer !== null);
865
+
866
+ return layers;
867
+ }
868
+ }