Skip to content

Commit 7a8326f

Browse files
committed
feat(Markers): add select change event
1 parent 72839db commit 7a8326f

File tree

6 files changed

+100
-8
lines changed

6 files changed

+100
-8
lines changed

src/components/Markers/Markers.ts

Lines changed: 87 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ import { LabelSize, TimelineMarker } from "../../types/markers";
44
import { CanvasApi } from "../../CanvasApi";
55
import { TimelineEvent } from "../../types";
66
import { DefaultMarkerRenderer } from "./DefaultMarkerRenderer";
7+
import RBush, { BBox } from "rbush";
8+
9+
const MAX_INDEX_TREE_WIDTH = 16;
710

811
/**
912
* Handles rendering timeline markers on the canvas
@@ -13,13 +16,18 @@ export class Markers<TEvent extends TimelineEvent = TimelineEvent>
1316
implements BaseComponentInterface
1417
{
1518
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+
);
1723
// Tracks last rendered label positions to prevent overlapping
1824
protected lastRenderedLabelPosition = { top: Infinity, bottom: Infinity };
1925
private textWidthCache = new Map<string, LabelSize>();
26+
private _selectedMarkers = new Set<number>();
2027

2128
constructor(api: CanvasApi<TEvent>) {
2229
this.api = api;
30+
this.addEventListeners();
2331
}
2432

2533
/**
@@ -28,23 +36,49 @@ export class Markers<TEvent extends TimelineEvent = TimelineEvent>
2836
*/
2937
public setMarkers(markers: TimelineMarker[]) {
3038
// 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();
3241
this.render();
3342
}
3443

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+
3569
/**
3670
* Renders all visible markers within the current viewport
3771
*/
3872
public render() {
3973
this.api.useStaticTransform();
40-
// Reset label positions for new render pass
74+
// Reset label positions for a new render pass
4175
this.lastRenderedLabelPosition = { top: Infinity, bottom: Infinity };
4276

4377
const { start, end } = this.api.getInterval();
4478

4579
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];
4882
const overscan = marker.label
4983
? this.api.widthToTime(this.getLabelSize(marker.label).width)
5084
: 0;
@@ -63,7 +97,7 @@ export class Markers<TEvent extends TimelineEvent = TimelineEvent>
6397
renderer.render({
6498
ctx: this.api.ctx,
6599
marker,
66-
isSelected: false,
100+
isSelected: this.isSelectedMarker(marker.time),
67101
markerPosition: this.api.timeToPosition(marker.time),
68102
viewConfiguration: this.api.getViewConfiguration(),
69103
lastRenderedLabelPosition: this.lastRenderedLabelPosition,
@@ -73,6 +107,10 @@ export class Markers<TEvent extends TimelineEvent = TimelineEvent>
73107
}
74108
}
75109

110+
public destroy() {
111+
this.api.canvas.removeEventListener("mouseup", this.handleCanvasMouseup);
112+
}
113+
76114
protected getLabelSize(text: string): LabelSize {
77115
if (this.textWidthCache.has(text)) {
78116
return this.textWidthCache.get(text);
@@ -90,6 +128,49 @@ export class Markers<TEvent extends TimelineEvent = TimelineEvent>
90128
return result;
91129
}
92130

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+
93174
/**
94175
* Collapses groups of similar markers that are closer than or equal to
95176
* `viewConfiguration.markers.collapseMinDistance` pixels in the current zoom level.

src/constants/options.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ export const defaultViewConfig: ViewConfigurationDefault = {
4646
},
4747
markers: {
4848
font: DEFAULT_FONT,
49-
collapseMinDistance: 2,
49+
hitboxPadding: 2,
50+
collapseMinDistance: 4,
5051
},
5152
};

src/stories/StoryWrapper.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,10 @@ export const StoryWrapper: React.FC<TimelineStoryProps> = (props) => {
7171
action("on-camera-change")(data);
7272
});
7373

74+
useTimelineEvent(timeline, "on-marker-select-change", (data) => {
75+
action("on-marker-select-change")(data);
76+
});
77+
7478
return (
7579
<div
7680
style={{

src/types/configuration.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,12 +41,13 @@ export type AxesViewOptions = {
4141
};
4242

4343
export type EventsViewOptions = {
44-
hitboxPadding?: number;
4544
font?: string;
45+
hitboxPadding?: number;
4646
};
4747

4848
export type MarkerViewOptions = {
4949
font?: string;
50+
hitboxPadding?: number;
5051
collapseMinDistance?: number;
5152
};
5253

src/types/events.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { AbstractEventRenderer } from "../components/Events";
2+
import { TimelineMarker } from "./markers";
23

34
export type TimelineEvent = {
45
id: string;
@@ -37,6 +38,7 @@ export type LeaveEvent<TEvent extends TimelineEvent = TimelineEvent> = {
3738
events: TEvent[];
3839
};
3940
export type CameraEvent = { from: number; to: number };
41+
export type MarkerSelectEvent = { markers: TimelineMarker[] } & BaseEventData;
4042

4143
export type ApiEvent<TEvent extends TimelineEvent = TimelineEvent> = {
4244
"on-click": (event: CustomEvent<ClickEvent<TEvent>>) => void;
@@ -45,6 +47,7 @@ export type ApiEvent<TEvent extends TimelineEvent = TimelineEvent> = {
4547
"on-hover": (events: CustomEvent<HoverEvent<TEvent>>) => void;
4648
"on-leave": (events: CustomEvent<LeaveEvent<TEvent>>) => void;
4749
"on-camera-change": (event: CustomEvent<CameraEvent>) => void;
50+
"on-marker-select-change": (markers: CustomEvent<MarkerSelectEvent>) => void;
4851
};
4952

5053
export type UnwrapTimelineEvents<

src/types/markers.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@ import { AbstractMarkerRenderer } from "../components/Markers/AbstractMarkerRend
33
export type TimelineMarker = {
44
time: number;
55
color: string;
6+
activeColor?: string;
67
lineWidth?: number;
78
label?: string;
89
labelColor?: string;
910
renderer?: AbstractMarkerRenderer;
11+
nonSelectable?: boolean;
1012
};
1113

1214
export type LabelSize = { height: number; width: number };

0 commit comments

Comments
 (0)