@agions/taroviz 1.9.0 → 1.11.1
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.
- package/README.md +35 -5
- package/dist/cjs/index.js +1 -1
- package/dist/esm/index.js +1128 -432
- package/package.json +1 -1
- package/src/charts/boxplot/types.ts +5 -3
- package/src/charts/candlestick/__tests__/index.test.tsx +4 -1
- package/src/charts/graph/__tests__/index.test.tsx +8 -2
- package/src/charts/parallel/types.ts +6 -3
- package/src/charts/tree/types.ts +4 -4
- package/src/charts/wordcloud/__tests__/index.test.tsx +4 -1
- package/src/core/animation/AnimationManager.ts +9 -6
- package/src/core/animation/types.ts +30 -0
- package/src/core/components/Annotation.tsx +12 -10
- package/src/core/components/BaseChart.tsx +41 -31
- package/src/core/components/ErrorBoundary.tsx +30 -17
- package/src/core/components/LazyChart.tsx +36 -9
- package/src/core/themes/ThemeManager.ts +33 -0
- package/src/core/types/common.ts +100 -5
- package/src/core/utils/chartUtils.ts +8 -3
- package/src/core/utils/export/ExportUtils.ts +39 -9
- package/src/core/utils/performance/PerformanceAnalyzer.ts +15 -5
- package/src/core/utils/performance/types.ts +10 -1
- package/src/hooks/index.ts +53 -0
- package/src/hooks/useChartDownload.ts +17 -261
- package/src/hooks/useChartHistory.ts +273 -0
- package/src/hooks/useChartSelection.ts +350 -0
- package/src/hooks/useDataTransform.ts +39 -286
- package/src/hooks/utils/chartDownloadUtils.ts +273 -0
- package/src/hooks/utils/dataTransformUtils.ts +287 -0
|
@@ -4,6 +4,16 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { useRef, useCallback } from 'react';
|
|
6
6
|
import type { ChartInstance } from './index';
|
|
7
|
+
import {
|
|
8
|
+
generateFilename,
|
|
9
|
+
downloadBlob,
|
|
10
|
+
downloadDataUrl,
|
|
11
|
+
csvToBlob,
|
|
12
|
+
jsonToBlob,
|
|
13
|
+
convertToCSV,
|
|
14
|
+
convertToJSON,
|
|
15
|
+
createPdfFromImage,
|
|
16
|
+
} from './utils/chartDownloadUtils';
|
|
7
17
|
|
|
8
18
|
// ============================================================================
|
|
9
19
|
// 类型定义
|
|
@@ -60,198 +70,13 @@ export interface UseChartDownloadReturn {
|
|
|
60
70
|
/** 获取图片 DataURL */
|
|
61
71
|
getImageDataUrl: (format?: 'png' | 'jpeg' | 'svg') => string | undefined;
|
|
62
72
|
/** 获取图表数据 */
|
|
63
|
-
getChartData: () =>
|
|
73
|
+
getChartData: () => unknown;
|
|
64
74
|
/** 获取 SVG 数据 */
|
|
65
75
|
getSvgData: () => string | undefined;
|
|
66
76
|
/** 直接导出(自动选择格式) */
|
|
67
77
|
exportChart: (options?: Partial<UseChartDownloadOptions>) => Promise<void>;
|
|
68
78
|
}
|
|
69
79
|
|
|
70
|
-
// ============================================================================
|
|
71
|
-
// 工具函数
|
|
72
|
-
// ============================================================================
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* 生成默认文件名
|
|
76
|
-
*/
|
|
77
|
-
function generateFilename(prefix: string = 'chart'): string {
|
|
78
|
-
const timestamp = new Date().toISOString().slice(0, 19).replace(/[:-]/g, '');
|
|
79
|
-
return `${prefix}_${timestamp}`;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* 下载 Blob 对象
|
|
84
|
-
*/
|
|
85
|
-
function downloadBlob(blob: Blob, filename: string): void {
|
|
86
|
-
const url = URL.createObjectURL(blob);
|
|
87
|
-
const link = document.createElement('a');
|
|
88
|
-
link.href = url;
|
|
89
|
-
link.download = filename;
|
|
90
|
-
document.body.appendChild(link);
|
|
91
|
-
link.click();
|
|
92
|
-
document.body.removeChild(link);
|
|
93
|
-
URL.revokeObjectURL(url);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* 下载数据 URL
|
|
98
|
-
*/
|
|
99
|
-
function downloadDataUrl(dataUrl: string, filename: string): void {
|
|
100
|
-
const link = document.createElement('a');
|
|
101
|
-
link.href = dataUrl;
|
|
102
|
-
link.download = filename;
|
|
103
|
-
document.body.appendChild(link);
|
|
104
|
-
link.click();
|
|
105
|
-
document.body.removeChild(link);
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* CSV 转 Blob
|
|
110
|
-
*/
|
|
111
|
-
function csvToBlob(csv: string, filename: string): Blob {
|
|
112
|
-
return new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
/**
|
|
116
|
-
* JSON 转 Blob
|
|
117
|
-
*/
|
|
118
|
-
function jsonToBlob(json: string, filename: string): Blob {
|
|
119
|
-
return new Blob([json], { type: 'application/json;charset=utf-8;' });
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/**
|
|
123
|
-
* 将数据转换为 CSV 格式
|
|
124
|
-
*/
|
|
125
|
-
function convertToCSV(data: any, options?: { includeLabels?: boolean }): string {
|
|
126
|
-
if (!data) return '';
|
|
127
|
-
|
|
128
|
-
// 处理 ECharts 格式的数据
|
|
129
|
-
if (data.series) {
|
|
130
|
-
return convertSeriesToCSV(data, options);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
// 处理数组数据
|
|
134
|
-
if (Array.isArray(data)) {
|
|
135
|
-
return convertArrayToCSV(data);
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
// 处理普通对象
|
|
139
|
-
if (typeof data === 'object') {
|
|
140
|
-
return convertObjectToCSV(data);
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
return String(data);
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/**
|
|
147
|
-
* 将 ECharts series 数据转换为 CSV
|
|
148
|
-
*/
|
|
149
|
-
function convertSeriesToCSV(chartData: any, options?: { includeLabels?: boolean }): string {
|
|
150
|
-
const { series = [], xAxis, dataset } = chartData;
|
|
151
|
-
const includeLabels = options?.includeLabels ?? true;
|
|
152
|
-
|
|
153
|
-
if (series.length === 0) return '';
|
|
154
|
-
|
|
155
|
-
// 获取类别轴数据
|
|
156
|
-
let categories: any[] = [];
|
|
157
|
-
if (xAxis?.data) {
|
|
158
|
-
categories = xAxis.data;
|
|
159
|
-
} else if (dataset?.dimensions && dataset.source) {
|
|
160
|
-
categories = dataset.source.map((row: any[]) => row[0]);
|
|
161
|
-
} else if (series[0]?.data) {
|
|
162
|
-
categories = series[0].data.map((item: any, index: number) =>
|
|
163
|
-
typeof item === 'object' ? item[0] || index : index
|
|
164
|
-
);
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
// 构建 CSV 头
|
|
168
|
-
const headers = includeLabels ? ['Category', ...series.map((s: any) => s.name || s.seriesIndex)] : [];
|
|
169
|
-
|
|
170
|
-
// 构建 CSV 行
|
|
171
|
-
const rows: string[][] = [];
|
|
172
|
-
|
|
173
|
-
series.forEach((s: any, seriesIndex: number) => {
|
|
174
|
-
const seriesData = s.data || [];
|
|
175
|
-
seriesData.forEach((item: any, dataIndex: number) => {
|
|
176
|
-
const value = typeof item === 'object' ? item[1] : item;
|
|
177
|
-
const category = categories[dataIndex] || dataIndex;
|
|
178
|
-
|
|
179
|
-
if (includeLabels) {
|
|
180
|
-
if (seriesIndex === 0) {
|
|
181
|
-
rows[dataIndex] = [category, value];
|
|
182
|
-
} else {
|
|
183
|
-
rows[dataIndex] = rows[dataIndex] || [category];
|
|
184
|
-
rows[dataIndex].push(value);
|
|
185
|
-
}
|
|
186
|
-
} else {
|
|
187
|
-
if (seriesIndex === 0) {
|
|
188
|
-
rows[dataIndex] = [value];
|
|
189
|
-
} else {
|
|
190
|
-
rows[dataIndex] = rows[dataIndex] || [];
|
|
191
|
-
rows[dataIndex].push(value);
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
});
|
|
195
|
-
});
|
|
196
|
-
|
|
197
|
-
// 生成 CSV 字符串
|
|
198
|
-
const csvRows = includeLabels
|
|
199
|
-
? [headers.join(','), ...rows.map((row) => row.join(','))]
|
|
200
|
-
: rows.map((row) => row.join(','));
|
|
201
|
-
|
|
202
|
-
return csvRows.join('\n');
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
/**
|
|
206
|
-
* 将数组数据转换为 CSV
|
|
207
|
-
*/
|
|
208
|
-
function convertArrayToCSV(data: any[]): string {
|
|
209
|
-
if (data.length === 0) return '';
|
|
210
|
-
|
|
211
|
-
// 检查是否为对象数组
|
|
212
|
-
if (typeof data[0] === 'object' && data[0] !== null) {
|
|
213
|
-
const keys = Object.keys(data[0]);
|
|
214
|
-
const headers = keys.join(',');
|
|
215
|
-
const rows = data.map((item) => keys.map((key) => JSON.stringify(item[key] ?? '')).join(','));
|
|
216
|
-
return [headers, ...rows].join('\n');
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
// 简单数组
|
|
220
|
-
return data.join('\n');
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
/**
|
|
224
|
-
* 将对象数据转换为 CSV
|
|
225
|
-
*/
|
|
226
|
-
function convertObjectToCSV(data: Record<string, any>): string {
|
|
227
|
-
const entries = Object.entries(data);
|
|
228
|
-
return entries.map(([key, value]) => `${key},${JSON.stringify(value)}`).join('\n');
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
/**
|
|
232
|
-
* 将 ECharts 数据转换为 JSON
|
|
233
|
-
*/
|
|
234
|
-
function convertToJSON(data: any): string {
|
|
235
|
-
if (!data) return '{}';
|
|
236
|
-
|
|
237
|
-
// 如果是 ECharts 格式,简化数据
|
|
238
|
-
if (data.series) {
|
|
239
|
-
const simplified: any = {
|
|
240
|
-
title: data.title?.text,
|
|
241
|
-
legend: data.legend?.data,
|
|
242
|
-
xAxis: data.xAxis?.data,
|
|
243
|
-
series: data.series.map((s: any) => ({
|
|
244
|
-
name: s.name,
|
|
245
|
-
type: s.type,
|
|
246
|
-
data: s.data?.map((item: any) => (typeof item === 'object' ? item[1] : item)),
|
|
247
|
-
})),
|
|
248
|
-
};
|
|
249
|
-
return JSON.stringify(simplified, null, 2);
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
return JSON.stringify(data, null, 2);
|
|
253
|
-
}
|
|
254
|
-
|
|
255
80
|
// ============================================================================
|
|
256
81
|
// Hook 实现
|
|
257
82
|
// ============================================================================
|
|
@@ -442,7 +267,7 @@ export function useChartDownload(
|
|
|
442
267
|
|
|
443
268
|
// 创建 PDF(使用 canvas 转换)
|
|
444
269
|
// 注意:在实际环境中,可能需要使用 jsPDF 或其他 PDF 库
|
|
445
|
-
const pdfDataUrl = await createPdfFromImage(dataUrl);
|
|
270
|
+
const pdfDataUrl = await createPdfFromImage(dataUrl, name);
|
|
446
271
|
if (pdfDataUrl) {
|
|
447
272
|
downloadDataUrl(pdfDataUrl, `${name}.pdf`);
|
|
448
273
|
executeAfterExport(pdfDataUrl);
|
|
@@ -454,79 +279,10 @@ export function useChartDownload(
|
|
|
454
279
|
[filename, pixelRatio, backgroundColor, executeBeforeExport, executeAfterExport]
|
|
455
280
|
);
|
|
456
281
|
|
|
457
|
-
/**
|
|
458
|
-
* 从图片创建 PDF DataURL
|
|
459
|
-
*/
|
|
460
|
-
const createPdfFromImage = async (imageDataUrl: string): Promise<string | null> => {
|
|
461
|
-
return new Promise((resolve) => {
|
|
462
|
-
try {
|
|
463
|
-
// 创建画布
|
|
464
|
-
const canvas = document.createElement('canvas');
|
|
465
|
-
const ctx = canvas.getContext('2d');
|
|
466
|
-
if (!ctx) {
|
|
467
|
-
resolve(null);
|
|
468
|
-
return;
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
// 加载图片
|
|
472
|
-
const img = new Image();
|
|
473
|
-
img.onload = () => {
|
|
474
|
-
// 设置 PDF 尺寸(A4 纵向)
|
|
475
|
-
const pdfWidth = 595.28; // A4 width in points
|
|
476
|
-
const pdfHeight = 841.89; // A4 height in points
|
|
477
|
-
|
|
478
|
-
canvas.width = pdfWidth;
|
|
479
|
-
canvas.height = pdfHeight;
|
|
480
|
-
|
|
481
|
-
// 填充背景
|
|
482
|
-
ctx.fillStyle = '#ffffff';
|
|
483
|
-
ctx.fillRect(0, 0, pdfWidth, pdfHeight);
|
|
484
|
-
|
|
485
|
-
// 计算图片位置和尺寸(居中)
|
|
486
|
-
const imgRatio = img.width / img.height;
|
|
487
|
-
const canvasRatio = pdfWidth / pdfHeight;
|
|
488
|
-
let drawWidth, drawHeight, offsetX, offsetY;
|
|
489
|
-
|
|
490
|
-
if (imgRatio > canvasRatio) {
|
|
491
|
-
drawWidth = pdfWidth * 0.8;
|
|
492
|
-
drawHeight = drawWidth / imgRatio;
|
|
493
|
-
} else {
|
|
494
|
-
drawHeight = pdfHeight * 0.6;
|
|
495
|
-
drawWidth = drawHeight * imgRatio;
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
offsetX = (pdfWidth - drawWidth) / 2;
|
|
499
|
-
offsetY = (pdfHeight - drawHeight) / 2;
|
|
500
|
-
|
|
501
|
-
// 绘制图片
|
|
502
|
-
ctx.drawImage(img, offsetX, offsetY, drawWidth, drawHeight);
|
|
503
|
-
|
|
504
|
-
// 添加标题
|
|
505
|
-
ctx.fillStyle = '#333333';
|
|
506
|
-
ctx.font = '16px Arial';
|
|
507
|
-
ctx.textAlign = 'center';
|
|
508
|
-
ctx.fillText(filename || 'Chart Export', pdfWidth / 2, offsetY - 20);
|
|
509
|
-
|
|
510
|
-
// 输出为 PNG(实际应用中应该使用 jsPDF 生成真正的 PDF)
|
|
511
|
-
resolve(canvas.toDataURL('image/png'));
|
|
512
|
-
};
|
|
513
|
-
|
|
514
|
-
img.onerror = () => {
|
|
515
|
-
resolve(null);
|
|
516
|
-
};
|
|
517
|
-
|
|
518
|
-
img.src = imageDataUrl;
|
|
519
|
-
} catch (e) {
|
|
520
|
-
console.warn('[useChartDownload] Failed to create PDF:', e);
|
|
521
|
-
resolve(null);
|
|
522
|
-
}
|
|
523
|
-
});
|
|
524
|
-
};
|
|
525
|
-
|
|
526
282
|
/**
|
|
527
283
|
* 获取图表数据
|
|
528
284
|
*/
|
|
529
|
-
const getChartData = useCallback(():
|
|
285
|
+
const getChartData = useCallback((): unknown => {
|
|
530
286
|
const chart = chartRef.current;
|
|
531
287
|
if (!chart) return null;
|
|
532
288
|
|
|
@@ -577,9 +333,9 @@ export function useChartDownload(
|
|
|
577
333
|
}
|
|
578
334
|
|
|
579
335
|
// 提取数据
|
|
580
|
-
let data = option;
|
|
336
|
+
let data: unknown = option;
|
|
581
337
|
if (dataKey && typeof data === 'object') {
|
|
582
|
-
data = (data as
|
|
338
|
+
data = (data as Record<string, unknown>)[dataKey] || data;
|
|
583
339
|
}
|
|
584
340
|
|
|
585
341
|
// 转换为 CSV
|
|
@@ -621,9 +377,9 @@ export function useChartDownload(
|
|
|
621
377
|
}
|
|
622
378
|
|
|
623
379
|
// 提取数据
|
|
624
|
-
let data = option;
|
|
380
|
+
let data: unknown = option;
|
|
625
381
|
if (dataKey && typeof data === 'object') {
|
|
626
|
-
data = (data as
|
|
382
|
+
data = (data as Record<string, unknown>)[dataKey] || data;
|
|
627
383
|
}
|
|
628
384
|
|
|
629
385
|
// 转换为 JSON
|
|
@@ -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;
|