Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

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

## 快速上手 ⚡

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

## 部署

Expand Down
123 changes: 121 additions & 2 deletions src/components/ChartContainer.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
Legend,
} from 'chart.js';
import zoomPlugin from 'chartjs-plugin-zoom';
import { ImageDown, Copy, FileDown } from 'lucide-react';
import { getMinSteps } from "../utils/getMinSteps.js";

ChartJS.register(
Expand Down Expand Up @@ -103,6 +104,59 @@ export default function ChartContainer({
chartRefs.current.set(id, inst);
}, []);

const exportChartPNG = useCallback((id) => {
const chart = chartRefs.current.get(id);
if (!chart) return;
const url = chart.toBase64Image();
const link = document.createElement('a');
link.href = url;
link.download = `${id}.png`;
link.click();
}, []);

const copyChartImage = useCallback(async (id) => {
const chart = chartRefs.current.get(id);
if (!chart || !navigator?.clipboard) return;
const url = chart.toBase64Image();
const res = await fetch(url);
const blob = await res.blob();
try {
await navigator.clipboard.write([
new ClipboardItem({ 'image/png': blob })
]);
} catch (e) {
console.error('复制图片失败', e);
}
}, []);

const exportChartCSV = useCallback((id) => {
const chart = chartRefs.current.get(id);
if (!chart) return;
const datasets = chart.data.datasets || [];
const xValues = new Set();
datasets.forEach(ds => {
(ds.data || []).forEach(p => xValues.add(p.x));
});
const sortedX = Array.from(xValues).sort((a, b) => a - b);
const header = ['step', ...datasets.map(ds => ds.label || '')];
const rows = sortedX.map(x => {
const cols = [x];
datasets.forEach(ds => {
const pt = (ds.data || []).find(p => p.x === x);
cols.push(pt ? pt.y : '');
});
return cols.join(',');
});
const csv = [header.join(','), ...rows].join('\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${id}.csv`;
link.click();
URL.revokeObjectURL(url);
}, []);

const syncHoverToAllCharts = useCallback((step, sourceId) => {
if (syncLockRef.current) return;
syncLockRef.current = true;
Expand Down Expand Up @@ -611,8 +665,39 @@ export default function ChartContainer({
y: { ...chartOptions.scales.y, min: compRange.min, max: compRange.max }
}
};
const compActions = (
<>
<button
type="button"
className="p-1 rounded-md text-gray-600 hover:text-blue-600 hover:bg-gray-100"
onClick={() => exportChartPNG(`metric-comp-${idx}`)}
aria-label="导出 PNG"
title="导出 PNG"
>
<ImageDown size={16} />
</button>
<button
type="button"
className="p-1 rounded-md text-gray-600 hover:text-blue-600 hover:bg-gray-100"
onClick={() => copyChartImage(`metric-comp-${idx}`)}
aria-label="复制图片"
title="复制图片"
>
<Copy size={16} />
</button>
<button
type="button"
className="p-1 rounded-md text-gray-600 hover:text-blue-600 hover:bg-gray-100"
onClick={() => exportChartCSV(`metric-comp-${idx}`)}
aria-label="导出 CSV"
title="导出 CSV"
>
<FileDown size={16} />
</button>
</>
);
comparisonChart = (
<ResizablePanel title={`⚖️ ${key} 对比分析 (${compareMode})`} initialHeight={440}>
<ResizablePanel title={`⚖️ ${key} 对比分析 (${compareMode})`} initialHeight={440} actions={compActions}>
<ChartWrapper
chartId={`metric-comp-${idx}`}
onRegisterChart={registerChart}
Expand All @@ -627,7 +712,41 @@ export default function ChartContainer({

return (
<div key={key} className="flex flex-col gap-3">
<ResizablePanel title={key} initialHeight={440}>
<ResizablePanel
title={key}
initialHeight={440}
actions={(
<>
<button
type="button"
className="p-1 rounded-md text-gray-600 hover:text-blue-600 hover:bg-gray-100"
onClick={() => exportChartPNG(`metric-${idx}`)}
aria-label="导出 PNG"
title="导出 PNG"
>
<ImageDown size={16} />
</button>
<button
type="button"
className="p-1 rounded-md text-gray-600 hover:text-blue-600 hover:bg-gray-100"
onClick={() => copyChartImage(`metric-${idx}`)}
aria-label="复制图片"
title="复制图片"
>
<Copy size={16} />
</button>
<button
type="button"
className="p-1 rounded-md text-gray-600 hover:text-blue-600 hover:bg-gray-100"
onClick={() => exportChartCSV(`metric-${idx}`)}
aria-label="导出 CSV"
title="导出 CSV"
>
<FileDown size={16} />
</button>
</>
)}
>
<ChartWrapper
chartId={`metric-${idx}`}
onRegisterChart={registerChart}
Expand Down
9 changes: 7 additions & 2 deletions src/components/ResizablePanel.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React, { useState, useRef, useCallback, useEffect } from 'react';

export function ResizablePanel({ children, title, initialHeight = 440, minHeight = 200, maxHeight = 800 }) {
export function ResizablePanel({ children, title, initialHeight = 440, minHeight = 200, maxHeight = 800, actions = null }) {
const [height, setHeight] = useState(initialHeight);
const [isResizing, setIsResizing] = useState(false);
const panelRef = useRef(null);
Expand Down Expand Up @@ -49,12 +49,17 @@ export function ResizablePanel({ children, title, initialHeight = 440, minHeight
aria-labelledby={`panel-title-${title.replace(/\s+/g, '-').toLowerCase()}`}
>
<div className="flex items-center justify-between mb-2">
<h3
<h3
id={`panel-title-${title.replace(/\s+/g, '-').toLowerCase()}`}
className="text-base font-semibold text-gray-800"
>
📊 {title}
</h3>
{actions && (
<div className="flex gap-2" aria-label="图表操作按钮">
{actions}
</div>
)}
</div>

<div
Expand Down