import { useEffect, useEffectEvent, useRef } from 'react' import { Box, type SxProps, type Theme } from '@mui/material' import * as echarts from 'echarts' import { clampEdgeLabels, CENTERED, type EdgeAlignment, } from './edge-label-clamp' import { observeResize } from './shared-resize-observer' import { styles } from './style' export const DEFAULT_INIT_OPTS = { renderer: 'svg', height: 304, } satisfies echarts.EChartsInitOpts export type EchartsEventHandler = (event: unknown) => void export interface EchartUIProps { option: echarts.EChartsOption /** * Keys to merge as arrays (replace by index) instead of by id. The middleware * memoizes this content-stably, so identical sets don't re-trigger setOption. */ replaceMerge?: readonly string[] /** * Opaque ECharts event passthrough — handlers fire untransformed. * * **Must be referentially stable** (memoize it with `useMemo` / * `useCallback`, or hoist to module scope). The binding effect depends on * the `onEvents` object identity; an inline `{ click: handler }` literal * recreates the object on every render and causes every listener to be * detached and re-attached each commit. */ onEvents?: Record /** * Init options forwarded to echarts.init. Captured at mount only — to change * `renderer` or `height` after mount, unmount and remount the chart. * Defaults: `{ renderer: 'svg', height: 304 }`. */ init?: echarts.EChartsInitOpts /** * Optional callback fired once after the ECharts instance is created * (`onInstance(chart)`) and once when it's about to be disposed * (`onInstance(null)`). Used by `Widget.Echart` to publish the live * instance to the per-widget registry so other actions (e.g. * `ZoomToggle`'s disable handler) can reach it without DOM lookups. * No need to memoize — the bridge wraps it in `useEffectEvent` so an * unstable reference does not re-init the chart. */ onInstance?: (chart: echarts.ECharts | null) => void className?: string sx?: SxProps } export function EchartUI({ option, replaceMerge, onEvents, init, onInstance, className, sx, }: EchartUIProps) { const containerRef = useRef(null) const chartRef = useRef(null) // Structural fingerprint of the last applied series / dataset arrays. // Used to decide whether the next `setOption` should *replace* those // components (when their shape changed) or *merge* them (when only // styling-level fields differ — e.g. a new `itemStyle.color` callback // from a selection update). Replacing wipes ECharts' per-series runtime // state (hover, animation cache, brush areas), so we want to do it only // when the structure actually moved. const seriesFingerprintRef = useRef(null) const datasetFingerprintRef = useRef(null) // Latest applied option (read by the `finished` clamp listener, which is // bound once and must see the current option) + the edge-label alignment // currently applied to the chart (so the clamp skips redundant setOptions). const optionRef = useRef(option) const edgeAlignRef = useRef(CENTERED) // Stable notify wrapper — always reads the latest `onInstance` prop // without forcing the init effect below to re-run when the parent // passes a fresh function reference. The init effect now only fires // when `init` actually changes (documented as mount-only anyway). const notifyInstance = useEffectEvent( (chart: echarts.ECharts | null): void => { onInstance?.(chart) }, ) // Init / dispose. `init` is captured once at mount. useEffect(() => { if (!containerRef.current) return undefined const chart = echarts.init(containerRef.current, null, { ...DEFAULT_INIT_OPTS, ...init, }) chartRef.current = chart notifyInstance(chart) // Edge-label clamp: after each layout settles (option change OR resize both // end in a render → `finished`), measure the category x-axis edge labels // and anchor them inward only if they'd clip. `finished` (not rAF) so // `convertToPixel` sees the flushed `lazyUpdate` layout. Loop-safe: the // verdict is anchor-independent, so our own clamp setOption recomputes the // same result and the ref guard short-circuits. const onFinished = (): void => { edgeAlignRef.current = clampEdgeLabels( chart, optionRef.current, edgeAlignRef.current, ) } chart.on('finished', onFinished) return () => { chart.off('finished', onFinished) // Notify observers *before* disposal so any queued imperative // dispatches (e.g. ZoomToggle's `setOption` cleanup) see the // instance disappear before its DOM is torn down. notifyInstance(null) chart.dispose() chartRef.current = null } }, [init]) // Apply option / replaceMerge changes via merge mode (no remount required). useEffect(() => { const seriesFp = computeSeriesFingerprint(option) const datasetFp = computeDatasetFingerprint(option) const augmented = new Set(replaceMerge ?? []) if (seriesFp !== seriesFingerprintRef.current) augmented.add('series') if (datasetFp !== datasetFingerprintRef.current) augmented.add('dataset') seriesFingerprintRef.current = seriesFp datasetFingerprintRef.current = datasetFp // Expose the latest option to the `finished` clamp listener (bound once). optionRef.current = option chartRef.current?.setOption(option, { notMerge: false, lazyUpdate: true, replaceMerge: augmented.size > 0 ? Array.from(augmented) : undefined, }) }, [option, replaceMerge]) // Resize via shared singleton observer. useEffect(() => { const node = containerRef.current if (!node) return undefined return observeResize(node, () => { chartRef.current?.resize() }) }, []) // Bind / unbind opaque event handlers. useEffect(() => { const chart = chartRef.current if (!chart || !onEvents) return undefined for (const [event, handler] of Object.entries(onEvents)) { chart.on(event, handler) } return () => { for (const [event, handler] of Object.entries(onEvents)) { chart.off(event, handler) } } }, [onEvents]) return ( ) } /** * Cheap structural digest of the option's `series` array — covers the fields * that actually demand a `replaceMerge` (length, type, datasetIndex, name, * stack, encode). Excludes runtime-style fields like `itemStyle` (callbacks * have unstable identity but don't change structure). */ function computeSeriesFingerprint(option: echarts.EChartsOption): string { const series = option.series if (!Array.isArray(series)) return series ? '1' : '' return series .map((s) => { if (s == null || typeof s !== 'object') return String(s) const o = s as Record return [ o.type, o.datasetIndex, o.name, o.stack, // `encode` is small; stringify is cheap and stable for plain objects. JSON.stringify(o.encode ?? null), ].join('|') }) .join('||') } /** * Structural digest of the option's `dataset` array — count is the only * thing that needs `replaceMerge` here. Row-level changes are picked up by * ECharts' default merge. */ function computeDatasetFingerprint(option: echarts.EChartsOption): string { const dataset = option.dataset if (!Array.isArray(dataset)) return dataset ? '1' : '' return String(dataset.length) }