@agions/taroviz 1.7.0 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/README.md +7 -3
  2. package/dist/cjs/index.js +1 -1
  3. package/dist/esm/index.js +960 -136
  4. package/package.json +1 -1
  5. package/src/adapters/__tests__/index.test.ts +4 -2
  6. package/src/adapters/h5/index.ts +16 -0
  7. package/src/adapters/types.ts +28 -120
  8. package/src/charts/boxplot/types.ts +5 -3
  9. package/src/charts/common/BaseChartWrapper.tsx +193 -32
  10. package/src/charts/liquid/index.tsx +6 -5
  11. package/src/charts/liquid/types.ts +4 -4
  12. package/src/charts/parallel/types.ts +6 -3
  13. package/src/charts/tree/types.ts +4 -4
  14. package/src/charts/types.ts +1 -1
  15. package/src/core/animation/AnimationManager.ts +69 -42
  16. package/src/core/components/Annotation.tsx +12 -10
  17. package/src/core/components/BaseChart.tsx +75 -12
  18. package/src/core/components/ErrorBoundary.tsx +30 -17
  19. package/src/core/components/LazyChart.tsx +14 -9
  20. package/src/core/themes/ThemeManager.ts +33 -0
  21. package/src/core/types/common.ts +21 -110
  22. package/src/core/types/index.ts +4 -135
  23. package/src/core/types/platform.ts +38 -230
  24. package/src/core/utils/chartUtils.ts +8 -3
  25. package/src/core/utils/export/ExportUtils.ts +10 -1
  26. package/src/core/utils/performance/PerformanceAnalyzer.ts +21 -1
  27. package/src/core/utils/performance/types.ts +5 -0
  28. package/src/hooks/__tests__/index.test.tsx +7 -5
  29. package/src/hooks/index.ts +23 -1
  30. package/src/hooks/useAnimation.ts +427 -0
  31. package/src/hooks/useChartHistory.ts +273 -0
  32. package/src/hooks/useChartSelection.ts +350 -0
  33. package/src/hooks/usePerformance.ts +291 -0
  34. package/src/themes/__tests__/index.test.ts +7 -13
