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)) }