@agions/taroviz 1.6.0 → 1.7.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.
@@ -0,0 +1,692 @@
1
+ /**
2
+ * useChartDownload - 图表下载 Hook
3
+ * 支持下载图表为图片(PNG/JPEG/SVG/PDF)或原始数据(CSV/JSON)
4
+ */
5
+ import { useRef, useCallback } from 'react';
6
+ import type { ChartInstance } from './index';
7
+
8
+ // ============================================================================
9
+ // 类型定义
10
+ // ============================================================================
11
+
12
+ /** 下载格式 */
13
+ export type DownloadFormat = 'png' | 'jpeg' | 'svg' | 'pdf';
14
+
15
+ /** 图片下载选项 */
16
+ export interface UseChartDownloadOptions {
17
+ /** 文件名(不含扩展名) */
18
+ filename?: string;
19
+ /** 图片像素比,默认2 */
20
+ pixelRatio?: number;
21
+ /** 背景色 */
22
+ backgroundColor?: string;
23
+ /** 默认格式 */
24
+ format?: DownloadFormat;
25
+ /** 是否包含标签 */
26
+ includeLabels?: boolean;
27
+ /** 图表实例 */
28
+ instance?: ChartInstance | null;
29
+ /** 导出前回调 */
30
+ beforeExport?: (instance: ChartInstance) => void;
31
+ /** 导出后回调 */
32
+ afterExport?: (blob: Blob | string) => void;
33
+ }
34
+
35
+ /** 图片下载选项 */
36
+ export interface DownloadImageOptions {
37
+ filename?: string;
38
+ pixelRatio?: number;
39
+ backgroundColor?: string;
40
+ format?: 'png' | 'jpeg' | 'svg';
41
+ }
42
+
43
+ /** 数据下载选项 */
44
+ export interface DownloadDataOptions {
45
+ filename?: string;
46
+ /** 数据键名(用于 JSON 导出) */
47
+ dataKey?: string;
48
+ }
49
+
50
+ /** 下载返回值 */
51
+ export interface UseChartDownloadReturn {
52
+ /** 下载图片 */
53
+ downloadImage: (options?: Partial<DownloadImageOptions>) => Promise<void>;
54
+ /** 下载 CSV */
55
+ downloadCSV: (options?: Partial<DownloadDataOptions>) => Promise<void>;
56
+ /** 下载 JSON */
57
+ downloadJSON: (options?: Partial<DownloadDataOptions>) => Promise<void>;
58
+ /** 下载 PDF */
59
+ downloadPDF: (options?: Partial<DownloadImageOptions>) => Promise<void>;
60
+ /** 获取图片 DataURL */
61
+ getImageDataUrl: (format?: 'png' | 'jpeg' | 'svg') => string | undefined;
62
+ /** 获取图表数据 */
63
+ getChartData: () => any;
64
+ /** 获取 SVG 数据 */
65
+ getSvgData: () => string | undefined;
66
+ /** 直接导出(自动选择格式) */
67
+ exportChart: (options?: Partial<UseChartDownloadOptions>) => Promise<void>;
68
+ }
69
+
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
+ // ============================================================================
256
+ // Hook 实现
257
+ // ============================================================================
258
+
259
+ /**
260
+ * 使用图表下载
261
+ * @param instance 图表实例
262
+ * @param options 默认选项
263
+ * @returns 下载操作接口
264
+ */
265
+ export function useChartDownload(
266
+ instance: ChartInstance | null,
267
+ options: UseChartDownloadOptions = {}
268
+ ): UseChartDownloadReturn {
269
+ const {
270
+ filename,
271
+ pixelRatio = 2,
272
+ backgroundColor = '#ffffff',
273
+ format = 'png',
274
+ includeLabels = true,
275
+ beforeExport,
276
+ afterExport,
277
+ } = options;
278
+
279
+ // Refs
280
+ const chartRef = useRef<ChartInstance | null>(null);
281
+ chartRef.current = instance;
282
+
283
+ /**
284
+ * 执行导出前回调
285
+ */
286
+ const executeBeforeExport = useCallback(() => {
287
+ const chart = chartRef.current;
288
+ if (chart && beforeExport) {
289
+ try {
290
+ beforeExport(chart);
291
+ } catch (e) {
292
+ console.warn('[useChartDownload] beforeExport error:', e);
293
+ }
294
+ }
295
+ }, [beforeExport]);
296
+
297
+ /**
298
+ * 执行导出后回调
299
+ */
300
+ const executeAfterExport = useCallback(
301
+ (result: Blob | string) => {
302
+ if (afterExport) {
303
+ try {
304
+ afterExport(result);
305
+ } catch (e) {
306
+ console.warn('[useChartDownload] afterExport error:', e);
307
+ }
308
+ }
309
+ },
310
+ [afterExport]
311
+ );
312
+
313
+ /**
314
+ * 获取图片 DataURL
315
+ */
316
+ const getImageDataUrl = useCallback(
317
+ (imgFormat?: 'png' | 'jpeg' | 'svg'): string | undefined => {
318
+ const chart = chartRef.current;
319
+ if (!chart) return undefined;
320
+
321
+ const fmt = imgFormat || format;
322
+
323
+ try {
324
+ // SVG 格式
325
+ if (fmt === 'svg') {
326
+ const svgData = chart.getSvgData?.();
327
+ if (svgData) {
328
+ return `data:image/svg+xml;base64,${btoa(svgData)}`;
329
+ }
330
+ return undefined;
331
+ }
332
+
333
+ // PNG/JPEG 格式
334
+ if (chart.getDataURL) {
335
+ return chart.getDataURL({
336
+ type: fmt,
337
+ pixelRatio,
338
+ backgroundColor,
339
+ });
340
+ }
341
+
342
+ console.warn('[useChartDownload] getDataURL not supported');
343
+ return undefined;
344
+ } catch (e) {
345
+ console.warn('[useChartDownload] Failed to get image data URL:', e);
346
+ return undefined;
347
+ }
348
+ },
349
+ [format, pixelRatio, backgroundColor]
350
+ );
351
+
352
+ /**
353
+ * 下载图片
354
+ */
355
+ const downloadImage = useCallback(
356
+ async (downloadOptions?: Partial<DownloadImageOptions>): Promise<void> => {
357
+ executeBeforeExport();
358
+
359
+ const chart = chartRef.current;
360
+ if (!chart) {
361
+ console.warn('[useChartDownload] No chart instance available');
362
+ return;
363
+ }
364
+
365
+ const {
366
+ filename: customFilename,
367
+ pixelRatio: customPixelRatio,
368
+ backgroundColor: customBg,
369
+ format: customFormat,
370
+ } = downloadOptions || {};
371
+
372
+ const fmt = customFormat || format;
373
+ const ratio = customPixelRatio ?? pixelRatio;
374
+ const bg = customBg ?? backgroundColor;
375
+ const name = customFilename || filename || generateFilename('chart');
376
+
377
+ try {
378
+ let dataUrl: string | undefined;
379
+
380
+ if (fmt === 'svg') {
381
+ const svgData = chart.getSvgData?.();
382
+ if (svgData) {
383
+ dataUrl = `data:image/svg+xml;base64,${btoa(svgData)}`;
384
+ }
385
+ } else {
386
+ dataUrl = chart.getDataURL?.({
387
+ type: fmt,
388
+ pixelRatio: ratio,
389
+ backgroundColor: bg,
390
+ });
391
+ }
392
+
393
+ if (dataUrl) {
394
+ downloadDataUrl(dataUrl, `${name}.${fmt}`);
395
+ executeAfterExport(dataUrl);
396
+ } else {
397
+ console.warn('[useChartDownload] Failed to generate image data URL');
398
+ }
399
+ } catch (e) {
400
+ console.warn('[useChartDownload] Failed to download image:', e);
401
+ }
402
+ },
403
+ [format, pixelRatio, backgroundColor, filename, executeBeforeExport, executeAfterExport]
404
+ );
405
+
406
+ /**
407
+ * 下载 PDF
408
+ * 注意:PDF 导出需要额外的库支持,这里提供基础实现
409
+ */
410
+ const downloadPDF = useCallback(
411
+ async (downloadOptions?: Partial<DownloadImageOptions>): Promise<void> => {
412
+ executeBeforeExport();
413
+
414
+ const chart = chartRef.current;
415
+ if (!chart) {
416
+ console.warn('[useChartDownload] No chart instance available');
417
+ return;
418
+ }
419
+
420
+ const {
421
+ filename: customFilename,
422
+ pixelRatio: customPixelRatio,
423
+ backgroundColor: customBg,
424
+ } = downloadOptions || {};
425
+
426
+ const ratio = customPixelRatio ?? pixelRatio;
427
+ const bg = customBg ?? backgroundColor;
428
+ const name = customFilename || filename || generateFilename('chart');
429
+
430
+ try {
431
+ // 获取图片数据
432
+ const dataUrl = chart.getDataURL?.({
433
+ type: 'png',
434
+ pixelRatio: ratio,
435
+ backgroundColor: bg,
436
+ });
437
+
438
+ if (!dataUrl) {
439
+ console.warn('[useChartDownload] Failed to get image data for PDF');
440
+ return;
441
+ }
442
+
443
+ // 创建 PDF(使用 canvas 转换)
444
+ // 注意:在实际环境中,可能需要使用 jsPDF 或其他 PDF 库
445
+ const pdfDataUrl = await createPdfFromImage(dataUrl);
446
+ if (pdfDataUrl) {
447
+ downloadDataUrl(pdfDataUrl, `${name}.pdf`);
448
+ executeAfterExport(pdfDataUrl);
449
+ }
450
+ } catch (e) {
451
+ console.warn('[useChartDownload] Failed to download PDF:', e);
452
+ }
453
+ },
454
+ [filename, pixelRatio, backgroundColor, executeBeforeExport, executeAfterExport]
455
+ );
456
+
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
+ /**
527
+ * 获取图表数据
528
+ */
529
+ const getChartData = useCallback((): any => {
530
+ const chart = chartRef.current;
531
+ if (!chart) return null;
532
+
533
+ try {
534
+ return chart.getOption?.() || null;
535
+ } catch (e) {
536
+ console.warn('[useChartDownload] Failed to get chart data:', e);
537
+ return null;
538
+ }
539
+ }, []);
540
+
541
+ /**
542
+ * 获取 SVG 数据
543
+ */
544
+ const getSvgData = useCallback((): string | undefined => {
545
+ const chart = chartRef.current;
546
+ if (!chart) return undefined;
547
+
548
+ try {
549
+ return chart.getSvgData?.();
550
+ } catch (e) {
551
+ console.warn('[useChartDownload] Failed to get SVG data:', e);
552
+ return undefined;
553
+ }
554
+ }, []);
555
+
556
+ /**
557
+ * 下载 CSV
558
+ */
559
+ const downloadCSV = useCallback(
560
+ async (downloadOptions?: Partial<DownloadDataOptions>): Promise<void> => {
561
+ executeBeforeExport();
562
+
563
+ const chart = chartRef.current;
564
+ if (!chart) {
565
+ console.warn('[useChartDownload] No chart instance available');
566
+ return;
567
+ }
568
+
569
+ const { filename: customFilename, dataKey } = downloadOptions || {};
570
+ const name = customFilename || filename || generateFilename('data');
571
+
572
+ try {
573
+ const option = chart.getOption?.();
574
+ if (!option) {
575
+ console.warn('[useChartDownload] No chart data available');
576
+ return;
577
+ }
578
+
579
+ // 提取数据
580
+ let data = option;
581
+ if (dataKey && typeof data === 'object') {
582
+ data = (data as any)[dataKey] || data;
583
+ }
584
+
585
+ // 转换为 CSV
586
+ const csv = convertToCSV(data, { includeLabels });
587
+
588
+ if (csv) {
589
+ const blob = csvToBlob(csv, `${name}.csv`);
590
+ downloadBlob(blob, `${name}.csv`);
591
+ executeAfterExport(blob);
592
+ }
593
+ } catch (e) {
594
+ console.warn('[useChartDownload] Failed to download CSV:', e);
595
+ }
596
+ },
597
+ [filename, executeBeforeExport, executeAfterExport]
598
+ );
599
+
600
+ /**
601
+ * 下载 JSON
602
+ */
603
+ const downloadJSON = useCallback(
604
+ async (downloadOptions?: Partial<DownloadDataOptions>): Promise<void> => {
605
+ executeBeforeExport();
606
+
607
+ const chart = chartRef.current;
608
+ if (!chart) {
609
+ console.warn('[useChartDownload] No chart instance available');
610
+ return;
611
+ }
612
+
613
+ const { filename: customFilename, dataKey } = downloadOptions || {};
614
+ const name = customFilename || filename || generateFilename('data');
615
+
616
+ try {
617
+ const option = chart.getOption?.();
618
+ if (!option) {
619
+ console.warn('[useChartDownload] No chart data available');
620
+ return;
621
+ }
622
+
623
+ // 提取数据
624
+ let data = option;
625
+ if (dataKey && typeof data === 'object') {
626
+ data = (data as any)[dataKey] || data;
627
+ }
628
+
629
+ // 转换为 JSON
630
+ const json = convertToJSON(data);
631
+
632
+ if (json) {
633
+ const blob = jsonToBlob(json, `${name}.json`);
634
+ downloadBlob(blob, `${name}.json`);
635
+ executeAfterExport(blob);
636
+ }
637
+ } catch (e) {
638
+ console.warn('[useChartDownload] Failed to download JSON:', e);
639
+ }
640
+ },
641
+ [filename, executeBeforeExport, executeAfterExport]
642
+ );
643
+
644
+ /**
645
+ * 直接导出(自动选择格式)
646
+ */
647
+ const exportChart = useCallback(
648
+ async (exportOptions?: Partial<UseChartDownloadOptions>): Promise<void> => {
649
+ const {
650
+ format: exportFormat,
651
+ filename: exportFilename,
652
+ pixelRatio: exportRatio,
653
+ backgroundColor: exportBg,
654
+ } = exportOptions || {};
655
+
656
+ const fmt = exportFormat || format;
657
+
658
+ switch (fmt) {
659
+ case 'png':
660
+ case 'jpeg':
661
+ await downloadImage({ format: fmt, filename: exportFilename, pixelRatio: exportRatio, backgroundColor: exportBg });
662
+ break;
663
+ case 'svg':
664
+ await downloadImage({ format: 'svg', filename: exportFilename });
665
+ break;
666
+ case 'pdf':
667
+ await downloadPDF({ filename: exportFilename, pixelRatio: exportRatio, backgroundColor: exportBg });
668
+ break;
669
+ default:
670
+ console.warn(`[useChartDownload] Unsupported format: ${fmt}`);
671
+ }
672
+ },
673
+ [format, downloadImage, downloadPDF]
674
+ );
675
+
676
+ return {
677
+ downloadImage,
678
+ downloadCSV,
679
+ downloadJSON,
680
+ downloadPDF,
681
+ getImageDataUrl,
682
+ getChartData,
683
+ getSvgData,
684
+ exportChart,
685
+ };
686
+ }
687
+
688
+ // ============================================================================
689
+ // 导出
690
+ // ============================================================================
691
+
692
+ export default useChartDownload;