Line data Source code
1 : import * as React from 'react';
2 : import * as d3 from 'd3';
3 :
4 1 : const BRUSH_SETTLE_DELAY_MS = 120;
5 :
6 1 : function hideBrushSelection(container: SVGGElement): void {
7 1 : d3.select(container)
8 1 : .selectAll<SVGRectElement, unknown>('.selection, .handle')
9 1 : .style('display', 'none')
10 1 : .attr('x', 0)
11 1 : .attr('y', 0)
12 1 : .attr('width', 0)
13 1 : .attr('height', 0);
14 1 : }
15 :
16 1 : export class SettledBrushUpdates<T> {
17 1 : private timeout: number | null = null;
18 1 : private pending: T | null = null;
19 :
20 1 : public schedule(event: T, callback: (event: T) => void): void {
21 1 : this.pending = event;
22 1 : if (this.timeout != null) {
23 1 : window.clearTimeout(this.timeout);
24 : }
25 1 : this.timeout = window.setTimeout(() => {
26 1 : this.timeout = null;
27 1 : const pending = this.pending;
28 1 : this.pending = null;
29 1 : if (pending != null) {
30 1 : callback(pending);
31 : }
32 1 : }, BRUSH_SETTLE_DELAY_MS);
33 1 : }
34 :
35 1 : public flush(event: T, callback: (event: T) => void): void {
36 1 : this.cancel();
37 1 : callback(event);
38 1 : }
39 :
40 1 : public cancel(): void {
41 1 : if (this.timeout != null) {
42 1 : window.clearTimeout(this.timeout);
43 1 : this.timeout = null;
44 1 : }
45 1 : this.pending = null;
46 1 : }
47 1 : }
48 :
49 : interface HistogramBrushOptions {
50 : xScale: d3.ScaleBand<string>;
51 : height: number;
52 : selection: [number, number] | null;
53 : onBrushUpdate: (event: d3.D3BrushEvent<unknown>) => void;
54 : onClear: () => void;
55 : onBrushingChange?: (isBrushing: boolean) => void;
56 : }
57 :
58 1 : export function useHistogramBrush(options: HistogramBrushOptions): {
59 : brushContainer: React.RefObject<SVGGElement | null>;
60 : clearBrush: () => void;
61 1 : } {
62 1 : const brushContainer = React.useRef<SVGGElement>(null);
63 1 : const brushBehavior = React.useRef<d3.BrushBehavior<unknown> | null>(null);
64 1 : const syncingSelection = React.useRef(false);
65 1 : const settledBrushUpdates = React.useRef(new SettledBrushUpdates<d3.D3BrushEvent<unknown>>());
66 :
67 1 : const onBrushUpdateRef = React.useRef(options.onBrushUpdate);
68 1 : onBrushUpdateRef.current = options.onBrushUpdate;
69 :
70 1 : const onBrushStart = React.useCallback(() => {
71 1 : if (syncingSelection.current) {
72 1 : return;
73 : }
74 1 : settledBrushUpdates.current.cancel();
75 1 : options.onBrushingChange?.(true);
76 1 : }, [options.onBrushingChange]);
77 :
78 1 : const onBrushMove = React.useCallback((event: d3.D3BrushEvent<unknown>) => {
79 1 : if (syncingSelection.current) {
80 1 : return;
81 : }
82 1 : settledBrushUpdates.current.schedule(event, pending => onBrushUpdateRef.current(pending));
83 1 : }, []);
84 :
85 1 : const onBrushEnd = React.useCallback((event: d3.D3BrushEvent<unknown>) => {
86 1 : if (syncingSelection.current) {
87 1 : return;
88 : }
89 1 : settledBrushUpdates.current.flush(event, options.onBrushUpdate);
90 1 : options.onBrushingChange?.(false);
91 1 : }, [options.onBrushUpdate, options.onBrushingChange]);
92 :
93 1 : const onBrushStartRef = React.useRef(onBrushStart);
94 1 : const onBrushMoveRef = React.useRef(onBrushMove);
95 1 : const onBrushEndRef = React.useRef(onBrushEnd);
96 1 : onBrushStartRef.current = onBrushStart;
97 1 : onBrushMoveRef.current = onBrushMove;
98 1 : onBrushEndRef.current = onBrushEnd;
99 :
100 1 : React.useEffect(() => () => settledBrushUpdates.current.cancel(), []);
101 :
102 1 : React.useLayoutEffect(() => {
103 1 : const brush = d3.brushX()
104 1 : .extent([
105 1 : [options.xScale.range()[0], 0],
106 1 : [options.xScale.range()[1], options.height]
107 1 : ])
108 1 : .on('start', () => onBrushStartRef.current())
109 1 : .on('brush', event => onBrushMoveRef.current(event))
110 1 : .on('end', event => onBrushEndRef.current(event));
111 1 : brushBehavior.current = brush;
112 :
113 1 : d3.select(brushContainer.current!)
114 1 : .selectChildren()
115 1 : .remove();
116 1 : d3.select(brushContainer.current!)
117 1 : .call(brush)
118 1 : .selectAll('rect')
119 1 : .attr('y', 0)
120 1 : .attr('height', options.height);
121 1 : if (options.selection == null) {
122 1 : hideBrushSelection(brushContainer.current!);
123 : }
124 1 : return () => {
125 1 : brushBehavior.current = null;
126 1 : };
127 1 : }, [options.xScale, options.height]);
128 :
129 1 : React.useLayoutEffect(() => {
130 1 : const container = brushContainer.current;
131 1 : const brush = brushBehavior.current;
132 1 : if (container == null || brush == null) {
133 0 : return;
134 : }
135 1 : const current = d3.brushSelection(container) as [number, number] | null;
136 1 : const next = options.selection;
137 1 : if (current == null ? next == null : next != null && current[0] === next[0] && current[1] === next[1]) {
138 1 : return;
139 : }
140 1 : settledBrushUpdates.current.cancel();
141 1 : syncingSelection.current = true;
142 1 : try {
143 1 : d3.select(container).call(brush.move, next);
144 1 : if (next == null) {
145 1 : hideBrushSelection(container);
146 : }
147 1 : } finally {
148 1 : syncingSelection.current = false;
149 1 : }
150 1 : }, [options.selection, options.xScale, options.height]);
151 :
152 1 : const clearBrush = React.useCallback(() => {
153 1 : const container = brushContainer.current;
154 1 : const brush = brushBehavior.current;
155 1 : if (container != null && brush != null) {
156 1 : d3.select(container).call(brush.move, null);
157 1 : hideBrushSelection(container);
158 1 : return;
159 1 : }
160 0 : options.onClear();
161 1 : }, [options.onClear]);
162 :
163 1 : return { brushContainer, clearBrush };
164 1 : }
|