import {
useCallback,
useEffect,
useRef,
useState,
useSyncExternalStore,
} from 'react'
import type { CSSProperties, PointerEvent as ReactPointerEvent } from 'react'
import { createPortal } from 'react-dom'
import type { ECharts } from 'echarts'
import { useWidgetStore, widgetStoreActions } from '../../stores/widget-store'
import { useWidgetSelector } from '../../stores/use-widget-selector'
import type { EchartWidgetState } from '../../echart/types'
import { observeResize } from '../../echart/shared-resize-observer'
import { hitTestRects } from './hit-test'
import type { BrushRect, BrushState } from './types'
import { overlayStyles } from './style'
interface BrushOverlayProps {
id: string
multiBrush?: boolean
}
interface PixelRect {
left: number
right: number
}
interface PlotRect {
x: number
y: number
width: number
height: number
}
// Shape of the internal ECharts API we reach into to get the grid rectangle.
// It's undocumented but stable across v5/v6; falling back gracefully if
// ECharts ever changes the shape is handled by a try/catch at the call site.
interface EChartsGridInternals {
getModel: () => {
getComponent: (
type: string,
index?: number,
) => { coordinateSystem?: { getRect: () => PlotRect } } | null
}
}
// Clicks (tiny drags) are ignored — distinguishes intent to select vs. misclick.
const MIN_DRAG_PX = 2
// Stable empty-array references. Returning `[]` from the Zustand selector
// on every call breaks shallow-equality memoization (each `[]` is a new
// reference) — which ripples into `rects` being a fresh reference on every
// render, which re-runs the re-projection effect, which `setProjectedRects`,
// which re-renders, producing an infinite loop.
const EMPTY_RECTS: readonly BrushRect[] = Object.freeze([])
const EMPTY_PIXEL_RECTS: readonly PixelRect[] = Object.freeze([])
/**
* Reads the plot-area (grid) rectangle from an ECharts instance. Uses the
* internal model API because ECharts doesn't expose this publicly; returns
* `null` if the grid is missing or the API shape has drifted.
*/
function readPlotRect(instance: ECharts): PlotRect | null {
try {
const model = (instance as unknown as EChartsGridInternals).getModel()
const grid = model.getComponent('grid', 0)
const rect = grid?.coordinateSystem?.getRect()
if (!rect) return null
if (
!Number.isFinite(rect.x) ||
!Number.isFinite(rect.y) ||
!Number.isFinite(rect.width) ||
!Number.isFinite(rect.height)
) {
return null
}
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }
} catch {
return null
}
}
function plotRectEquals(a: PlotRect | null, b: PlotRect | null): boolean {
if (a === b) return true
if (!a || !b) return false
return (
a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height
)
}
/**
* Portaled overlay rendered inside the ECharts chart `
`. Handles brush
* drawing entirely in DOM, independent of ECharts' built-in brush.
*
* Rectangles are stored in x-axis data coords (category indices) in the
* widget store. The overlay re-projects them to pixel coords on every chart
* `finished` event and container resize, so `setOption({ notMerge: true })`
* by the config pipeline is a non-event.
*
* Pointer capture and rectangle rendering are both clamped to the ECharts
* grid (plot area), so the legend, axis labels, and other chart chrome
* receive their usual events and aren't visually covered.
*
* Hit-testing happens on `pointerup` using `hitTestRects` against the widget's
* data length (derived from `widget.data`). The result is written to
* `brushSelection` in the store; `BrushToggle` subscribes to that and invokes
* the consumer's `onBrushSelected` callback.
*/
export function BrushOverlay({ id, multiBrush }: BrushOverlayProps) {
const { enabled, rects, dataLength } = useWidgetSelector(id, (w) => {
const brushState = w as BrushState | undefined
// Bar and histogram both expose data as a nested array of series;
// all series share the same category count.
const widgetData = w?.data as unknown[][] | undefined
const firstSeries = widgetData?.[0]
const length = Array.isArray(firstSeries) ? firstSeries.length : 0
return {
enabled: brushState?.brush ?? false,
rects: brushState?.brushRects ?? (EMPTY_RECTS as BrushRect[]),
dataLength: length,
}
})
// Surface the chart DOM element and ECharts instance via `useSyncExternalStore`.
// Reading `ref.current` inside a `getSnapshot` callback is idiomatic and
// satisfies React Compiler's "don't read refs during render" guardrail.
// The store notifies us when EchartUI registers the refs via `setWidget`;
// `getSnapshot` then resolves the current values.
const subscribe = useCallback(
(notify: () => void) => useWidgetStore.subscribe(notify),
[],
)
const chartEl = useSyncExternalStore(
subscribe,
useCallback((): HTMLElement | null => {
return (
widgetStoreActions.getWidget
(id)?.refUI?.current ??
null
)
}, [id]),
() => null,
)
const instance = useSyncExternalStore(
subscribe,
useCallback((): ECharts | null => {
return (
widgetStoreActions.getWidget(id)?.instance
?.current ?? null
)
}, [id]),
() => null,
)
const plotCaptureRef = useRef(null)
const dragStartRef = useRef(null)
const pointerIdRef = useRef(null)
const [projectedRects, setProjectedRects] = useState(
EMPTY_PIXEL_RECTS as PixelRect[],
)
const [drawing, setDrawing] = useState(null)
const [plotRect, setPlotRect] = useState(null)
// Anchor the absolute-positioned overlay to the chart div. Look the element
// up by id so React Compiler doesn't flag the style mutation; `chartEl` is
// a normal state value here so using it as a trigger is safe.
useEffect(() => {
if (!chartEl) return
const el = document.getElementById(id)
if (!el) return
const previous = el.style.position
if (!previous) el.style.position = 'relative'
return () => {
if (!previous) el.style.position = ''
}
}, [id, chartEl])
// Re-project stored rects to pixel coords and refresh the plot-area bounds
// on every chart render (ECharts `finished` event) and on container
// resize. This is what makes `setOption({ notMerge: true })` a non-event —
// the store is untouched and the overlay just re-paints.
useEffect(() => {
if (!instance || !chartEl) return
const project = () => {
// Plot area bounds — if the grid isn't laid out yet we can't project
// anything meaningfully, so skip this pass and wait for the next
// `finished` event.
const nextPlot = readPlotRect(instance)
setPlotRect((prev) => (plotRectEquals(prev, nextPlot) ? prev : nextPlot))
if (!nextPlot) return
const next: PixelRect[] = []
for (const r of rects) {
const leftPx = instance.convertToPixel({ xAxisIndex: 0 }, r.xStart)
const rightPx = instance.convertToPixel({ xAxisIndex: 0 }, r.xEnd)
if (
typeof leftPx !== 'number' ||
typeof rightPx !== 'number' ||
!Number.isFinite(leftPx) ||
!Number.isFinite(rightPx)
) {
continue
}
// Clamp to plot area horizontally so rectangles never bleed onto
// adjacent padding/legend.
const lo = Math.max(nextPlot.x, Math.min(leftPx, rightPx))
const hi = Math.min(
nextPlot.x + nextPlot.width,
Math.max(leftPx, rightPx),
)
if (hi <= lo) continue
next.push({ left: lo, right: hi })
}
// Reuse the frozen empty reference so successive "no rectangles"
// updates hit React's `Object.is` bail-out instead of re-rendering.
setProjectedRects(
next.length === 0 ? (EMPTY_PIXEL_RECTS as PixelRect[]) : next,
)
}
project()
instance.on('finished', project)
const unobserve = observeResize(chartEl, project)
return () => {
instance.off('finished', project)
unobserve()
}
}, [instance, chartEl, rects])
const handlePointerDown = useCallback(
(e: ReactPointerEvent) => {
if (!enabled || !chartEl || !plotRect) return
const capture = plotCaptureRef.current
if (!capture) return
e.preventDefault()
capture.setPointerCapture(e.pointerId)
pointerIdRef.current = e.pointerId
// Hide the tooltip so it doesn't overlap the brush selection.
instance?.dispatchAction({ type: 'hideTip' })
// Work in chart-container pixel space so conversions via
// `convertFromPixel` (which expects container-relative coords) stay
// consistent with the rectangle positions we store.
const chartBBox = chartEl.getBoundingClientRect()
const x = clampX(e.clientX - chartBBox.left, plotRect)
dragStartRef.current = x
setDrawing({ left: x, right: x })
},
[enabled, instance, chartEl, plotRect],
)
const handlePointerMove = useCallback(
(e: ReactPointerEvent) => {
// No active drag — forward the hover to ECharts so its tooltip works.
if (pointerIdRef.current === null) {
if (instance && chartEl) {
const chartBBox = chartEl.getBoundingClientRect()
instance.dispatchAction({
type: 'showTip',
x: e.clientX - chartBBox.left,
y: e.clientY - chartBBox.top,
})
}
return
}
if (pointerIdRef.current !== e.pointerId) return
const start = dragStartRef.current
if (start === null || !chartEl || !plotRect) return
const chartBBox = chartEl.getBoundingClientRect()
const x = clampX(e.clientX - chartBBox.left, plotRect)
setDrawing({ left: Math.min(start, x), right: Math.max(start, x) })
},
[instance, chartEl, plotRect],
)
const handlePointerLeave = useCallback(() => {
// Don't hide the tooltip during a drag — the pointer may leave the
// element bounds while captured and we don't want flickering.
if (pointerIdRef.current !== null) return
instance?.dispatchAction({ type: 'hideTip' })
}, [instance])
const handlePointerUp = useCallback(
(e: ReactPointerEvent) => {
if (pointerIdRef.current !== e.pointerId) return
const start = dragStartRef.current
const capture = plotCaptureRef.current
pointerIdRef.current = null
dragStartRef.current = null
setDrawing(null)
if (start === null || !capture || !instance || !chartEl || !plotRect) {
return
}
try {
capture.releasePointerCapture(e.pointerId)
} catch {
// releasePointerCapture throws if the pointer is no longer captured;
// ignore and continue.
}
const chartBBox = chartEl.getBoundingClientRect()
const x = clampX(e.clientX - chartBBox.left, plotRect)
const leftPx = Math.min(start, x)
const rightPx = Math.max(start, x)
if (rightPx - leftPx < MIN_DRAG_PX) return
const xStart = instance.convertFromPixel({ xAxisIndex: 0 }, leftPx)
const xEnd = instance.convertFromPixel({ xAxisIndex: 0 }, rightPx)
if (
typeof xStart !== 'number' ||
typeof xEnd !== 'number' ||
!Number.isFinite(xStart) ||
!Number.isFinite(xEnd)
) {
return
}
const newRect: BrushRect = { xStart, xEnd }
const nextRects = multiBrush ? [...rects, newRect] : [newRect]
const selection = {
dataIndex: hitTestRects(nextRects, dataLength),
seriesIndex: 0,
}
widgetStoreActions.setWidget(id, {
brushRects: nextRects,
brushSelection: selection,
// Single-brush: auto-disable after selection (matches prior UX).
...(multiBrush ? {} : { brush: false }),
})
},
[id, instance, multiBrush, rects, dataLength, chartEl, plotRect],
)
if (!chartEl) return null
// Outer container spans the whole chart but never captures pointer events,
// so the legend / axis labels / other chart chrome remain interactive.
const containerStyle: CSSProperties = {
position: 'absolute',
inset: 0,
pointerEvents: 'none',
zIndex: 1,
}
// Inner capture layer sits over the plot area only. This is the element
// that gets the pointer events when brush is active.
const captureStyle: CSSProperties | undefined = plotRect
? {
position: 'absolute',
left: plotRect.x,
top: plotRect.y,
width: plotRect.width,
height: plotRect.height,
pointerEvents: enabled ? 'auto' : 'none',
cursor: enabled ? 'crosshair' : 'default',
userSelect: 'none',
touchAction: 'none',
}
: undefined
return createPortal(
{captureStyle && (
)}
{plotRect &&
projectedRects.map((r, i) => (
))}
{plotRect && drawing && (
)}
,
chartEl,
)
}
function clampX(x: number, plot: PlotRect): number {
return Math.max(plot.x, Math.min(plot.x + plot.width, x))
}