import { useCallback, useMemo } from 'react' import type * as echarts from 'echarts' import { applyTransforms, setEchartInstance, useWidgetId, useWidgetShallow, type Transform, } from '../stores' import { EchartUI, type EchartsEventHandler } from './echart-ui' /** * Default ECharts `replaceMerge` keys for every widget. `dataZoom` and * `brush` are omitted so ZoomToggle / BrushToggle's user-driven runtime * state (slider range, brushed areas in `multiple` mode) survives unrelated * re-renders. `series` and `dataset` are also omitted: EchartUI fingerprints * them per-render and adds them to `replaceMerge` only when their shape * actually changes, keeping ECharts' per-series runtime state alive when * only callback-style fields differ. * * Lives in this module (not the generic store) because it's an ECharts * concept. Module-private — callers should not need it. */ const DEFAULT_REPLACE_MERGE: readonly string[] = ['toolbox'] /** * Reactive context passed to every {@link OptionFactory} call so the * factory can rebuild render-time pieces (axis label / tooltip formatters) * from the live store. Drives RelativeData / consumer-formatter changes * through to the chart without rebuilding the structural option. */ export interface OptionFactoryContext { formatter?: (value: number) => string labelFormatter?: (value: string | number) => string | number } /** * The per-widget option factory — a single callable that owns BOTH phases * of option construction: * * - **Structural phase** — when `option == null`, return the theme-aware * structural option (tooltip / legend / color palette / series * template, optionally merged with a consumer-supplied `optionsOverride`). * No data is read. `` calls the factory with * `(undefined, undefined)` synchronously during render to derive the * structural base; `configTransforms` (Stack/Zoom/Brush) then mutate it * in the same render pass. * - **Merge phase** — when `option` is defined, fuse `data` into the * post-configTransforms option at fusion time. `` calls * the factory with `(transformed, data, ctx)` on every render. * * The two phases share a closure (the factory creator captures `theme`, * `formatter`, `labelFormatter`, `seriesNames`, `selection`, `optionsOverride`, * …), so structural and merge agree on the same widget configuration. * * The third arg `ctx` carries the **live** store-side formatters at the * call site — distinct from the closure-time formatters because actions * like RelativeData can install a percent formatter on the store after * the factory was constructed. The merge phase reads from `ctx`; the * structural phase typically uses the closure-time values. */ export type OptionFactory = ( option: echarts.EChartsOption | undefined, data: unknown, ctx?: OptionFactoryContext, ) => echarts.EChartsOption export interface EchartProps { /** * The per-widget {@link OptionFactory}. Required — `` * derives the structural option from it (so configTransforms have a base * to mutate) and fuses `state.data` into the post-pipeline option at * render time. Wrap the factory creator in `useMemo` so its identity is * stable across renders. */ optionFactory: OptionFactory onEvents?: Record init?: echarts.EChartsInitOpts className?: string } interface EchartSlice { data: unknown configTransforms: readonly Transform[] formatter?: (value: number) => string labelFormatter?: (value: string | number) => string | number } const echartSelector = (s: { data: unknown configTransforms: readonly Transform[] formatter?: (value: number) => string labelFormatter?: (value: string | number) => string | number }): EchartSlice => ({ data: s.data, configTransforms: s.configTransforms, formatter: s.formatter, labelFormatter: s.labelFormatter, }) /** * Stateful Echart bridge — owns the entire ECharts coupling. The whole * option pipeline lives here, not in the store: * * 1. **Structural** — `optionFactory(undefined, undefined)` produces the * theme-aware base. Memoized on the factory identity, so the consumer's * `useMemo` ID gates the rebuild. * 2. **Transformed** — `applyTransforms(structural, configTransforms)` * applies any registered configTransforms (Stack/Zoom/Brush) over the * structural base. Memoized on `[structural, configTransforms]`. * 3. **`replaceMerge`** — derived from the enabled configTransforms' * `replaceMergeKeys`, deduped and sorted, seeded with * {@link DEFAULT_REPLACE_MERGE}. Memoized on `[configTransforms]` so * ECharts sees a stable array reference across non-transform changes. * 4. **Merge** — `optionFactory(transformed, data, ctx)` fuses post- * pipeline data into the option. Reactive `ctx` carries the live store * formatters so RelativeData's percent formatter flows through without * a structural rebuild. * * The `` doesn't know about the factory at all: it stays * a renderer-agnostic shell. The `ProviderProps` surface has no ECharts * coupling, so non-Echart widgets don't transitively import the type. */ export function Echart({ optionFactory, onEvents, init, className, }: EchartProps) { const id = useWidgetId() const slice = useWidgetShallow(id, echartSelector) // Publish the live ECharts instance to the per-id registry so actions // (e.g. `ZoomToggle`'s disable handler) can reach it imperatively. The // callback identity is stable across renders (only `id` is in deps), so // EchartUI's init effect doesn't see a fresh `onInstance` and re-init. const onInstance = useCallback( (chart: echarts.ECharts | null) => setEchartInstance(id, chart), [id], ) // Destructure so React Compiler sees specific deps instead of inferring // the whole `slice` object. `useWidgetShallow` already shallow-compares // the slice, so per-field deps drive each memo exactly when its inputs // change. const { data: sliceData, configTransforms, formatter: sliceFormatter, labelFormatter: sliceLabelFormatter, } = slice const structural = useMemo( () => optionFactory(undefined, undefined), [optionFactory], ) const transformed = useMemo( () => applyTransforms(structural, configTransforms) as echarts.EChartsOption, [structural, configTransforms], ) const replaceMerge = useMemo(() => { const keys = new Set(DEFAULT_REPLACE_MERGE) for (const xf of configTransforms) { if (xf.enabled && xf.replaceMergeKeys?.length) { for (const k of xf.replaceMergeKeys) keys.add(k) } } return Array.from(keys).sort() }, [configTransforms]) const option = useMemo( () => optionFactory(transformed, sliceData, { formatter: sliceFormatter, labelFormatter: sliceLabelFormatter, }), [ optionFactory, transformed, sliceData, sliceFormatter, sliceLabelFormatter, ], ) return ( ) }