@@ -0,0 +1,427 @@
1
+ /**
2
+ * useAnimation - 图表动画控制 Hook
3
+ * 提供图表动画的播放、暂停、控制等功能
4
+ */
5
+ import { useState, useCallback, useRef, useEffect } from 'react';
6
+ import type { ChartInstance } from './index';
7
+
8
+ /**
9
+ * 动画状态
10
+ */
11
+ export type AnimationStatus = 'playing' | 'paused' | 'stopped';
12
+
13
+ /**
14
+ * 动画配置选项
15
+ */
16
+ export interface UseAnimationOptions {
17
+ /** 动画时长 (ms) */
18
+ duration?: number;
19
+ /** 动画缓动函数 */
20
+ easing?: 'cubicOut' | 'cubicIn' | 'cubicInOut' | 'linear' | 'sinusoidalIn' | 'sinusoidalOut';
21
+ /** 是否禁用动画 */
22
+ disabled?: boolean;
23
+ /** 动画延迟 (ms) */
24
+ delay?: number;
25
+ /** 是否循环 */
26
+ loop?: boolean;
27
+ /** 循环次数 (-1 表示无限) */
28
+ loopCount?: number;
29
+ }
30
+
31
+ /**
32
+ * 动画状态返回值
33
+ */
34
+ export interface UseAnimationReturn {
35
+ /** 当前动画状态 */
36
+ status: AnimationStatus;
37
+ /** 当前动画帧 */
38
+ frame: number;
39
+ /** 总帧数 */
40
+ totalFrames: number;
41
+ /** 动画进度 (0-1) */
42
+ progress: number;
43
+ /** 播放动画 */
44
+ play: () => void;
45
+ /** 暂停动画 */
46
+ pause: () => void;
47
+ /** 停止动画并重置 */
48
+ stop: () => void;
49
+ /** 跳转到指定帧 */
50
+ seekTo: (frame: number) => void;
51
+ /** 跳转到指定进度 */
52
+ seekToProgress: (progress: number) => void;
53
+ /** 设置播放速度 */
54
+ setPlaybackSpeed: (speed: number) => void;
55
+ /** 播放速度 */
56
+ playbackSpeed: number;
57
+ }
58
+
59
+ /**
60
+ * 使用图表动画控制
61
+ * @param chartInstance 图表实例
62
+ * @param options 配置选项
63
+ * @returns 动画控制接口
64
+ */
65
+ export function useAnimation(
66
+ chartInstance: ChartInstance | null,
67
+ options: UseAnimationOptions = {}
68
+ ): UseAnimationReturn {
69
+ const {
70
+ duration = 1000,
71
+ easing = 'cubicOut',
72
+ disabled = false,
73
+ delay = 0,
74
+ loop = false,
75
+ loopCount = -1,
76
+ } = options;
77
+
78
+ // State
79
+ const [status, setStatus] = useState<AnimationStatus>('stopped');
80
+ const [frame, setFrame] = useState(0);
81
+ const [playbackSpeed, setPlaybackSpeedState] = useState(1);
82
+
83
+ // Refs
84
+ const animationRef = useRef<{
85
+ startTime: number;
86
+ currentFrame: number;
87
+ loopCounter: number;
88
+ animationId: number | null;
89
+ isPaused: boolean;
90
+ }>({
91
+ startTime: 0,
92
+ currentFrame: 0,
93
+ loopCounter: 0,
94
+ animationId: null,
95
+ isPaused: false,
96
+ });
97
+
98
+ const chartRef = useRef<ChartInstance | null>(null);
99
+ chartRef.current = chartInstance;
100
+
101
+ // 计算总帧数(假设 60fps)
102
+ const totalFrames = Math.ceil((duration / 1000) * 60);
103
+
104
+ // 缓动函数
105
+ const easingFunctions: Record<string, (t: number) => number> = {
106
+ cubicOut: (t) => 1 - Math.pow(1 - t, 3),
107
+ cubicIn: (t) => t * t * t,
108
+ cubicInOut: (t) => t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2,
109
+ linear: (t) => t,
110
+ sinusoidalIn: (t) => 1 - Math.cos((t * Math.PI) / 2),
111
+ sinusoidalOut: (t) => Math.sin((t * Math.PI) / 2),
112
+ };
113
+
114
+ // 计算当前进度对应的帧
115
+ const calculateFrame = useCallback(
116
+ (progress: number) => {
117
+ return Math.floor(progress * totalFrames);
118
+ },
119
+ [totalFrames]
120
+ );
121
+
122
+ // 设置播放速度
123
+ const setPlaybackSpeed = useCallback((speed: number) => {
124
+ setPlaybackSpeedState(Math.max(0.1, Math.min(10, speed)));
125
+ }, []);
126
+
127
+ // 动画更新函数
128
+ const animate = useCallback(() => {
129
+ const anim = animationRef.current;
130
+ const chart = chartRef.current;
131
+
132
+ if (!chart || anim.isPaused || disabled) {
133
+ return;
134
+ }
135
+
136
+ const elapsed = performance.now() - anim.startTime;
137
+ const adjustedDuration = duration / playbackSpeed;
138
+ const effectiveElapsed = anim.isPaused ? 0 : elapsed - delay;
139
+
140
+ if (effectiveElapsed < 0) {
141
+ anim.animationId = requestAnimationFrame(animate);
142
+ return;
143
+ }
144
+
145
+ // 计算当前进度
146
+ let progress = Math.min(effectiveElapsed / adjustedDuration, 1);
147
+ progress = easingFunctions[easing](progress);
148
+
149
+ // 计算当前帧
150
+ const currentFrame = calculateFrame(progress);
151
+ anim.currentFrame = currentFrame;
152
+
153
+ setFrame(currentFrame);
154
+
155
+ // 更新图表配置以反映动画进度
156
+ try {
157
+ // ECharts 通过 setOption 配合 notMerge: false 更新动画
158
+ if (chart.setOption && !anim.isPaused) {
159
+ // 触发图表重新渲染
160
+ chart.setOption({}, false, true);
161
+ }
162
+ } catch (e) {
163
+ console.warn('[useAnimation] Failed to update chart:', e);
164
+ }
165
+
166
+ // 检查是否完成
167
+ if (effectiveElapsed >= adjustedDuration) {
168
+ if (loop && (loopCount === -1 || anim.loopCounter < loopCount)) {
169
+ // 继续循环
170
+ anim.loopCounter++;
171
+ anim.startTime = performance.now();
172
+ anim.isPaused = false;
173
+ setStatus('playing');
174
+ } else {
175
+ // 动画结束
176
+ setStatus('stopped');
177
+ anim.animationId = null;
178
+ return;
179
+ }
180
+ }
181
+
182
+ anim.animationId = requestAnimationFrame(animate);
183
+ }, [duration, easing, disabled, delay, loop, loopCount, playbackSpeed, easingFunctions, calculateFrame]);
184
+
185
+ // 播放动画
186
+ const play = useCallback(() => {
187
+ const chart = chartRef.current;
188
+ if (!chart || disabled) return;
189
+
190
+ const anim = animationRef.current;
191
+
192
+ if (status === 'paused') {
193
+ // 从暂停恢复
194
+ anim.isPaused = false;
195
+ anim.startTime = performance.now() - (anim.currentFrame / totalFrames) * duration;
196
+ setStatus('playing');
197
+ } else {
198
+ // 开始新动画
199
+ anim.startTime = performance.now();
200
+ anim.currentFrame = 0;
201
+ anim.loopCounter = 0;
202
+ anim.isPaused = false;
203
+ setStatus('playing');
204
+ }
205
+
206
+ if (anim.animationId === null) {
207
+ anim.animationId = requestAnimationFrame(animate);
208
+ }
209
+ }, [status, disabled, totalFrames, duration, animate]);
210
+
211
+ // 暂停动画
212
+ const pause = useCallback(() => {
213
+ const anim = animationRef.current;
214
+ anim.isPaused = true;
215
+ setStatus('paused');
216
+ }, []);
217
+
218
+ // 停止动画并重置
219
+ const stop = useCallback(() => {
220
+ const anim = animationRef.current;
221
+
222
+ if (anim.animationId !== null) {
223
+ cancelAnimationFrame(anim.animationId);
224
+ anim.animationId = null;
225
+ }
226
+
227
+ anim.isPaused = false;
228
+ anim.currentFrame = 0;
229
+ anim.loopCounter = 0;
230
+ setStatus('stopped');
231
+ setFrame(0);
232
+ }, []);
233
+
234
+ // 跳转到指定帧
235
+ const seekTo = useCallback(
236
+ (targetFrame: number) => {
237
+ const anim = animationRef.current;
238
+ const chart = chartRef.current;
239
+ if (!chart) return;
240
+
241
+ const clampedFrame = Math.max(0, Math.min(totalFrames, targetFrame));
242
+ anim.currentFrame = clampedFrame;
243
+ setFrame(clampedFrame);
244
+
245
+ // 更新图表状态
246
+ const progress = clampedFrame / totalFrames;
247
+ try {
248
+ if (chart.setOption) {
249
+ chart.setOption({}, false, true);
250
+ }
251
+ } catch (e) {
252
+ console.warn('[useAnimation] Failed to seek:', e);
253
+ }
254
+ },
255
+ [totalFrames]
256
+ );
257
+
258
+ // 跳转到指定进度
259
+ const seekToProgress = useCallback(
260
+ (progress: number) => {
261
+ const targetProgress = Math.max(0, Math.min(1, progress));
262
+ const targetFrame = calculateFrame(targetProgress);
263
+ seekTo(targetFrame);
264
+ },
265
+ [calculateFrame, seekTo]
266
+ );
267
+
268
+ // 进度
269
+ const progress = totalFrames > 0 ? frame / totalFrames : 0;
270
+
271
+ // 清理
272
+ useEffect(() => {
273
+ return () => {
274
+ const anim = animationRef.current;
275
+ if (anim.animationId !== null) {
276
+ cancelAnimationFrame(anim.animationId);
277
+ }
278
+ };
279
+ }, []);
280
+
281
+ // 组件卸载时停止动画
282
+ useEffect(() => {
283
+ if (!chartInstance) {
284
+ stop();
285
+ }
286
+ }, [chartInstance, stop]);
287
+
288
+ return {
289
+ status,
290
+ frame,
291
+ totalFrames,
292
+ progress,
293
+ play,
294
+ pause,
295
+ stop,
296
+ seekTo,
297
+ seekToProgress,
298
+ setPlaybackSpeed,
299
+ playbackSpeed,
300
+ };
301
+ }
302
+
303
+ /**
304
+ * 渐进式加载动画 Hook
305
+ * 用于大数据的分批次加载动画
306
+ */
307
+ export interface UseProgressiveLoadingOptions {
308
+ /** 每批加载的数据量 */
309
+ batchSize?: number;
310
+ /** 批次间隔 (ms) */
311
+ interval?: number;
312
+ /** 是否自动开始 */
313
+ autoStart?: boolean;
314
+ /** 加载完成回调 */
315
+ onBatchLoaded?: (batchIndex: number, totalBatches: number) => void;
316
+ /** 全部加载完成回调 */
317
+ onComplete?: () => void;
318
+ }
319
+
320
+ export interface UseProgressiveLoadingReturn {
321
+ /** 当前批次 */
322
+ currentBatch: number;
323
+ /** 总批次数 */
324
+ totalBatches: number;
325
+ /** 加载进度 */
326
+ progress: number;
327
+ /** 是否正在加载 */
328
+ isLoading: boolean;
329
+ /** 开始加载 */
330
+ start: () => void;
331
+ /** 暂停加载 */
332
+ pause: () => void;
333
+ /** 停止加载 */
334
+ stop: () => void;
335
+ /** 重置 */
336
+ reset: () => void;
337
+ }
338
+
339
+ export function useProgressiveLoading(
340
+ totalCount: number,
341
+ options: UseProgressiveLoadingOptions = {}
342
+ ): UseProgressiveLoadingReturn {
343
+ const {
344
+ batchSize = 1000,
345
+ interval = 100,
346
+ autoStart = false,
347
+ onBatchLoaded,
348
+ onComplete,
349
+ } = options;
350
+
351
+ const totalBatches = Math.ceil(totalCount / batchSize);
352
+ const [currentBatch, setCurrentBatch] = useState(0);
353
+ const [isLoading, setIsLoading] = useState(false);
354
+ const intervalRef = useRef<number | null>(null);
355
+
356
+ const progress = totalBatches > 0 ? currentBatch / totalBatches : 0;
357
+
358
+ const start = useCallback(() => {
359
+ if (isLoading) return;
360
+
361
+ setIsLoading(true);
362
+ setCurrentBatch(0);
363
+
364
+ intervalRef.current = window.setInterval(() => {
365
+ setCurrentBatch(prev => {
366
+ const next = prev + 1;
367
+ onBatchLoaded?.(next, totalBatches);
368
+
369
+ if (next >= totalBatches) {
370
+ if (intervalRef.current !== null) {
371
+ clearInterval(intervalRef.current);
372
+ intervalRef.current = null;
373
+ }
374
+ setIsLoading(false);
375
+ onComplete?.();
376
+ return totalBatches;
377
+ }
378
+
379
+ return next;
380
+ });
381
+ }, interval);
382
+ }, [isLoading, interval, totalBatches, onBatchLoaded, onComplete]);
383
+
384
+ const pause = useCallback(() => {
385
+ if (intervalRef.current !== null) {
386
+ clearInterval(intervalRef.current);
387
+ intervalRef.current = null;
388
+ }
389
+ setIsLoading(false);
390
+ }, []);
391
+
392
+ const stop = useCallback(() => {
393
+ pause();
394
+ setCurrentBatch(0);
395
+ }, [pause]);
396
+
397
+ const reset = useCallback(() => {
398
+ stop();
399
+ }, [stop]);
400
+
401
+ useEffect(() => {
402
+ if (autoStart) {
403
+ start();
404
+ }
405
+ }, [autoStart, start]);
406
+
407
+ useEffect(() => {
408
+ return () => {
409
+ if (intervalRef.current !== null) {
410
+ clearInterval(intervalRef.current);
411
+ }
412
+ };
413
+ }, []);
414
+
415
+ return {
416
+ currentBatch,
417
+ totalBatches,
418
+ progress,
419
+ isLoading,
420
+ start,
421
+ pause,
422
+ stop,
423
+ reset,
424
+ };
425
+ }
426
+
427
+ export default useAnimation;
@@ -0,0 +1,273 @@
1
+ /**
2
+ * useChartHistory - 图表 Undo/Redo Hook
3
+ * 追踪图表配置历史,支持撤销/重做操作和键盘快捷键
4
+ *
5
+ * 特性:
6
+ * - 最多保存 50 条历史记录(可配置)
7
+ * - 自动绑定 Ctrl+Z / Ctrl+Y 键盘快捷键
8
+ * - 支持 ignoreKeys 忽略特定配置字段(如动画、时间戳)
9
+ * - 暴露 canUndo / canRedo 状态
10
+ */
11
+ import { useEffect, useRef, useCallback, useState } from 'react';
12
+ import type { ChartInstance } from './index';
13
+ import type { EChartsOption } from 'echarts';
14
+
15
+ // ============================================================================
16
+ // 类型定义
17
+ // ============================================================================
18
+
19
+ export interface UseChartHistoryOptions {
20
+ /** 最大历史记录数,默认 50 */
21
+ maxHistorySize?: number;
22
+ /** 忽略的顶层配置键(不计入历史),默认 ['animation', 'animationDuration', 'animationEasing', 'animationFrame'] */
23
+ ignoreKeys?: string[];
24
+ /** 是否自动绑定键盘快捷键,默认 true */
25
+ enableKeyboard?: boolean;
26
+ /** 是否在组件卸载时自动清空历史,默认 false */
27
+ clearOnUnmount?: boolean;
28
+ }
29
+
30
+ export interface UseChartHistoryReturn {
31
+ /** 是否可撤销 */
32
+ canUndo: boolean;
33
+ /** 是否可重做 */
34
+ canRedo: boolean;
35
+ /** 当前历史索引 */
36
+ currentIndex: number;
37
+ /** 历史总数 */
38
+ historyCount: number;
39
+ /** 撤销一步 */
40
+ undo: () => void;
41
+ /** 重做一步 */
42
+ redo: () => void;
43
+ /** 跳转到指定历史索引 */
44
+ goTo: (index: number) => void;
45
+ /** 手动推送一条历史记录 */
46
+ push: (option: EChartsOption) => void;
47
+ /** 清空历史记录 */
48
+ clear: () => void;
49
+ }
50
+
51
+ // ============================================================================
52
+ // 工具函数
53
+ // ============================================================================
54
+
55
+ /** 深度省略指定键后比较两个配置是否相等 */
56
+ function omitAndCompare(
57
+ a: unknown,
58
+ b: unknown,
59
+ ignoreKeys: Set<string>
60
+ ): boolean {
61
+ if (a === b) return true;
62
+ if (typeof a !== 'object' || a === null || typeof b !== 'object' || b === null) {
63
+ return a === b;
64
+ }
65
+
66
+ const aObj = a as Record<string, unknown>;
67
+ const bObj = b as Record<string, unknown>;
68
+ const aKeys = Object.keys(aObj).filter((k) => !ignoreKeys.has(k));
69
+ const bKeys = Object.keys(bObj).filter((k) => !ignoreKeys.has(k));
70
+
71
+ if (aKeys.length !== bKeys.length) return false;
72
+
73
+ for (const key of aKeys) {
74
+ if (!bObj.hasOwnProperty(key)) return false;
75
+ if (!omitAndCompare(aObj[key], bObj[key], ignoreKeys)) return false;
76
+ }
77
+
78
+ return true;
79
+ }
80
+
81
+ // ============================================================================
82
+ // Hook 实现
83
+ // ============================================================================
84
+
85
+ /**
86
+ * 使用图表历史记录(Undo/Redo)
87
+ * @param chartInstance 图表实例
88
+ * @param options 配置选项
89
+ * @returns 历史记录控制接口
90
+ */
91
+ export function useChartHistory(
92
+ chartInstance: ChartInstance | null,
93
+ options: UseChartHistoryOptions = {}
94
+ ): UseChartHistoryReturn {
95
+ const {
96
+ maxHistorySize = 50,
97
+ ignoreKeys = [
98
+ 'animation',
99
+ 'animationDuration',
100
+ 'animationEasing',
101
+ 'animationFrame',
102
+ ],
103
+ enableKeyboard = true,
104
+ clearOnUnmount = false,
105
+ } = options;
106
+
107
+ // 忽略键集合
108
+ const ignoreKeySet = useRef(new Set<string>(ignoreKeys));
109
+
110
+ // 历史栈(每次 setOption 快照)
111
+ const historyStack = useRef<EChartsOption[]>([]);
112
+
113
+ // 当前索引
114
+ const [currentIndex, setCurrentIndex] = useState(-1);
115
+
116
+ // Chart instance ref
117
+ const chartRef = useRef<ChartInstance | null>(null);
118
+ chartRef.current = chartInstance;
119
+
120
+ // 是否正在执行 undo/redo(避免 push 时重复记录)
121
+ const isApplyingRef = useRef(false);
122
+
123
+ // 拦截 chart.setOption,记录历史
124
+ useEffect(() => {
125
+ const chart = chartRef.current;
126
+ if (!chart) return;
127
+
128
+ const originalSetOption = chart.setOption.bind(chart);
129
+
130
+ chart.setOption = (
131
+ option: EChartsOption,
132
+ notMerge?: boolean,
133
+ lazyUpdate?: boolean
134
+ ) => {
135
+ // 如果正在执行 undo/redo,跳过历史记录
136
+ if (isApplyingRef.current) {
137
+ return originalSetOption(option, notMerge, lazyUpdate);
138
+ }
139
+
140
+ const stack = historyStack.current;
141
+ const idx = currentIndex;
142
+
143
+ // 如果当前索引不在栈顶,丢弃redo历史(类似 Git 行为)
144
+ const newStack = idx < stack.length - 1 ? stack.slice(0, idx + 1) : [...stack];
145
+
146
+ // 检查是否与上一次配置相同(忽略动画字段)
147
+ const lastOption = newStack[newStack.length - 1];
148
+ if (lastOption && omitAndCompare(lastOption, option, ignoreKeySet.current)) {
149
+ // 配置没变,直接应用
150
+ return originalSetOption(option, notMerge, lazyUpdate);
151
+ }
152
+
153
+ // 入栈
154
+ newStack.push(option);
155
+
156
+ // 裁剪超出 maxHistorySize
157
+ if (newStack.length > maxHistorySize) {
158
+ newStack.shift();
159
+ } else {
160
+ // 更新索引
161
+ setCurrentIndex(newStack.length - 1);
162
+ }
163
+
164
+ historyStack.current = newStack;
165
+ return originalSetOption(option, notMerge, lazyUpdate);
166
+ };
167
+
168
+ return () => {
169
+ // 恢复原始 setOption
170
+ chart.setOption = originalSetOption;
171
+ };
172
+ }, [chartInstance, currentIndex, maxHistorySize]);
173
+
174
+ // 键盘快捷键:Ctrl+Z 撤销,Ctrl+Y / Ctrl+Shift+Z 重做
175
+ useEffect(() => {
176
+ if (!enableKeyboard) return;
177
+
178
+ const handleKeyDown = (e: KeyboardEvent) => {
179
+ const isMod = e.ctrlKey || e.metaKey;
180
+ if (!isMod) return;
181
+
182
+ if (e.key === 'z' && !e.shiftKey) {
183
+ e.preventDefault();
184
+ undo();
185
+ } else if (e.key === 'y' || (e.key === 'z' && e.shiftKey)) {
186
+ e.preventDefault();
187
+ redo();
188
+ }
189
+ };
190
+
191
+ window.addEventListener('keydown', handleKeyDown);
192
+ return () => window.removeEventListener('keydown', handleKeyDown);
193
+ }, [enableKeyboard]); // eslint-disable-line react-hooks/exhaustive-deps
194
+
195
+ // 组件卸载时清空
196
+ useEffect(() => {
197
+ return () => {
198
+ if (clearOnUnmount) {
199
+ historyStack.current = [];
200
+ setCurrentIndex(-1);
201
+ }
202
+ };
203
+ }, [clearOnUnmount]);
204
+
205
+ // ─── Public API ───────────────────────────────────────────────────────────
206
+
207
+ const undo = useCallback(() => {
208
+ const chart = chartRef.current;
209
+ if (!chart || currentIndex <= 0) return;
210
+
211
+ const idx = currentIndex - 1;
212
+ isApplyingRef.current = true;
213
+ chart.setOption(historyStack.current[idx], true, true);
214
+ isApplyingRef.current = false;
215
+ setCurrentIndex(idx);
216
+ }, [currentIndex]);
217
+
218
+ const redo = useCallback(() => {
219
+ const chart = chartRef.current;
220
+ if (!chart || currentIndex >= historyStack.current.length - 1) return;
221
+
222
+ const idx = currentIndex + 1;
223
+ isApplyingRef.current = true;
224
+ chart.setOption(historyStack.current[idx], true, true);
225
+ isApplyingRef.current = false;
226
+ setCurrentIndex(idx);
227
+ }, [currentIndex]);
228
+
229
+ const goTo = useCallback(
230
+ (index: number) => {
231
+ const chart = chartRef.current;
232
+ if (!chart) return;
233
+ if (index < 0 || index >= historyStack.current.length) return;
234
+ if (index === currentIndex) return;
235
+
236
+ isApplyingRef.current = true;
237
+ chart.setOption(historyStack.current[index], true, true);
238
+ isApplyingRef.current = false;
239
+ setCurrentIndex(index);
240
+ },
241
+ [currentIndex]
242
+ );
243
+
244
+ const push = useCallback((option: EChartsOption) => {
245
+ const stack = historyStack.current;
246
+ const idx = currentIndex;
247
+
248
+ const newStack = idx < stack.length - 1 ? stack.slice(0, idx + 1) : [...stack];
249
+ newStack.push(option);
250
+ if (newStack.length > maxHistorySize) newStack.shift();
251
+ historyStack.current = newStack;
252
+ setCurrentIndex(newStack.length - 1);
253
+ }, [currentIndex, maxHistorySize]);
254
+
255
+ const clear = useCallback(() => {
256
+ historyStack.current = [];
257
+ setCurrentIndex(-1);
258
+ }, []);
259
+
260
+ return {
261
+ canUndo: currentIndex > 0,
262
+ canRedo: currentIndex < historyStack.current.length - 1,
263
+ currentIndex,
264
+ historyCount: historyStack.current.length,
265
+ undo,
266
+ redo,
267
+ goTo,
268
+ push,
269
+ clear,
270
+ };
271
+ }
272
+
273
+ export default useChartHistory;