@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.
Files changed (38) hide show
  1. package/dist/{download-config-C3I0jWIL.js → download-config-DNLkypdN.js} +8 -7
  2. package/dist/{download-config-C3I0jWIL.js.map → download-config-DNLkypdN.js.map} +1 -1
  3. package/dist/shared-resize-observer-98b1SK1e.js +17 -0
  4. package/dist/shared-resize-observer-98b1SK1e.js.map +1 -0
  5. package/dist/types/widgets/actions/brush-toggle/brush-overlay.d.ts +24 -0
  6. package/dist/types/widgets/actions/brush-toggle/brush-toggle.d.ts +15 -10
  7. package/dist/types/widgets/actions/brush-toggle/hit-test.d.ts +19 -0
  8. package/dist/types/widgets/actions/brush-toggle/hit-test.test.d.ts +1 -0
  9. package/dist/types/widgets/actions/brush-toggle/style.d.ts +8 -0
  10. package/dist/types/widgets/actions/brush-toggle/types.d.ts +35 -1
  11. package/dist/widgets/actions.js +985 -772
  12. package/dist/widgets/actions.js.map +1 -1
  13. package/dist/widgets/bar.js +1 -1
  14. package/dist/widgets/category.js +9 -8
  15. package/dist/widgets/category.js.map +1 -1
  16. package/dist/widgets/echart.js +79 -91
  17. package/dist/widgets/echart.js.map +1 -1
  18. package/dist/widgets/formula.js +43 -42
  19. package/dist/widgets/formula.js.map +1 -1
  20. package/dist/widgets/histogram.js +7 -6
  21. package/dist/widgets/histogram.js.map +1 -1
  22. package/dist/widgets/markdown.js +15 -14
  23. package/dist/widgets/markdown.js.map +1 -1
  24. package/dist/widgets/pie.js +1 -1
  25. package/dist/widgets/scatterplot.js +1 -1
  26. package/dist/widgets/spread.js +47 -46
  27. package/dist/widgets/spread.js.map +1 -1
  28. package/dist/widgets/table.js +17 -16
  29. package/dist/widgets/table.js.map +1 -1
  30. package/dist/widgets/timeseries.js +1 -1
  31. package/dist/widgets/utils.js +1 -1
  32. package/package.json +3 -1
  33. package/src/widgets/actions/brush-toggle/brush-overlay.tsx +386 -0
  34. package/src/widgets/actions/brush-toggle/brush-toggle.tsx +88 -152
  35. package/src/widgets/actions/brush-toggle/hit-test.test.ts +65 -0
  36. package/src/widgets/actions/brush-toggle/hit-test.ts +45 -0
  37. package/src/widgets/actions/brush-toggle/style.ts +32 -0
  38. 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 { useEffect, useCallback, useRef } from 'react'
3
+ import { useCallback, useEffect, useRef } from 'react'
4
4
  import { widgetStoreActions } from '../../stores/widget-store'
5
- import type { BrushSelectedItems, BrushToggleProps, BrushState } from './types'
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 EChart brush selection functionality.
21
+ * Widget action to toggle brush-style selection on bar and histogram widgets.
16
22
  *
17
- * Registers as a config pipeline tool so that brush configuration is automatically
18
- * re-applied when the base config is updated (e.g., by WidgetLoader).
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
- * Brush state is stored in the widget store root, and the tool derives its
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
- const selected = useRef<BrushSelectedItems>({ dataIndex: [], seriesIndex: 0 })
43
-
44
- // Read brush state from widget store root — single source of truth
45
- const { brush } = useWidgetSelector(id, (w) => ({
46
- brush: (w as BrushState | undefined)?.brush ?? false,
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
- }, [brush, id, onBrushSelected])
56
+ })
57
57
 
58
- // Re-dispatch brush action after every ECharts render while brush is active
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
- if (!brush) return
61
-
62
- const instance =
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
- // Use the first seriesIndex as the primary one (matches previous behavior)
112
- const primarySeriesIndex = allSelected[0]?.seriesIndex ?? 0
113
-
114
- const mergedDataIndex = Array.from(
115
- new Set(
116
- allSelected
117
- .filter(
118
- (item) =>
119
- item.seriesIndex === undefined ||
120
- item.seriesIndex === primarySeriesIndex,
121
- )
122
- .flatMap((item) => item.dataIndex ?? []),
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
- // Register config tool once fn closure depends on event handlers.
138
- // Enabled is synced separately to avoid full re-registration on toggle.
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
- widgetStoreActions.registerTool(id, {
141
- id: BRUSH_TOGGLE_TOOL_ID,
142
- type: 'config',
143
- order: 25,
144
- enabled: false,
145
- fn: (currentConfig) => {
146
- const config = currentConfig as Record<string, unknown>
147
- const option = config.option as EchartOptionsProps | undefined
148
- const currentOnEvents =
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
- widgetStoreActions.setToolEnabled(id, BRUSH_TOGGLE_TOOL_ID, brush)
175
- }, [id, brush])
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
- <Box sx={styles.container}>
183
- <Tooltip title={tooltipLabel}>
184
- <IconButton
185
- size='small'
186
- aria-label={labels?.ariaLabel ?? tooltipLabel}
187
- onClick={handleToggle}
188
- sx={styles.trigger}
189
- data-active={brush}
190
- {...IconButtonProps}
191
- >
192
- {Icon ?? <HighlightAltOutlined />}
193
- </IconButton>
194
- </Tooltip>
195
- </Box>
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
  }