Skip to content

Commit b5c0678

Browse files
authored
feat: add chart export options (#41)
* feat: add chart export options * style: replace chart action buttons with icons
1 parent 5d8e806 commit b5c0678

File tree

3 files changed

+130
-4
lines changed

3 files changed

+130
-4
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,15 @@
2121
- 🔬 **对比分析**:提供 Normal、Absolute、Relative 等多种差值模式,并计算统计指标。
2222
- 🎛️ **灵活展示**:可独立启用或禁用文件和图表,支持调整数据范围与图表尺寸,图表高度可拖拽调整。
2323
-**易用特性**:支持匹配预览、智能推荐解析规则,图表 Shift+拖动可快速缩放。
24+
- 💾 **数据导出**:每个图表均支持导出 PNG、复制图片到剪贴板以及导出 CSV 数据。
2425

2526
## 快速上手 ⚡
2627

2728
1. 将训练日志文件拖拽到页面任意位置。
2829
2. 在弹出的配置面板中选择解析方式(关键字或正则),并可添加自定义指标。
2930
3. 查看自动生成的图表,必要时可上传第二个文件进行对比分析。
3031
4. 调整图表显示或数据范围以获得更精确的结果。
32+
5. 使用图表右上角的按钮导出 PNG、复制图片或下载 CSV 数据。
3133

3234
## 部署
3335

src/components/ChartContainer.jsx

Lines changed: 121 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
Legend,
1414
} from 'chart.js';
1515
import zoomPlugin from 'chartjs-plugin-zoom';
16+
import { ImageDown, Copy, FileDown } from 'lucide-react';
1617
import { getMinSteps } from "../utils/getMinSteps.js";
1718

