import type { ECharts } from 'echarts' import { useStore, type StoreApi } from 'zustand' import { createStore } from 'zustand/vanilla' import { devtools } from 'zustand/middleware' import { useShallow } from 'zustand/react/shallow' import { pipelineMiddleware } from './pipeline-middleware' import type { WidgetInit, WidgetState, WidgetStoreApi } from './types' const widgetStores = new Map() const widgetMountCounts = new Map() const warnedDuplicates = new Set() // Per-widget capture-element registry. Held outside the reactive store so // publishing the element doesn't trigger any consumer re-renders — these // handles are read imperatively by per-widget download configs at click time // only. `Widget.State` writes its success-path content wrapper here on mount // (auto-cleared on unmount via callback ref), and `create*DownloadConfig` // looks it up to feed `html2canvas` for PNG export. const captureEls = new Map() // Per-widget live ECharts instance registry. Same imperative-only contract // as `captureEls`: published by the `Widget.Echart` bridge on mount / // dispose, read at click time by actions that need to dispatch on the live // chart (e.g. `ZoomToggle`'s disable handler issuing // `setOption({}, { replaceMerge: ['dataZoom'] })` to clear the slider). // Held outside the reactive store so publishing never triggers consumer // re-renders. const echartInstances = new Map() // Per-id subscribers notified whenever `setEchartInstance` writes a new // value. Powers `useEchartInstance(id)` (a `useSyncExternalStore` wrapper) // so actions like `BrushToggle` can react when the chart becomes available // — required for lifecycle effects that need the instance on mount but // arrive in the tree before the `Widget.Echart` bridge has run its init // `useEffect`. type EchartInstanceListener = (chart: ECharts | null) => void const echartInstanceSubscribers = new Map>() const isDev = (): boolean => { try { return Boolean( (import.meta as unknown as { env?: { DEV?: boolean } }).env?.DEV, ) } catch { return false } } export function getWidgetStore(id: string): WidgetStoreApi { const store = widgetStores.get(id) if (!store) throw new Error(`[widgets-v2] Widget store "${id}" not found.`) return store } export function hasWidgetStore(id: string): boolean { return widgetStores.has(id) } export function deleteWidgetStore(id: string): void { widgetStores.delete(id) } export function resetWidgetStore(id: string): void { const store = widgetStores.get(id) if (store) store.setState({ transformStates: {} }) } export function clearAllWidgetStores(): void { widgetStores.clear() widgetMountCounts.clear() warnedDuplicates.clear() captureEls.clear() echartInstances.clear() echartInstanceSubscribers.clear() } /** * Register or clear the capture element for a widget. Pass `null` to clear. * Called by `Widget.State` via a callback ref on its success-path content * wrapper. The element is read by per-widget download configs at click time * to feed `html2canvas` for PNG export. */ export function setCaptureEl(id: string, el: HTMLElement | null): void { if (el == null) captureEls.delete(id) else captureEls.set(id, el) } /** * Read the currently registered capture element for a widget. Returns `null` * when no `Widget.State` is on its success path (loading / error / empty) * or when the widget hasn't mounted. */ export function getCaptureEl(id: string): HTMLElement | null { return captureEls.get(id) ?? null } /** @internal — exposed for tests and debugging only. */ export function clearAllCaptureEls(): void { captureEls.clear() } /** * Register or clear the live ECharts instance for a widget. Pass `null` * to clear. Called by the `Widget.Echart` bridge on mount / dispose. * Read by actions (e.g. `ZoomToggle`'s disable handler) that need to * dispatch on the live chart without going through the pipeline. * * Notifies subscribers registered via {@link subscribeEchartInstance} * after the map mutation, so reactive consumers (the * `useEchartInstance` hook) re-render with the new value. */ export function setEchartInstance(id: string, chart: ECharts | null): void { if (chart == null) echartInstances.delete(id) else echartInstances.set(id, chart) echartInstanceSubscribers.get(id)?.forEach((fn) => fn(chart)) } /** * Read the currently registered ECharts instance for a widget. Returns * `null` when no `Widget.Echart` is mounted, or before the bridge has * created the instance. */ export function getEchartInstance(id: string): ECharts | null { return echartInstances.get(id) ?? null } /** * Subscribe to changes of the registered ECharts instance for a widget. * The listener is called *after* every {@link setEchartInstance} (with * the new chart, or `null` when cleared). Returns an unsubscribe * function. Used by the `useEchartInstance` hook to drive React effects * that need to react to instance arrival / departure (e.g. * `BrushToggle`'s `'finished'` re-dispatch loop). */ export function subscribeEchartInstance( id: string, listener: EchartInstanceListener, ): () => void { let listeners = echartInstanceSubscribers.get(id) if (!listeners) { listeners = new Set() echartInstanceSubscribers.set(id, listeners) } listeners.add(listener) return () => { const set = echartInstanceSubscribers.get(id) if (!set) return set.delete(listener) if (set.size === 0) echartInstanceSubscribers.delete(id) } } /** @internal — exposed for tests and debugging only. */ export function clearAllEchartInstances(): void { echartInstances.clear() echartInstanceSubscribers.clear() } /** @internal — exposed for tests and debugging only. */ export function __debugListWidgetStores(): string[] { return Array.from(widgetStores.keys()) } /** * Insert or replace a store entry in the registry without refcounting or * duplicate-id detection. Used by Provider's lazy `useState` init so the * store is reachable from `useWidget(id)` calls in children that render * before the Provider's mount effect runs. Refcounting and dev warns happen * in {@link registerWidgetStore} during the mount effect. * * @internal */ export function setWidgetStoreEntry(id: string, store: WidgetStoreApi): void { widgetStores.set(id, store) } /** @internal — used by Provider; not part of the public API. */ export function createWidgetStore( id: string, init: WidgetInit, ): WidgetStoreApi { const builder = pipelineMiddleware(() => ({ rawData: init.data, data: init.data, dataTransforms: [], configTransforms: [], transformStates: {}, isLoading: init.isLoading ?? false, isFetching: init.isFetching ?? false, error: init.error, rawFormatter: init.formatter, formatter: init.formatter, labelFormatter: init.labelFormatter, })) const store = createStore()( devtools(builder, { name: `widget-${id}`, enabled: isDev() }), ) as unknown as WidgetStoreApi return store } /** @internal — registers a Provider mount and tracks/warns on duplicate ids in dev. */ export function registerWidgetStore(id: string, store: WidgetStoreApi): void { widgetStores.set(id, store) const count = (widgetMountCounts.get(id) ?? 0) + 1 widgetMountCounts.set(id, count) if (count > 1 && isDev() && !warnedDuplicates.has(id)) { // eslint-disable-next-line no-console console.warn( `[widgets-v2] Duplicate detected. ` + `Multiple providers sharing an id will race on prop sync and corrupt the store. ` + `Use unique ids per widget instance.`, ) warnedDuplicates.add(id) } } /** @internal — Provider unmount counterpart; on last unmount removes the refcount entry. */ export function unregisterWidgetStore( id: string, options: { keepAlive: boolean }, ): void { const count = (widgetMountCounts.get(id) ?? 1) - 1 if (count <= 0) { widgetMountCounts.delete(id) if (!options.keepAlive) widgetStores.delete(id) } else { widgetMountCounts.set(id, count) } } export function useWidget( id: string, selector: (state: S) => T, ): T { const store = getWidgetStore(id) as unknown as StoreApi return useStore(store, selector) } export function useWidgetShallow( id: string, selector: (state: S) => T, ): T { const store = getWidgetStore(id) as unknown as StoreApi return useStore(store, useShallow(selector)) }