@carto/ps-react-ui 4.6.3 → 4.7.0
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.
- package/dist/{download-config-C3I0jWIL.js → download-config-DNLkypdN.js} +8 -7
- package/dist/{download-config-C3I0jWIL.js.map → download-config-DNLkypdN.js.map} +1 -1
- package/dist/shared-resize-observer-98b1SK1e.js +17 -0
- package/dist/shared-resize-observer-98b1SK1e.js.map +1 -0
- package/dist/types/widgets/actions/brush-toggle/brush-overlay.d.ts +24 -0
- package/dist/types/widgets/actions/brush-toggle/brush-toggle.d.ts +15 -10
- package/dist/types/widgets/actions/brush-toggle/hit-test.d.ts +19 -0
- package/dist/types/widgets/actions/brush-toggle/hit-test.test.d.ts +1 -0
- package/dist/types/widgets/actions/brush-toggle/style.d.ts +8 -0
- package/dist/types/widgets/actions/brush-toggle/types.d.ts +35 -1
- package/dist/widgets/actions.js +985 -772
- package/dist/widgets/actions.js.map +1 -1
- package/dist/widgets/bar.js +1 -1
- package/dist/widgets/category.js +9 -8
- package/dist/widgets/category.js.map +1 -1
- package/dist/widgets/echart.js +79 -91
- package/dist/widgets/echart.js.map +1 -1
- package/dist/widgets/formula.js +43 -42
- package/dist/widgets/formula.js.map +1 -1
- package/dist/widgets/histogram.js +7 -6
- package/dist/widgets/histogram.js.map +1 -1
- package/dist/widgets/markdown.js +15 -14
- package/dist/widgets/markdown.js.map +1 -1
- package/dist/widgets/pie.js +1 -1
- package/dist/widgets/scatterplot.js +1 -1
- package/dist/widgets/spread.js +47 -46
- package/dist/widgets/spread.js.map +1 -1
- package/dist/widgets/table.js +17 -16
- package/dist/widgets/table.js.map +1 -1
- package/dist/widgets/timeseries.js +1 -1
- package/dist/widgets/utils.js +1 -1
- package/package.json +3 -1
- package/src/widgets/actions/brush-toggle/brush-overlay.tsx +386 -0
- package/src/widgets/actions/brush-toggle/brush-toggle.tsx +88 -152
- package/src/widgets/actions/brush-toggle/hit-test.test.ts +65 -0
- package/src/widgets/actions/brush-toggle/hit-test.ts +45 -0
- package/src/widgets/actions/brush-toggle/style.ts +32 -0
- package/src/widgets/actions/brush-toggle/types.ts +36 -1
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useCallback,
|
|
3
|
+
useEffect,
|
|
4
|
+
useRef,
|
|
5
|
+
useState,
|
|
6
|
+
useSyncExternalStore,
|
|
7
|
+
} from 'react'
|
|
8
|
+
import type { CSSProperties, PointerEvent as ReactPointerEvent } from 'react'
|
|
9
|
+
import { createPortal } from 'react-dom'
|
|
10
|
+
import type { ECharts } from 'echarts'
|
|
11
|
+
import { useWidgetStore, widgetStoreActions } from '../../stores/widget-store'
|
|
12
|
+
import { useWidgetSelector } from '../../stores/use-widget-selector'
|
|
13
|
+
import type { EchartWidgetState } from '../../echart/types'
|
|
14
|
+
import { observeResize } from '../../echart/shared-resize-observer'
|
|
15
|
+
import { hitTestRects } from './hit-test'
|
|
16
|
+
import type { BrushRect, BrushState } from './types'
|
|
17
|
+
import { overlayStyles } from './style'
|
|
18
|
+
|
|
19
|
+
interface BrushOverlayProps {
|
|
20
|
+
id: string
|
|
21
|
+
multiBrush?: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface PixelRect {
|
|
25
|
+
left: number
|
|
26
|
+
right: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface PlotRect {
|
|
30
|
+
x: number
|
|
31
|
+
y: number
|
|
32
|
+
width: number
|
|
33
|
+
height: number
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Shape of the internal ECharts API we reach into to get the grid rectangle.
|
|
37
|
+
// It's undocumented but stable across v5/v6; falling back gracefully if
|
|
38
|
+
// ECharts ever changes the shape is handled by a try/catch at the call site.
|
|
39
|
+
interface EChartsGridInternals {
|
|
40
|
+
getModel: () => {
|
|
41
|
+
getComponent: (
|
|
42
|
+
type: string,
|
|
43
|
+
index?: number,
|
|
44
|
+
) => { coordinateSystem?: { getRect: () => PlotRect } } | null
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Clicks (tiny drags) are ignored — distinguishes intent to select vs. misclick.
|
|
49
|
+
const MIN_DRAG_PX = 2
|
|
50
|
+
|
|
51
|
+
// Stable empty-array references. Returning `[]` from the Zustand selector
|
|
52
|
+
// on every call breaks shallow-equality memoization (each `[]` is a new
|
|
53
|
+
// reference) — which ripples into `rects` being a fresh reference on every
|
|
54
|
+
// render, which re-runs the re-projection effect, which `setProjectedRects`,
|
|
55
|
+
// which re-renders, producing an infinite loop.
|
|
56
|
+
const EMPTY_RECTS: readonly BrushRect[] = Object.freeze([])
|
|
57
|
+
const EMPTY_PIXEL_RECTS: readonly PixelRect[] = Object.freeze([])
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Reads the plot-area (grid) rectangle from an ECharts instance. Uses the
|
|
61
|
+
* internal model API because ECharts doesn't expose this publicly; returns
|
|
62
|
+
* `null` if the grid is missing or the API shape has drifted.
|
|
63
|
+
*/
|
|
64
|
+
function readPlotRect(instance: ECharts): PlotRect | null {
|
|
65
|
+
try {
|
|
66
|
+
const model = (instance as unknown as EChartsGridInternals).getModel()
|
|
67
|
+
const grid = model.getComponent('grid', 0)
|
|
68
|
+
const rect = grid?.coordinateSystem?.getRect()
|
|
69
|
+
if (!rect) return null
|
|
70
|
+
if (
|
|
71
|
+
!Number.isFinite(rect.x) ||
|
|
72
|
+
!Number.isFinite(rect.y) ||
|
|
73
|
+
!Number.isFinite(rect.width) ||
|
|
74
|
+
!Number.isFinite(rect.height)
|
|
75
|
+
) {
|
|
76
|
+
return null
|
|
77
|
+
}
|
|
78
|
+
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }
|
|
79
|
+
} catch {
|
|
80
|
+
return null
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function plotRectEquals(a: PlotRect | null, b: PlotRect | null): boolean {
|
|
85
|
+
if (a === b) return true
|
|
86
|
+
if (!a || !b) return false
|
|
87
|
+
return (
|
|
88
|
+
a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Portaled overlay rendered inside the ECharts chart `<div>`. Handles brush
|
|
94
|
+
* drawing entirely in DOM, independent of ECharts' built-in brush.
|
|
95
|
+
*
|
|
96
|
+
* Rectangles are stored in x-axis data coords (category indices) in the
|
|
97
|
+
* widget store. The overlay re-projects them to pixel coords on every chart
|
|
98
|
+
* `finished` event and container resize, so `setOption({ notMerge: true })`
|
|
99
|
+
* by the config pipeline is a non-event.
|
|
100
|
+
*
|
|
101
|
+
* Pointer capture and rectangle rendering are both clamped to the ECharts
|
|
102
|
+
* grid (plot area), so the legend, axis labels, and other chart chrome
|
|
103
|
+
* receive their usual events and aren't visually covered.
|
|
104
|
+
*
|
|
105
|
+
* Hit-testing happens on `pointerup` using `hitTestRects` against the widget's
|
|
106
|
+
* data length (derived from `widget.data`). The result is written to
|
|
107
|
+
* `brushSelection` in the store; `BrushToggle` subscribes to that and invokes
|
|
108
|
+
* the consumer's `onBrushSelected` callback.
|
|
109
|
+
*/
|
|
110
|
+
export function BrushOverlay({ id, multiBrush }: BrushOverlayProps) {
|
|
111
|
+
const { enabled, rects, dataLength } = useWidgetSelector(id, (w) => {
|
|
112
|
+
const brushState = w as BrushState | undefined
|
|
113
|
+
// Bar and histogram both expose data as a nested array of series;
|
|
114
|
+
// all series share the same category count.
|
|
115
|
+
const widgetData = w?.data as unknown[][] | undefined
|
|
116
|
+
const firstSeries = widgetData?.[0]
|
|
117
|
+
const length = Array.isArray(firstSeries) ? firstSeries.length : 0
|
|
118
|
+
return {
|
|
119
|
+
enabled: brushState?.brush ?? false,
|
|
120
|
+
rects: brushState?.brushRects ?? (EMPTY_RECTS as BrushRect[]),
|
|
121
|
+
dataLength: length,
|
|
122
|
+
}
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
// Surface the chart DOM element and ECharts instance via `useSyncExternalStore`.
|
|
126
|
+
// Reading `ref.current` inside a `getSnapshot` callback is idiomatic and
|
|
127
|
+
// satisfies React Compiler's "don't read refs during render" guardrail.
|
|
128
|
+
// The store notifies us when EchartUI registers the refs via `setWidget`;
|
|
129
|
+
// `getSnapshot` then resolves the current values.
|
|
130
|
+
const subscribe = useCallback(
|
|
131
|
+
(notify: () => void) => useWidgetStore.subscribe(notify),
|
|
132
|
+
[],
|
|
133
|
+
)
|
|
134
|
+
const chartEl = useSyncExternalStore(
|
|
135
|
+
subscribe,
|
|
136
|
+
useCallback((): HTMLElement | null => {
|
|
137
|
+
return (
|
|
138
|
+
widgetStoreActions.getWidget<EchartWidgetState>(id)?.refUI?.current ??
|
|
139
|
+
null
|
|
140
|
+
)
|
|
141
|
+
}, [id]),
|
|
142
|
+
() => null,
|
|
143
|
+
)
|
|
144
|
+
const instance = useSyncExternalStore(
|
|
145
|
+
subscribe,
|
|
146
|
+
useCallback((): ECharts | null => {
|
|
147
|
+
return (
|
|
148
|
+
widgetStoreActions.getWidget<EchartWidgetState>(id)?.instance
|
|
149
|
+
?.current ?? null
|
|
150
|
+
)
|
|
151
|
+
}, [id]),
|
|
152
|
+
() => null,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
const plotCaptureRef = useRef<HTMLDivElement | null>(null)
|
|
156
|
+
const dragStartRef = useRef<number | null>(null)
|
|
157
|
+
const pointerIdRef = useRef<number | null>(null)
|
|
158
|
+
const [projectedRects, setProjectedRects] = useState<PixelRect[]>(
|
|
159
|
+
EMPTY_PIXEL_RECTS as PixelRect[],
|
|
160
|
+
)
|
|
161
|
+
const [drawing, setDrawing] = useState<PixelRect | null>(null)
|
|
162
|
+
const [plotRect, setPlotRect] = useState<PlotRect | null>(null)
|
|
163
|
+
|
|
164
|
+
// Anchor the absolute-positioned overlay to the chart div. Look the element
|
|
165
|
+
// up by id so React Compiler doesn't flag the style mutation; `chartEl` is
|
|
166
|
+
// a normal state value here so using it as a trigger is safe.
|
|
167
|
+
useEffect(() => {
|
|
168
|
+
if (!chartEl) return
|
|
169
|
+
const el = document.getElementById(id)
|
|
170
|
+
if (!el) return
|
|
171
|
+
const previous = el.style.position
|
|
172
|
+
if (!previous) el.style.position = 'relative'
|
|
173
|
+
return () => {
|
|
174
|
+
if (!previous) el.style.position = ''
|
|
175
|
+
}
|
|
176
|
+
}, [id, chartEl])
|
|
177
|
+
|
|
178
|
+
// Re-project stored rects to pixel coords and refresh the plot-area bounds
|
|
179
|
+
// on every chart render (ECharts `finished` event) and on container
|
|
180
|
+
// resize. This is what makes `setOption({ notMerge: true })` a non-event —
|
|
181
|
+
// the store is untouched and the overlay just re-paints.
|
|
182
|
+
useEffect(() => {
|
|
183
|
+
if (!instance || !chartEl) return
|
|
184
|
+
|
|
185
|
+
const project = () => {
|
|
186
|
+
// Plot area bounds — if the grid isn't laid out yet we can't project
|
|
187
|
+
// anything meaningfully, so skip this pass and wait for the next
|
|
188
|
+
// `finished` event.
|
|
189
|
+
const nextPlot = readPlotRect(instance)
|
|
190
|
+
setPlotRect((prev) => (plotRectEquals(prev, nextPlot) ? prev : nextPlot))
|
|
191
|
+
if (!nextPlot) return
|
|
192
|
+
|
|
193
|
+
const next: PixelRect[] = []
|
|
194
|
+
for (const r of rects) {
|
|
195
|
+
const leftPx = instance.convertToPixel({ xAxisIndex: 0 }, r.xStart)
|
|
196
|
+
const rightPx = instance.convertToPixel({ xAxisIndex: 0 }, r.xEnd)
|
|
197
|
+
if (
|
|
198
|
+
typeof leftPx !== 'number' ||
|
|
199
|
+
typeof rightPx !== 'number' ||
|
|
200
|
+
!Number.isFinite(leftPx) ||
|
|
201
|
+
!Number.isFinite(rightPx)
|
|
202
|
+
) {
|
|
203
|
+
continue
|
|
204
|
+
}
|
|
205
|
+
// Clamp to plot area horizontally so rectangles never bleed onto
|
|
206
|
+
// adjacent padding/legend.
|
|
207
|
+
const lo = Math.max(nextPlot.x, Math.min(leftPx, rightPx))
|
|
208
|
+
const hi = Math.min(
|
|
209
|
+
nextPlot.x + nextPlot.width,
|
|
210
|
+
Math.max(leftPx, rightPx),
|
|
211
|
+
)
|
|
212
|
+
if (hi <= lo) continue
|
|
213
|
+
next.push({ left: lo, right: hi })
|
|
214
|
+
}
|
|
215
|
+
// Reuse the frozen empty reference so successive "no rectangles"
|
|
216
|
+
// updates hit React's `Object.is` bail-out instead of re-rendering.
|
|
217
|
+
setProjectedRects(
|
|
218
|
+
next.length === 0 ? (EMPTY_PIXEL_RECTS as PixelRect[]) : next,
|
|
219
|
+
)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
project()
|
|
223
|
+
instance.on('finished', project)
|
|
224
|
+
const unobserve = observeResize(chartEl, project)
|
|
225
|
+
return () => {
|
|
226
|
+
instance.off('finished', project)
|
|
227
|
+
unobserve()
|
|
228
|
+
}
|
|
229
|
+
}, [instance, chartEl, rects])
|
|
230
|
+
|
|
231
|
+
const handlePointerDown = useCallback(
|
|
232
|
+
(e: ReactPointerEvent<HTMLDivElement>) => {
|
|
233
|
+
if (!enabled || !chartEl || !plotRect) return
|
|
234
|
+
const capture = plotCaptureRef.current
|
|
235
|
+
if (!capture) return
|
|
236
|
+
e.preventDefault()
|
|
237
|
+
capture.setPointerCapture(e.pointerId)
|
|
238
|
+
pointerIdRef.current = e.pointerId
|
|
239
|
+
// Work in chart-container pixel space so conversions via
|
|
240
|
+
// `convertFromPixel` (which expects container-relative coords) stay
|
|
241
|
+
// consistent with the rectangle positions we store.
|
|
242
|
+
const chartBBox = chartEl.getBoundingClientRect()
|
|
243
|
+
const x = clampX(e.clientX - chartBBox.left, plotRect)
|
|
244
|
+
dragStartRef.current = x
|
|
245
|
+
setDrawing({ left: x, right: x })
|
|
246
|
+
},
|
|
247
|
+
[enabled, chartEl, plotRect],
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
const handlePointerMove = useCallback(
|
|
251
|
+
(e: ReactPointerEvent<HTMLDivElement>) => {
|
|
252
|
+
if (pointerIdRef.current !== e.pointerId) return
|
|
253
|
+
const start = dragStartRef.current
|
|
254
|
+
if (start === null || !chartEl || !plotRect) return
|
|
255
|
+
const chartBBox = chartEl.getBoundingClientRect()
|
|
256
|
+
const x = clampX(e.clientX - chartBBox.left, plotRect)
|
|
257
|
+
setDrawing({ left: Math.min(start, x), right: Math.max(start, x) })
|
|
258
|
+
},
|
|
259
|
+
[chartEl, plotRect],
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
const handlePointerUp = useCallback(
|
|
263
|
+
(e: ReactPointerEvent<HTMLDivElement>) => {
|
|
264
|
+
if (pointerIdRef.current !== e.pointerId) return
|
|
265
|
+
const start = dragStartRef.current
|
|
266
|
+
const capture = plotCaptureRef.current
|
|
267
|
+
pointerIdRef.current = null
|
|
268
|
+
dragStartRef.current = null
|
|
269
|
+
setDrawing(null)
|
|
270
|
+
|
|
271
|
+
if (start === null || !capture || !instance || !chartEl || !plotRect) {
|
|
272
|
+
return
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
try {
|
|
276
|
+
capture.releasePointerCapture(e.pointerId)
|
|
277
|
+
} catch {
|
|
278
|
+
// releasePointerCapture throws if the pointer is no longer captured;
|
|
279
|
+
// ignore and continue.
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const chartBBox = chartEl.getBoundingClientRect()
|
|
283
|
+
const x = clampX(e.clientX - chartBBox.left, plotRect)
|
|
284
|
+
const leftPx = Math.min(start, x)
|
|
285
|
+
const rightPx = Math.max(start, x)
|
|
286
|
+
if (rightPx - leftPx < MIN_DRAG_PX) return
|
|
287
|
+
|
|
288
|
+
const xStart = instance.convertFromPixel({ xAxisIndex: 0 }, leftPx)
|
|
289
|
+
const xEnd = instance.convertFromPixel({ xAxisIndex: 0 }, rightPx)
|
|
290
|
+
if (
|
|
291
|
+
typeof xStart !== 'number' ||
|
|
292
|
+
typeof xEnd !== 'number' ||
|
|
293
|
+
!Number.isFinite(xStart) ||
|
|
294
|
+
!Number.isFinite(xEnd)
|
|
295
|
+
) {
|
|
296
|
+
return
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const newRect: BrushRect = { xStart, xEnd }
|
|
300
|
+
const nextRects = multiBrush ? [...rects, newRect] : [newRect]
|
|
301
|
+
const selection = {
|
|
302
|
+
dataIndex: hitTestRects(nextRects, dataLength),
|
|
303
|
+
seriesIndex: 0,
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
widgetStoreActions.setWidget(id, {
|
|
307
|
+
brushRects: nextRects,
|
|
308
|
+
brushSelection: selection,
|
|
309
|
+
// Single-brush: auto-disable after selection (matches prior UX).
|
|
310
|
+
...(multiBrush ? {} : { brush: false }),
|
|
311
|
+
})
|
|
312
|
+
},
|
|
313
|
+
[id, instance, multiBrush, rects, dataLength, chartEl, plotRect],
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
if (!chartEl) return null
|
|
317
|
+
|
|
318
|
+
// Outer container spans the whole chart but never captures pointer events,
|
|
319
|
+
// so the legend / axis labels / other chart chrome remain interactive.
|
|
320
|
+
const containerStyle: CSSProperties = {
|
|
321
|
+
position: 'absolute',
|
|
322
|
+
inset: 0,
|
|
323
|
+
pointerEvents: 'none',
|
|
324
|
+
zIndex: 1,
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Inner capture layer sits over the plot area only. This is the element
|
|
328
|
+
// that gets the pointer events when brush is active.
|
|
329
|
+
const captureStyle: CSSProperties | undefined = plotRect
|
|
330
|
+
? {
|
|
331
|
+
position: 'absolute',
|
|
332
|
+
left: plotRect.x,
|
|
333
|
+
top: plotRect.y,
|
|
334
|
+
width: plotRect.width,
|
|
335
|
+
height: plotRect.height,
|
|
336
|
+
pointerEvents: enabled ? 'auto' : 'none',
|
|
337
|
+
cursor: enabled ? 'crosshair' : 'default',
|
|
338
|
+
userSelect: 'none',
|
|
339
|
+
touchAction: 'none',
|
|
340
|
+
}
|
|
341
|
+
: undefined
|
|
342
|
+
|
|
343
|
+
return createPortal(
|
|
344
|
+
<div style={containerStyle}>
|
|
345
|
+
{captureStyle && (
|
|
346
|
+
<div
|
|
347
|
+
ref={plotCaptureRef}
|
|
348
|
+
style={captureStyle}
|
|
349
|
+
onPointerDown={handlePointerDown}
|
|
350
|
+
onPointerMove={handlePointerMove}
|
|
351
|
+
onPointerUp={handlePointerUp}
|
|
352
|
+
onPointerCancel={handlePointerUp}
|
|
353
|
+
/>
|
|
354
|
+
)}
|
|
355
|
+
{plotRect &&
|
|
356
|
+
projectedRects.map((r, i) => (
|
|
357
|
+
<div
|
|
358
|
+
key={`${r.left}-${r.right}-${i}`}
|
|
359
|
+
style={{
|
|
360
|
+
...overlayStyles.rect,
|
|
361
|
+
left: r.left,
|
|
362
|
+
width: Math.max(0, r.right - r.left),
|
|
363
|
+
top: plotRect.y,
|
|
364
|
+
height: plotRect.height,
|
|
365
|
+
}}
|
|
366
|
+
/>
|
|
367
|
+
))}
|
|
368
|
+
{plotRect && drawing && (
|
|
369
|
+
<div
|
|
370
|
+
style={{
|
|
371
|
+
...overlayStyles.rectPreview,
|
|
372
|
+
left: drawing.left,
|
|
373
|
+
width: Math.max(0, drawing.right - drawing.left),
|
|
374
|
+
top: plotRect.y,
|
|
375
|
+
height: plotRect.height,
|
|
376
|
+
}}
|
|
377
|
+
/>
|
|
378
|
+
)}
|
|
379
|
+
</div>,
|
|
380
|
+
chartEl,
|
|
381
|
+
)
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function clampX(x: number, plot: PlotRect): number {
|
|
385
|
+
return Math.max(plot.x, Math.min(plot.x + plot.width, x))
|
|
386
|
+
}
|
|
@@ -1,28 +1,32 @@
|
|
|
1
1
|
import { Box, IconButton } from '@mui/material'
|
|
2
2
|
import { HighlightAltOutlined } from '@mui/icons-material'
|
|
3
|
-
import {
|
|
3
|
+
import { useCallback, useEffect, useRef } from 'react'
|
|
4
4
|
import { widgetStoreActions } from '../../stores/widget-store'
|
|
5
|
-
import type { BrushSelectedItems,
|
|
5
|
+
import type { BrushSelectedItems, BrushState, BrushToggleProps } from './types'
|
|
6
6
|
import { styles } from './style'
|
|
7
7
|
import { Tooltip } from '../../../components'
|
|
8
|
-
import { getEChartBrushConfig } from '../../echart/utils'
|
|
9
|
-
import type { EchartOptionsProps, EchartWidgetState } from '../../echart/types'
|
|
10
8
|
import { useWidgetSelector } from '../../stores/use-widget-selector'
|
|
9
|
+
import { BrushOverlay } from './brush-overlay'
|
|
11
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Kept for backwards compatibility. The tool is no longer registered with the
|
|
13
|
+
* widget store — brush state is now owned directly by this component and the
|
|
14
|
+
* associated DOM overlay instead of being driven through the config pipeline.
|
|
15
|
+
* Existing code that looks this constant up in `registeredTools` will simply
|
|
16
|
+
* find no match.
|
|
17
|
+
*/
|
|
12
18
|
export const BRUSH_TOGGLE_TOOL_ID = 'brush-toggle'
|
|
13
19
|
|
|
14
20
|
/**
|
|
15
|
-
* Widget action to toggle
|
|
21
|
+
* Widget action to toggle brush-style selection on bar and histogram widgets.
|
|
16
22
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
23
|
+
* Renders a toggle button and a portaled overlay rendered inside the chart's
|
|
24
|
+
* `<div>`. The overlay handles all pointer interactions, draws selection
|
|
25
|
+
* rectangles in DOM, and hit-tests against the widget data. All state lives
|
|
26
|
+
* in the widget store — rectangles survive config-pipeline re-renders
|
|
27
|
+
* (`setOption({ notMerge: true })`) automatically.
|
|
19
28
|
*
|
|
20
|
-
*
|
|
21
|
-
* `enabled` flag from the store. This keeps state accessible across component instances.
|
|
22
|
-
*
|
|
23
|
-
* When brush is active, users can drag across bars to select a range.
|
|
24
|
-
* Selection clearing is handled via the chart's brush interactions/toolbox configuration.
|
|
25
|
-
* Only intended for use in fullscreen ToolbarActions for bar and histogram widgets.
|
|
29
|
+
* Only intended for bar and histogram widgets (both use `xAxis.type === 'category'`).
|
|
26
30
|
*
|
|
27
31
|
* @example
|
|
28
32
|
* ```tsx
|
|
@@ -35,163 +39,95 @@ export const BRUSH_TOGGLE_TOOL_ID = 'brush-toggle'
|
|
|
35
39
|
export function BrushToggle({
|
|
36
40
|
id,
|
|
37
41
|
onBrushSelected,
|
|
42
|
+
multiBrush,
|
|
43
|
+
selections,
|
|
44
|
+
defaultEnabled = false,
|
|
38
45
|
labels,
|
|
39
46
|
Icon,
|
|
40
47
|
IconButtonProps,
|
|
41
48
|
}: BrushToggleProps) {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const handleToggle = useCallback(() => {
|
|
50
|
-
const newBrush = !brush
|
|
51
|
-
widgetStoreActions.setWidget(id, { brush: newBrush })
|
|
52
|
-
|
|
53
|
-
if (newBrush) {
|
|
54
|
-
onBrushSelected?.({ dataIndex: [], seriesIndex: 0 }) // Clear selection when enabling brush
|
|
49
|
+
// Single subscription for everything this component cares about.
|
|
50
|
+
const { brush, selection } = useWidgetSelector(id, (w) => {
|
|
51
|
+
const s = w as BrushState | undefined
|
|
52
|
+
return {
|
|
53
|
+
brush: s?.brush ?? defaultEnabled,
|
|
54
|
+
selection: s?.brushSelection,
|
|
55
55
|
}
|
|
56
|
-
}
|
|
56
|
+
})
|
|
57
57
|
|
|
58
|
-
//
|
|
58
|
+
// Seed the widget store with `defaultEnabled` on first mount. Guarded so
|
|
59
|
+
// remounts don't stomp whatever the user has toggled to.
|
|
59
60
|
useEffect(() => {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
widgetStoreActions.getWidget<EchartWidgetState>(id)?.instance?.current
|
|
64
|
-
if (!instance) return
|
|
65
|
-
|
|
66
|
-
const handleRendered = () => {
|
|
67
|
-
const config = getEChartBrushConfig()
|
|
68
|
-
instance.dispatchAction({
|
|
69
|
-
type: 'takeGlobalCursor',
|
|
70
|
-
key: 'brush',
|
|
71
|
-
brushOption: {
|
|
72
|
-
brushType: config.brush.brushType,
|
|
73
|
-
brushMode: config.brush.brushMode,
|
|
74
|
-
},
|
|
75
|
-
})
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
// Dispatch immediately for the initial activation
|
|
79
|
-
handleRendered()
|
|
80
|
-
|
|
81
|
-
// Re-dispatch after each render (covers setOption with notMerge:true)
|
|
82
|
-
instance.on('rendered', handleRendered)
|
|
83
|
-
|
|
84
|
-
return () => {
|
|
85
|
-
instance.off('rendered', handleRendered)
|
|
86
|
-
}
|
|
87
|
-
}, [brush, id])
|
|
88
|
-
|
|
89
|
-
// Handle brushSelected event to capture selected bar indices
|
|
90
|
-
const handleBrushSelected = useCallback((event: unknown) => {
|
|
91
|
-
const brushEvent = event as {
|
|
92
|
-
batch?: {
|
|
93
|
-
selected?: {
|
|
94
|
-
dataIndex?: number[]
|
|
95
|
-
seriesIndex?: number
|
|
96
|
-
}[]
|
|
97
|
-
}[]
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
const allSelected =
|
|
101
|
-
brushEvent.batch?.flatMap((batchItem) => batchItem.selected ?? []) ?? []
|
|
102
|
-
|
|
103
|
-
if (!allSelected.length) {
|
|
104
|
-
selected.current = {
|
|
105
|
-
dataIndex: [],
|
|
106
|
-
seriesIndex: 0,
|
|
107
|
-
}
|
|
108
|
-
return
|
|
61
|
+
const current = widgetStoreActions.getWidget<BrushState>(id)?.brush
|
|
62
|
+
if (current === undefined) {
|
|
63
|
+
widgetStoreActions.setWidget(id, { brush: defaultEnabled })
|
|
109
64
|
}
|
|
65
|
+
}, [id, defaultEnabled])
|
|
110
66
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
selected.current = {
|
|
127
|
-
dataIndex: mergedDataIndex,
|
|
128
|
-
seriesIndex: primarySeriesIndex,
|
|
67
|
+
const handleToggle = useCallback(() => {
|
|
68
|
+
if (brush) {
|
|
69
|
+
// Disabling: keep rectangles visible so the consumer's selection stays
|
|
70
|
+
// in sync. Re-enabling clears them below (fresh slate each activation).
|
|
71
|
+
widgetStoreActions.setWidget(id, { brush: false })
|
|
72
|
+
} else {
|
|
73
|
+
widgetStoreActions.setWidget(id, {
|
|
74
|
+
brush: true,
|
|
75
|
+
brushRects: [],
|
|
76
|
+
brushSelection: { dataIndex: [], seriesIndex: 0 },
|
|
77
|
+
})
|
|
78
|
+
// Mirror the clear to the consumer so its selection drops with the
|
|
79
|
+
// visual rectangles.
|
|
80
|
+
onBrushSelected?.({ dataIndex: [], seriesIndex: 0 })
|
|
129
81
|
}
|
|
130
|
-
}, [])
|
|
131
|
-
|
|
132
|
-
const handleBrushEnd = useCallback(() => {
|
|
133
|
-
onBrushSelected?.(selected.current)
|
|
134
|
-
widgetStoreActions.setWidget(id, { brush: false }) // Disable brush after selection is made
|
|
135
|
-
}, [onBrushSelected, id])
|
|
82
|
+
}, [brush, id, onBrushSelected])
|
|
136
83
|
|
|
137
|
-
//
|
|
138
|
-
//
|
|
84
|
+
// Invoke the consumer's callback whenever the overlay writes a new
|
|
85
|
+
// selection to the store. `selection` is a new object reference on each
|
|
86
|
+
// pointerup so a simple reference check is enough.
|
|
87
|
+
const lastSelectionRef = useRef<BrushSelectedItems | undefined>(undefined)
|
|
139
88
|
useEffect(() => {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
(config.onEvents as Record<string, unknown> | undefined) ?? {}
|
|
150
|
-
|
|
151
|
-
const brushConfig = getEChartBrushConfig()
|
|
152
|
-
|
|
153
|
-
const onEvents = {
|
|
154
|
-
...currentOnEvents,
|
|
155
|
-
brushSelected: handleBrushSelected,
|
|
156
|
-
brushEnd: handleBrushEnd,
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
return {
|
|
160
|
-
...config,
|
|
161
|
-
option: {
|
|
162
|
-
...option,
|
|
163
|
-
...brushConfig,
|
|
164
|
-
},
|
|
165
|
-
onEvents,
|
|
166
|
-
}
|
|
167
|
-
},
|
|
168
|
-
})
|
|
169
|
-
return () => widgetStoreActions.unregisterTool(id, BRUSH_TOGGLE_TOOL_ID)
|
|
170
|
-
}, [id, handleBrushSelected, handleBrushEnd])
|
|
171
|
-
|
|
172
|
-
// Sync enabled from store — lightweight, no re-registration
|
|
89
|
+
if (!selection) return
|
|
90
|
+
if (lastSelectionRef.current === selection) return
|
|
91
|
+
lastSelectionRef.current = selection
|
|
92
|
+
onBrushSelected?.(selection)
|
|
93
|
+
}, [selection, onBrushSelected])
|
|
94
|
+
|
|
95
|
+
// Consumer-driven clear: when `selections` transitions to 0 (e.g. the user
|
|
96
|
+
// presses the clear button in `WidgetSelectionSummary`), drop all drawn
|
|
97
|
+
// rectangles and any stored selection.
|
|
173
98
|
useEffect(() => {
|
|
174
|
-
|
|
175
|
-
|
|
99
|
+
if (selections !== 0) return
|
|
100
|
+
const state = widgetStoreActions.getWidget<BrushState>(id)
|
|
101
|
+
const hasRects = (state?.brushRects?.length ?? 0) > 0
|
|
102
|
+
const hasSelection = (state?.brushSelection?.dataIndex?.length ?? 0) > 0
|
|
103
|
+
if (!hasRects && !hasSelection) return
|
|
104
|
+
widgetStoreActions.setWidget(id, {
|
|
105
|
+
brushRects: [],
|
|
106
|
+
brushSelection: { dataIndex: [], seriesIndex: 0 },
|
|
107
|
+
})
|
|
108
|
+
}, [selections, id])
|
|
176
109
|
|
|
177
110
|
const enableLabel = labels?.enable ?? 'Enable brush selection'
|
|
178
111
|
const disableLabel = labels?.disable ?? 'Disable brush selection'
|
|
179
112
|
const tooltipLabel = brush ? disableLabel : enableLabel
|
|
180
113
|
|
|
181
114
|
return (
|
|
182
|
-
|
|
183
|
-
<
|
|
184
|
-
<
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
115
|
+
<>
|
|
116
|
+
<Box sx={styles.container}>
|
|
117
|
+
<Tooltip title={tooltipLabel}>
|
|
118
|
+
<IconButton
|
|
119
|
+
size='small'
|
|
120
|
+
aria-label={labels?.ariaLabel ?? tooltipLabel}
|
|
121
|
+
onClick={handleToggle}
|
|
122
|
+
sx={styles.trigger}
|
|
123
|
+
data-active={brush}
|
|
124
|
+
{...IconButtonProps}
|
|
125
|
+
>
|
|
126
|
+
{Icon ?? <HighlightAltOutlined />}
|
|
127
|
+
</IconButton>
|
|
128
|
+
</Tooltip>
|
|
129
|
+
</Box>
|
|
130
|
+
<BrushOverlay id={id} multiBrush={multiBrush} />
|
|
131
|
+
</>
|
|
196
132
|
)
|
|
197
133
|
}
|