@@ -4,6 +4,9 @@ import { LabelSize, TimelineMarker } from "../../types/markers";
4
4
import { CanvasApi } from "../../CanvasApi" ;
5
5
import { TimelineEvent } from "../../types" ;
6
6
import { DefaultMarkerRenderer } from "./DefaultMarkerRenderer" ;
7
+ import RBush , { BBox } from "rbush" ;
8
+
9
+ const MAX_INDEX_TREE_WIDTH = 16 ;
7
10
8
11
/**
9
12
* Handles rendering timeline markers on the canvas
@@ -13,13 +16,18 @@ export class Markers<TEvent extends TimelineEvent = TimelineEvent>
13
16
implements BaseComponentInterface
14
17
{
15
18
protected api : CanvasApi < TEvent > ;
16
- protected sortedMarkers : TimelineMarker [ ] = [ ] ;
19
+ protected _sortedMarkers : TimelineMarker [ ] = [ ] ;
20
+ protected index = new RBush < BBox & { marker : TimelineMarker } > (
21
+ MAX_INDEX_TREE_WIDTH ,
22
+ ) ;
17
23
// Tracks last rendered label positions to prevent overlapping
18
24
protected lastRenderedLabelPosition = { top : Infinity , bottom : Infinity } ;
19
25
private textWidthCache = new Map < string , LabelSize > ( ) ;
26
+ private _selectedMarkers = new Set < number > ( ) ;
20
27
21
28
constructor ( api : CanvasApi < TEvent > ) {
22
29
this . api = api ;
30
+ this . addEventListeners ( ) ;
23
31
}
24
32
25
33
/**
@@ -28,23 +36,49 @@ export class Markers<TEvent extends TimelineEvent = TimelineEvent>
28
36
*/
29
37
public setMarkers ( markers : TimelineMarker [ ] ) {
30
38
// Sort markers by time for efficient rendering
31
- this . sortedMarkers = markers . slice ( ) . sort ( ( a , b ) => a . time - b . time ) ;
39
+ this . _sortedMarkers = markers . slice ( ) . sort ( ( a , b ) => a . time - b . time ) ;
40
+ this . rebuildIndex ( ) ;
32
41
this . render ( ) ;
33
42
}
34
43
44
+ public getMarkersAt ( rect : DOMRect ) : TimelineMarker [ ] {
45
+ const {
46
+ markers : { hitboxPadding } ,
47
+ } = this . api . getViewConfiguration ( ) ;
48
+
49
+ const markers = this . index . search ( {
50
+ minX : this . api . positionToTime ( rect . left - hitboxPadding ) ,
51
+ maxX : this . api . positionToTime ( rect . right + hitboxPadding ) ,
52
+ minY : 0 ,
53
+ maxY : this . api . ctx . canvas . height ,
54
+ } ) ;
55
+ return markers
56
+ . filter ( ( box ) => ! box . marker . nonSelectable )
57
+ . map ( ( box ) => box . marker ) ;
58
+ }
59
+
60
+ public getMarkersAtPoint ( x : number , y : number ) {
61
+ const p = 6 ;
62
+ return this . getMarkersAt ( new DOMRect ( x - p / 2 , y - p / 2 , p , p ) ) ;
63
+ }
64
+
65
+ public isSelectedMarker ( time : number ) {
66
+ return this . _selectedMarkers . has ( time ) ;
67
+ }
68
+
35
69
/**
36
70
* Renders all visible markers within the current viewport
37
71
*/
38
72
public render ( ) {
39
73
this . api . useStaticTransform ( ) ;
40
- // Reset label positions for new render pass
74
+ // Reset label positions for a new render pass
41
75
this . lastRenderedLabelPosition = { top : Infinity , bottom : Infinity } ;
42
76
43
77
const { start, end } = this . api . getInterval ( ) ;
44
78
45
79
const visibleMarkers : TimelineMarker [ ] = [ ] ;
46
- for ( let i = 0 ; i < this . sortedMarkers . length ; i += 1 ) {
47
- const marker = this . sortedMarkers [ i ] ;
80
+ for ( let i = 0 ; i < this . _sortedMarkers . length ; i += 1 ) {
81
+ const marker = this . _sortedMarkers [ i ] ;
48
82
const overscan = marker . label
49
83
? this . api . widthToTime ( this . getLabelSize ( marker . label ) . width )
50
84
: 0 ;
@@ -63,7 +97,7 @@ export class Markers<TEvent extends TimelineEvent = TimelineEvent>
63
97
renderer . render ( {
64
98
ctx : this . api . ctx ,
65
99
marker,
66
- isSelected : false ,
100
+ isSelected : this . isSelectedMarker ( marker . time ) ,
67
101
markerPosition : this . api . timeToPosition ( marker . time ) ,
68
102
viewConfiguration : this . api . getViewConfiguration ( ) ,
69
103
lastRenderedLabelPosition : this . lastRenderedLabelPosition ,
@@ -73,6 +107,10 @@ export class Markers<TEvent extends TimelineEvent = TimelineEvent>
73
107
}
74
108
}
75
109
110
+ public destroy ( ) {
111
+ this . api . canvas . removeEventListener ( "mouseup" , this . handleCanvasMouseup ) ;
112
+ }
113
+
76
114
protected getLabelSize ( text : string ) : LabelSize {
77
115
if ( this . textWidthCache . has ( text ) ) {
78
116
return this . textWidthCache . get ( text ) ;
@@ -90,6 +128,49 @@ export class Markers<TEvent extends TimelineEvent = TimelineEvent>
90
128
return result ;
91
129
}
92
130
131
+ protected handleCanvasMouseup = ( event : MouseEvent ) => {
132
+ const candidates = this . getMarkersAtPoint ( event . offsetX , event . offsetY ) ;
133
+
134
+ const times = candidates . map ( ( marker ) => marker . time ) ;
135
+ const arraysAreEqual =
136
+ times . length === this . _selectedMarkers . size &&
137
+ times . every ( ( num ) => this . _selectedMarkers . has ( num ) ) ;
138
+
139
+ if ( arraysAreEqual ) return ;
140
+
141
+ if ( candidates . length ) {
142
+ this . _selectedMarkers = new Set ( times ) ;
143
+ } else {
144
+ this . _selectedMarkers . clear ( ) ;
145
+ }
146
+
147
+ this . api . emit ( "on-marker-select-change" , {
148
+ markers : candidates ,
149
+ time : this . api . positionToTime ( event . offsetX ) ,
150
+ relativeX : event . clientX ,
151
+ relativeY : event . clientY ,
152
+ } ) ;
153
+ this . api . rerender ( ) ;
154
+ } ;
155
+
156
+ protected addEventListeners ( ) {
157
+ this . api . canvas . addEventListener ( "mouseup" , this . handleCanvasMouseup ) ;
158
+ }
159
+
160
+ protected rebuildIndex ( ) : void {
161
+ const boxes = this . _sortedMarkers . map (
162
+ ( marker ) : BBox & { marker : TimelineMarker } => {
163
+ const minX = marker . time ;
164
+ const maxX = marker . time ;
165
+ const minY = 0 ;
166
+ const maxY = this . api . ctx . canvas . height ;
167
+ return { minX, maxX, minY, maxY, marker } ;
168
+ } ,
169
+ ) ;
170
+ this . index . clear ( ) ;
171
+ this . index . load ( boxes ) ;
172
+ }
173
+
93
174
/**
94
175
* Collapses groups of similar markers that are closer than or equal to
95
176
* `viewConfiguration.markers.collapseMinDistance` pixels in the current zoom level.
0 commit comments