1819
ChartJS.register(
@@ -103,6 +104,59 @@ export default function ChartContainer({
103104
chartRefs.current.set(id, inst);
104105
}, []);
105106

107+
const exportChartPNG = useCallback((id) => {
108+
const chart = chartRefs.current.get(id);
109+
if (!chart) return;
110+
const url = chart.toBase64Image();
111+
const link = document.createElement('a');
112+
link.href = url;
113+
link.download = `${id}.png`;
114+
link.click();
115+
}, []);
116+
117+
const copyChartImage = useCallback(async (id) => {
118+
const chart = chartRefs.current.get(id);
119+
if (!chart || !navigator?.clipboard) return;
120+
const url = chart.toBase64Image();
121+
const res = await fetch(url);
122+
const blob = await res.blob();
123+
try {
124+
await navigator.clipboard.write([
125+
new ClipboardItem({ 'image/png': blob })
126+
]);
127+
} catch (e) {
128+
console.error('复制图片失败', e);
129+
}
130+
}, []);
131+
132+
const exportChartCSV = useCallback((id) => {
133+
const chart = chartRefs.current.get(id);
134+
if (!chart) return;
135+
const datasets = chart.data.datasets || [];
136+
const xValues = new Set();
137+
datasets.forEach(ds => {
138+
(ds.data || []).forEach(p => xValues.add(p.x));
139+
});
140+
const sortedX = Array.from(xValues).sort((a, b) => a - b);
141+
const header = ['step', ...datasets.map(ds => ds.label || '')];
142+
const rows = sortedX.map(x => {
143+
const cols = [x];
144+
datasets.forEach(ds => {
145+
const pt = (ds.data || []).find(p => p.x === x);
146+
cols.push(pt ? pt.y : '');
147+
});
148+
return cols.join(',');
149+
});
150+
const csv = [header.join(','), ...rows].join('\n');
151+
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
152+
const url = URL.createObjectURL(blob);
153+
const link = document.createElement('a');
154+
link.href = url;
155+
link.download = `${id}.csv`;
156+
link.click();
157+
URL.revokeObjectURL(url);
158+
}, []);
159+
106160
const syncHoverToAllCharts = useCallback((step, sourceId) => {
107161
if (syncLockRef.current) return;
108162
syncLockRef.current = true;
@@ -611,8 +665,39 @@ export default function ChartContainer({
611665
y: { ...chartOptions.scales.y, min: compRange.min, max: compRange.max }
612666
}
613667
};
668+
const compActions = (
669+
<>
670+
<button
671+
type="button"
672+
className="p-1 rounded-md text-gray-600 hover:text-blue-600 hover:bg-gray-100"
673+
onClick={() => exportChartPNG(`metric-comp-${idx}`)}
674+
aria-label="导出 PNG"
675+
title="导出 PNG"
676+
>
677+
<ImageDown size={16} />
678+
</button>
679+
<button
680+
type="button"
681+
className="p-1 rounded-md text-gray-600 hover:text-blue-600 hover:bg-gray-100"
682+
onClick={() => copyChartImage(`metric-comp-${idx}`)}
683+
aria-label="复制图片"
684+
title="复制图片"
685+
>
686+
<Copy size={16} />
687+
</button>
688+
<button
689+
type="button"
690+
className="p-1 rounded-md text-gray-600 hover:text-blue-600 hover:bg-gray-100"
691+
onClick={() => exportChartCSV(`metric-comp-${idx}`)}
692+
aria-label="导出 CSV"
693+
title="导出 CSV"
694+
>
695+
<FileDown size={16} />
696+
</button>
697+
</>
698+
);
614699
comparisonChart = (
615-
<ResizablePanel title={`⚖️ ${key} 对比分析 (${compareMode})`} initialHeight={440}>
700+
<ResizablePanel title={`⚖️ ${key} 对比分析 (${compareMode})`} initialHeight={440} actions={compActions}>
616701
<ChartWrapper
617702
chartId={`metric-comp-${idx}`}
618703
onRegisterChart={registerChart}
@@ -627,7 +712,41 @@ export default function ChartContainer({
627712

628713
return (
629714
<div key={key} className="flex flex-col gap-3">
630-
<ResizablePanel title={key} initialHeight={440}>
715+
<ResizablePanel
716+
title={key}
717+
initialHeight={440}
718+
actions={(
719+
<>
720+
<button
721+
type="button"
722+
className="p-1 rounded-md text-gray-600 hover:text-blue-600 hover:bg-gray-100"
723+
onClick={() => exportChartPNG(`metric-${idx}`)}
724+
aria-label="导出 PNG"
725+
title="导出 PNG"
726+
>
727+
<ImageDown size={16} />
728+
</button>
729+
<button
730+
type="button"
731+
className="p-1 rounded-md text-gray-600 hover:text-blue-600 hover:bg-gray-100"
732+
onClick={() => copyChartImage(`metric-${idx}`)}
733+
aria-label="复制图片"
734+
title="复制图片"
735+
>
736+
<Copy size={16} />
737+
</button>
738+
<button
739+
type="button"
740+
className="p-1 rounded-md text-gray-600 hover:text-blue-600 hover:bg-gray-100"
741+
onClick={() => exportChartCSV(`metric-${idx}`)}
742+
aria-label="导出 CSV"
743+
title="导出 CSV"
744+
>
745+
<FileDown size={16} />
746+
</button>
747+
</>
748+
)}
749+
>
631750
<ChartWrapper
632751
chartId={`metric-${idx}`}
633752
onRegisterChart={registerChart}

src/components/ResizablePanel.jsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import React, { useState, useRef, useCallback, useEffect } from 'react';
22

3-
export function ResizablePanel({ children, title, initialHeight = 440, minHeight = 200, maxHeight = 800 }) {
3+
export function ResizablePanel({ children, title, initialHeight = 440, minHeight = 200, maxHeight = 800, actions = null }) {
44
const [height, setHeight] = useState(initialHeight);
55
const [isResizing, setIsResizing] = useState(false);
66
const panelRef = useRef(null);
@@ -49,12 +49,17 @@ export function ResizablePanel({ children, title, initialHeight = 440, minHeight
4949
aria-labelledby={`panel-title-${title.replace(/\s+/g, '-').toLowerCase()}`}
5050
>
5151
<div className="flex items-center justify-between mb-2">
52-
<h3
52+
<h3
5353
id={`panel-title-${title.replace(/\s+/g, '-').toLowerCase()}`}
5454
className="text-base font-semibold text-gray-800"
5555
>
5656
📊 {title}
5757
</h3>
58+
{actions && (
59+
<div className="flex gap-2" aria-label="图表操作按钮">
60+
{actions}
61+
</div>
62+
)}
5863
</div>
5964

6065
<div

0 commit comments

Comments
 (0)