diff --git a/src/App.jsx b/src/App.jsx index c7ba568..6f3a265 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -12,27 +12,25 @@ function App() { // 全局解析配置状态 const [globalParsingConfig, setGlobalParsingConfig] = useState({ - loss: { - mode: 'keyword', // 'keyword' | 'regex' - keyword: 'loss:', - regex: 'loss:\\s*([\\d.eE+-]+)' - }, - gradNorm: { - mode: 'keyword', // 'keyword' | 'regex' - keyword: 'norm:', - regex: 'grad[\\s_]norm:\\s*([\\d.eE+-]+)' - } + metrics: [ + { + name: 'Loss', + mode: 'keyword', // 'keyword' | 'regex' + keyword: 'loss:', + regex: 'loss:\\s*([\\d.eE+-]+)' + }, + { + name: 'Grad Norm', + mode: 'keyword', + keyword: 'norm:', + regex: 'grad[\\s_]norm:\\s*([\\d.eE+-]+)' + } + ] }); - // 兼容旧版本的正则表达式状态(供ChartContainer使用) - const [lossRegex, setLossRegex] = useState('loss:\\s*([\\d.eE+-]+)'); - const [gradNormRegex, setGradNormRegex] = useState('grad[\\s_]norm:\\s*([\\d.eE+-]+)'); - const [compareMode, setCompareMode] = useState('normal'); const [relativeBaseline, setRelativeBaseline] = useState(0.002); const [absoluteBaseline, setAbsoluteBaseline] = useState(0.005); - const [showLoss, setShowLoss] = useState(true); - const [showGradNorm, setShowGradNorm] = useState(false); const [configModalOpen, setConfigModalOpen] = useState(false); const [configFile, setConfigFile] = useState(null); const [globalDragOver, setGlobalDragOver] = useState(false); @@ -46,8 +44,7 @@ function App() { enabled: true, config: { // 使用全局解析配置作为默认值 - loss: { ...globalParsingConfig.loss }, - gradNorm: { ...globalParsingConfig.gradNorm }, + metrics: globalParsingConfig.metrics.map(m => ({ ...m })), dataRange: { start: 0, // 默认从第一个数据点开始 end: undefined, // 默认到最后一个数据点 @@ -118,30 +115,17 @@ function App() { // 全局解析配置变更处理 const handleGlobalParsingConfigChange = useCallback((newConfig) => { setGlobalParsingConfig(newConfig); - - // 同步更新兼容的正则表达式状态 - setLossRegex(newConfig.loss.mode === 'regex' ? newConfig.loss.regex : 'loss:\\s*([\\d.eE+-]+)'); - setGradNormRegex(newConfig.gradNorm.mode === 'regex' ? newConfig.gradNorm.regex : 'grad[\\s_]norm:\\s*([\\d.eE+-]+)'); - + // 同步所有文件的解析配置 setUploadedFiles(prev => prev.map(file => ({ ...file, config: { ...file.config, - loss: { ...newConfig.loss }, - gradNorm: { ...newConfig.gradNorm } + metrics: newConfig.metrics.map(m => ({ ...m })) } }))); }, []); - const handleRegexChange = useCallback((type, value) => { - if (type === 'loss') { - setLossRegex(value); - } else { - setGradNormRegex(value); - } - }, []); - // 全局拖拽事件处理 const handleGlobalDragEnter = useCallback((e) => { e.preventDefault(); @@ -303,9 +287,6 @@ function App() {

📊 图表显示

-
- - - -
+

上传文件后自动展示所有已配置的指标图表

@@ -438,13 +385,10 @@ function App() { > { const chartRef = useRef(null); - + const handleChartRef = useCallback((ref) => { if (ref) { chartRef.current = ref; @@ -46,335 +45,191 @@ const ChartWrapper = ({ data, options, chartId, onRegisterChart, onSyncHover }) onSyncHover(null, chartId); } }, - // 添加额外的事件处理确保清除状态 events: ['mousemove', 'mouseout', 'click', 'touchstart', 'touchmove'], }; - // 添加容器的鼠标离开事件 const handleContainerMouseLeave = useCallback(() => { onSyncHover(null, chartId); }, [onSyncHover, chartId]); - try { - return ( -
- -
- ); - } catch (error) { - console.error('Chart rendering error:', error); - return ( -
-
-

⚠️ 图表渲染错误

-

🔄 请检查数据格式或刷新页面重试

-
-
- ); - } + return ( +
+ +
+ ); }; -export default function ChartContainer({ - files, - lossRegex, - gradNormRegex, +export default function ChartContainer({ + files, + metrics = [], compareMode, relativeBaseline = 0.002, absoluteBaseline = 0.005, - showLoss = true, - showGradNorm = false, xRange = { min: undefined, max: undefined }, onXRangeChange, onMaxStepChange }) { - // 同步hover状态管理 - const chartRefs = useRef(new Map()); // 存储所有图表实例的引用 - - // 注册图表实例 - const registerChart = useCallback((chartId, chartInstance) => { - chartRefs.current.set(chartId, chartInstance); + const chartRefs = useRef(new Map()); + const registerChart = useCallback((id, inst) => { + chartRefs.current.set(id, inst); }, []); - - // 同步所有图表的hover状态 - const syncHoverToAllCharts = useCallback((step, sourceChartId) => { - if (step === null) { - // 清除所有图表的hover状态(包括源图表) - chartRefs.current.forEach((chart) => { - if (chart) { - chart.setActiveElements([]); - chart.tooltip.setActiveElements([]); - chart.update('none'); - } - }); - } else { - // 同步hover到所有图表(不包括源图表,避免重复操作) - chartRefs.current.forEach((chart, chartId) => { - if (chart && chartId !== sourceChartId) { - const activeElements = []; - - // 为每个数据集找到对应step的数据点 - chart.data.datasets.forEach((dataset, datasetIndex) => { - if (dataset.data && dataset.data.length > step) { - activeElements.push({ - datasetIndex, - index: step - }); - } - }); - - chart.setActiveElements(activeElements); - chart.tooltip.setActiveElements(activeElements, { x: 0, y: 0 }); - chart.update('none'); - } - }); - } + + const syncHoverToAllCharts = useCallback((step, sourceId) => { + chartRefs.current.forEach((chart, id) => { + if (!chart) return; + if (step === null) { + chart.setActiveElements([]); + chart.tooltip.setActiveElements([]); + chart.update('none'); + } else if (id !== sourceId) { + const activeElements = []; + chart.data.datasets.forEach((dataset, datasetIndex) => { + if (dataset.data && dataset.data.length > step) { + activeElements.push({ datasetIndex, index: step }); + } + }); + chart.setActiveElements(activeElements); + chart.tooltip.setActiveElements(activeElements, { x: 0, y: 0 }); + chart.update('none'); + } + }); }, []); const parsedData = useMemo(() => { - // 只处理已启用的文件 - const enabledFiles = files.filter(file => file.enabled !== false); - - return enabledFiles.map(file => { - if (!file.content) return { ...file, lossData: [], gradNormData: [] }; - + const enabled = files.filter(f => f.enabled !== false); + return enabled.map(file => { + if (!file.content) return { ...file, metricsData: {} }; const lines = file.content.split('\n'); - const lossData = []; - const gradNormData = []; - - try { - // 使用新的配置格式,同时保持向后兼容 - let fileLossConfig, fileGradNormConfig; - - if (file.config?.loss && file.config?.gradNorm) { - // 新配置格式 - fileLossConfig = file.config.loss; - fileGradNormConfig = file.config.gradNorm; - } else { - // 旧配置格式,转换为新格式 - fileLossConfig = { - mode: 'regex', - regex: file.config?.lossRegex || lossRegex - }; - fileGradNormConfig = { - mode: 'regex', - regex: file.config?.gradNormRegex || gradNormRegex - }; - } - - // 处理Loss数据 - if (fileLossConfig.mode === 'keyword') { - // 关键词匹配 - const extractByKeyword = (content, keyword) => { - const results = []; - const lines = content.split('\n'); - - // 数值正则:支持各种数值格式,包括科学计数法 - const numberRegex = /[+-]?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/; - - lines.forEach((line) => { - // 查找关键词(忽略大小写) - const keywordIndex = line.toLowerCase().indexOf(keyword.toLowerCase()); - if (keywordIndex !== -1) { - // 从关键词后开始查找第一个数字 - const afterKeyword = line.substring(keywordIndex + keyword.length); - const numberMatch = afterKeyword.match(numberRegex); - - if (numberMatch) { - const value = parseFloat(numberMatch[0]); - if (!isNaN(value)) { - results.push(value); - } - } - } - }); - - return results; - }; - - const lossValues = extractByKeyword(file.content, fileLossConfig.keyword); - lossValues.forEach((value, index) => { - if (!isNaN(value)) { - lossData.push({ x: index, y: value }); - } - }); - } else { - // 正则表达式匹配 - const lossRegexObj = new RegExp(fileLossConfig.regex); - lines.forEach((line) => { - lossRegexObj.lastIndex = 0; - const lossMatch = lossRegexObj.exec(line); - if (lossMatch && lossMatch[1]) { - const value = parseFloat(lossMatch[1]); - if (!isNaN(value)) { - lossData.push({ x: lossData.length, y: value }); - } - } - }); - } - - // 处理Grad Norm数据 - if (fileGradNormConfig.mode === 'keyword') { - // 关键词匹配 - const extractByKeyword = (content, keyword) => { - const results = []; - const lines = content.split('\n'); - - // 数值正则:支持各种数值格式,包括科学计数法 - const numberRegex = /[+-]?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/; - - lines.forEach((line) => { - // 查找关键词(忽略大小写) - const keywordIndex = line.toLowerCase().indexOf(keyword.toLowerCase()); - if (keywordIndex !== -1) { - // 从关键词后开始查找第一个数字 - const afterKeyword = line.substring(keywordIndex + keyword.length); - const numberMatch = afterKeyword.match(numberRegex); - - if (numberMatch) { - const value = parseFloat(numberMatch[0]); - if (!isNaN(value)) { - results.push(value); - } - } - } - }); - - return results; - }; - - const gradNormValues = extractByKeyword(file.content, fileGradNormConfig.keyword); - gradNormValues.forEach((value, index) => { - if (!isNaN(value)) { - gradNormData.push({ x: index, y: value }); + const metricsData = {}; + + const extractByKeyword = (content, keyword) => { + const results = []; + const numberRegex = /[+-]?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/; + content.split('\n').forEach(line => { + const idx = line.toLowerCase().indexOf(keyword.toLowerCase()); + if (idx !== -1) { + const after = line.substring(idx + keyword.length); + const match = after.match(numberRegex); + if (match) { + const v = parseFloat(match[0]); + if (!isNaN(v)) results.push(v); } - }); - } else { - // 正则表达式匹配 - const gradNormRegexObj = new RegExp(fileGradNormConfig.regex); - lines.forEach((line) => { - gradNormRegexObj.lastIndex = 0; - const gradNormMatch = gradNormRegexObj.exec(line); - if (gradNormMatch && gradNormMatch[1]) { - const value = parseFloat(gradNormMatch[1]); - if (!isNaN(value)) { - gradNormData.push({ x: gradNormData.length, y: value }); - } + } + }); + return results; + }; + + metrics.forEach(metric => { + let values = []; + if (metric.mode === 'keyword') { + values = extractByKeyword(file.content, metric.keyword); + } else if (metric.regex) { + const reg = new RegExp(metric.regex); + lines.forEach(line => { + reg.lastIndex = 0; + const m = reg.exec(line); + if (m && m[1]) { + const v = parseFloat(m[1]); + if (!isNaN(v)) values.push(v); } }); } - } catch (error) { - console.error('Regex error:', error); - } + metricsData[metric.name || metric.keyword] = values.map((v, i) => ({ x: i, y: v })); + }); - // 应用数据范围过滤 - const dataRange = file.config?.dataRange; - if (dataRange && (dataRange.start > 0 || dataRange.end !== undefined)) { - const applyRangeFilter = (data) => { + const range = file.config?.dataRange; + if (range && (range.start > 0 || range.end !== undefined)) { + const applyRange = data => { if (data.length === 0) return data; - - const start = Math.max(0, parseInt(dataRange.start) || 0); - const end = dataRange.end !== undefined ? parseInt(dataRange.end) : data.length; - - // 验证范围有效性 - if (start >= data.length || (end !== undefined && start >= end)) { - console.warn(`Invalid range for file ${file.name}: start=${start}, end=${end}, length=${data.length}`); - return data; // 返回原始数据 - } - - // 切片数据(start到end,不包含end) + const start = Math.max(0, parseInt(range.start) || 0); + const end = range.end !== undefined ? parseInt(range.end) : data.length; const endIndex = Math.min(data.length, end); - const slicedData = data.slice(start, endIndex); - - return slicedData; - }; - - const filteredLossData = applyRangeFilter(lossData); - const filteredGradNormData = applyRangeFilter(gradNormData); - - // 重新索引数据点 - const reindexData = (data) => data.map((point, index) => ({ x: index, y: point.y })); - - return { - ...file, - lossData: reindexData(filteredLossData), - gradNormData: reindexData(filteredGradNormData) + return data.slice(start, endIndex); }; + const reindex = data => data.map((p, idx) => ({ x: idx, y: p.y })); + Object.keys(metricsData).forEach(k => { + metricsData[k] = reindex(applyRange(metricsData[k])); + }); } - return { ...file, lossData, gradNormData }; + return { ...file, metricsData }; }); - }, [files, lossRegex, gradNormRegex]); + }, [files, metrics]); useEffect(() => { - const maxStep = parsedData.reduce((max, file) => { - const maxLoss = file.lossData.length > 0 ? file.lossData[file.lossData.length - 1].x : 0; - const maxGrad = file.gradNormData.length > 0 ? file.gradNormData[file.gradNormData.length - 1].x : 0; - return Math.max(max, maxLoss, maxGrad); + const maxStep = parsedData.reduce((m, f) => { + const localMax = Object.values(f.metricsData).reduce((mm, d) => Math.max(mm, d.length > 0 ? d[d.length - 1].x : 0), 0); + return Math.max(m, localMax); }, 0); onMaxStepChange(maxStep); }, [parsedData, onMaxStepChange]); + const colors = ['#ef4444', '#3b82f6', '#10b981', '#f59e0b', '#8b5cf6', '#f97316']; + const createChartData = dataArray => ({ + datasets: dataArray.map((item, index) => { + const color = colors[index % colors.length]; + return { + label: item.name?.replace(/\.(log|txt)$/i, '') || `File ${index + 1}`, + data: item.data, + borderColor: color, + backgroundColor: `${color}33`, + borderWidth: 2, + fill: false, + tension: 0, + pointRadius: 0, + pointHoverRadius: 4, + pointBackgroundColor: color, + pointBorderColor: color, + pointBorderWidth: 1, + pointHoverBackgroundColor: color, + pointHoverBorderColor: color, + pointHoverBorderWidth: 1, + animation: false, + animations: { colors: false, x: false, y: false }, + }; + }) + }); const getComparisonData = (data1, data2, mode) => { const minLength = Math.min(data1.length, data2.length); const result = []; - for (let i = 0; i < minLength; i++) { - const val1 = data1[i].y; - const val2 = data2[i].y; + const v1 = data1[i].y; + const v2 = data2[i].y; let diff; - switch (mode) { case 'absolute': - diff = Math.abs(val2 - val1); + diff = Math.abs(v2 - v1); break; case 'relative': { - // 相对误差:先计算绝对差值,再计算相对误差(不使用百分号) - const absoluteDiff = Math.abs(val2 - val1); - diff = val1 !== 0 ? absoluteDiff / Math.abs(val1) : 0; + const ad = Math.abs(v2 - v1); + diff = v1 !== 0 ? ad / Math.abs(v1) : 0; break; } - default: // normal - diff = val2 - val1; + default: + diff = v2 - v1; } - result.push({ x: i, y: diff }); } - return result; }; - const chartOptions = { + const chartOptions = useMemo(() => ({ responsive: true, maintainAspectRatio: false, - animation: { - duration: 0, // 禁用默认动画 - }, - animations: { - // 禁用所有动画,包括hover动画 - colors: false, - x: false, - y: false, - }, - hover: { - animationDuration: 0, // 禁用hover动画 - }, - responsiveAnimationDuration: 0, // 禁用响应式动画 - interaction: { - mode: 'index', - intersect: false, - }, + animation: { duration: 0 }, + animations: { colors: false, x: false, y: false }, + hover: { animationDuration: 0 }, + responsiveAnimationDuration: 0, + interaction: { mode: 'index', intersect: false }, plugins: { zoom: { pan: { enabled: true, mode: 'x', - onPanComplete: ({chart}) => { - const {min, max} = chart.scales.x; - onXRangeChange({min: Math.round(min), max: Math.round(max)}); + onPanComplete: ({ chart }) => { + const { min, max } = chart.scales.x; + onXRangeChange({ min: Math.round(min), max: Math.round(max) }); } }, zoom: { @@ -383,18 +238,14 @@ export default function ChartContainer({ borderColor: 'rgba(225,225,225,0.2)', borderWidth: 1, backgroundColor: 'rgba(225,225,225,0.2)', - modifierKey: 'shift', - }, - wheel: { - enabled: true, - }, - pinch: { - enabled: true + modifierKey: 'shift' }, + wheel: { enabled: true }, + pinch: { enabled: true }, mode: 'x', - onZoomComplete: ({chart}) => { - const {min, max} = chart.scales.x; - onXRangeChange({min: Math.round(min), max: Math.round(max)}); + onZoomComplete: ({ chart }) => { + const { min, max } = chart.scales.x; + onXRangeChange({ min: Math.round(min), max: Math.round(max) }); } } }, @@ -405,25 +256,23 @@ export default function ChartContainer({ boxHeight: 2, padding: 10, usePointStyle: false, - generateLabels: function(chart) { + generateLabels: function (chart) { const original = Chart.defaults.plugins.legend.labels.generateLabels; const labels = original.call(this, chart); - labels.forEach((label, index) => { const dataset = chart.data.datasets[index]; if (dataset && dataset.borderDash && dataset.borderDash.length > 0) { label.lineDash = dataset.borderDash; } }); - return labels; } - }, + } }, tooltip: { mode: 'index', intersect: false, - animation: false, // 禁用tooltip动画 + animation: false, backgroundColor: 'rgba(15, 23, 42, 0.92)', titleColor: '#f1f5f9', bodyColor: '#cbd5e1', @@ -432,38 +281,22 @@ export default function ChartContainer({ cornerRadius: 6, displayColors: true, usePointStyle: true, - titleFont: { - size: 11, - weight: '600', - family: 'Inter, system-ui, sans-serif' - }, - bodyFont: { - size: 10, - weight: '400', - family: 'Inter, system-ui, sans-serif' - }, - footerFont: { - size: 9, - weight: '300' - }, - padding: { - top: 6, - bottom: 6, - left: 8, - right: 8 - }, + titleFont: { size: 11, weight: '600', family: 'Inter, system-ui, sans-serif' }, + bodyFont: { size: 10, weight: '400', family: 'Inter, system-ui, sans-serif' }, + footerFont: { size: 9, weight: '300' }, + padding: { top: 6, bottom: 6, left: 8, right: 8 }, caretPadding: 4, caretSize: 4, multiKeyBackground: 'transparent', callbacks: { - title: function(context) { + title: function (context) { return `Step ${context[0].parsed.x}`; }, - label: function(context) { + label: function (context) { const value = Number(context.parsed.y.toPrecision(4)); return ` ${value}`; }, - labelColor: function(context) { + labelColor: function (context) { return { borderColor: context.dataset.borderColor, backgroundColor: context.dataset.borderColor, @@ -472,16 +305,13 @@ export default function ChartContainer({ }; } } - }, + } }, scales: { x: { type: 'linear', display: true, - title: { - display: true, - text: 'Step', - }, + title: { display: true, text: 'Step' }, min: xRange.min, max: xRange.max, bounds: 'data' @@ -489,76 +319,21 @@ export default function ChartContainer({ y: { type: 'linear', display: true, - title: { - display: true, - text: 'Value', - }, + title: { display: true, text: 'Value' }, bounds: 'data', ticks: { - callback: function(value) { + callback: function (value) { return Number(value.toPrecision(2)); - }, - }, - }, - }, - elements: { - point: { - radius: 0, // 默认不显示数据点 - }, + } + } + } }, - }; - - const colors = [ - '#ef4444', // red - '#3b82f6', // blue - '#10b981', // green - '#f59e0b', // yellow - '#8b5cf6', // purple - '#f97316', // orange - ]; - - const createChartData = (dataArray) => { - const datasets = []; - - dataArray.forEach((item, index) => { - const color = colors[index % colors.length]; - - datasets.push({ - label: `${item.name?.replace(/\.(log|txt)$/i, '') || `File ${index + 1}`}`, - data: item.data, - borderColor: color, - backgroundColor: `${color}33`, // Add transparency - borderWidth: 2, - fill: false, - tension: 0, // 设置 tension 为 0,绘制直线段 - pointRadius: 0, // 默认不显示数据点 - pointHoverRadius: 4, // hover时显示实心圆点,半径为4 - pointBackgroundColor: color, // 设置点的背景色 - pointBorderColor: color, // 设置点的边框色 - pointBorderWidth: 1, // 设置点的边框宽度 - pointHoverBackgroundColor: color, // hover时的背景色 - pointHoverBorderColor: color, // hover时的边框色 - pointHoverBorderWidth: 1, // hover时的边框宽度 - // 禁用所有动画,确保响应迅速 - animation: false, - animations: { - colors: false, - x: false, - y: false, - }, - }); - }); - - return { - datasets, - }; - }; + elements: { point: { radius: 0 } } + }), [xRange, onXRangeChange]); const createComparisonChartData = (item1, item2, title) => { const comparisonData = getComparisonData(item1.data, item2.data, compareMode); - const baseline = compareMode === 'relative' ? relativeBaseline : - compareMode === 'absolute' ? absoluteBaseline : 0; - + const baseline = compareMode === 'relative' ? relativeBaseline : compareMode === 'absolute' ? absoluteBaseline : 0; const datasets = [ { label: `${title} 差值`, @@ -567,55 +342,42 @@ export default function ChartContainer({ backgroundColor: '#dc2626', borderWidth: 2, fill: false, - tension: 0, // 设置 tension 为 0,绘制直线段 - pointRadius: 0, // 默认不显示数据点 - pointHoverRadius: 4, // hover时显示实心圆点 - pointBackgroundColor: '#dc2626', // 设置点的背景色 - pointBorderColor: '#dc2626', // 设置点的边框色 - pointBorderWidth: 1, // 设置点的边框宽度 - pointHoverBackgroundColor: '#dc2626', // hover时的背景色 - pointHoverBorderColor: '#dc2626', // hover时的边框色 - pointHoverBorderWidth: 1, // hover时的边框宽度 - // 禁用所有动画,确保响应迅速 + tension: 0, + pointRadius: 0, + pointHoverRadius: 4, + pointBackgroundColor: '#dc2626', + pointBorderColor: '#dc2626', + pointBorderWidth: 1, + pointHoverBackgroundColor: '#dc2626', + pointHoverBorderColor: '#dc2626', + pointHoverBorderWidth: 1, animation: false, - animations: { - colors: false, - x: false, - y: false, - }, - } + animations: { colors: false, x: false, y: false }, + }, ]; - - // Add baseline if it's not zero and we're in relative or absolute mode if (baseline > 0 && (compareMode === 'relative' || compareMode === 'absolute')) { - const baselineData = comparisonData.map(point => ({ x: point.x, y: baseline })); + const baselineData = comparisonData.map(p => ({ x: p.x, y: baseline })); datasets.push({ - label: `Baseline`, - data: baselineData, - borderColor: '#10b981', - backgroundColor: '#10b981', - borderWidth: 2, - borderDash: [5, 5], - fill: false, - tension: 0, - pointRadius: 0, // 默认不显示数据点 - pointHoverRadius: 4, // hover时显示实心圆点 - pointBackgroundColor: '#10b981', // 设置点的背景色 - pointBorderColor: '#10b981', // 设置点的边框色 - pointBorderWidth: 1, // 设置点的边框宽度 - pointHoverBackgroundColor: '#10b981', // hover时的背景色 - pointHoverBorderColor: '#10b981', // hover时的边框色 - pointHoverBorderWidth: 1, // hover时的边框宽度 - // 禁用所有动画,确保响应迅速 - animation: false, - animations: { - colors: false, - x: false, - y: false, - }, - }); + label: 'Baseline', + data: baselineData, + borderColor: '#10b981', + backgroundColor: '#10b981', + borderWidth: 2, + borderDash: [5, 5], + fill: false, + tension: 0, + pointRadius: 0, + pointHoverRadius: 4, + pointBackgroundColor: '#10b981', + pointBorderColor: '#10b981', + pointBorderWidth: 1, + pointHoverBackgroundColor: '#10b981', + pointHoverBorderColor: '#10b981', + pointHoverBorderWidth: 1, + animation: false, + animations: { colors: false, x: false, y: false }, + }); } - return { datasets }; }; @@ -630,213 +392,89 @@ export default function ChartContainer({ ); } - // 检查是否有任何图表可以显示 - if (!showLoss && !showGradNorm) { + const metricNames = metrics.map((m, idx) => { + if (m.name && m.name.trim()) return m.name.trim(); + if (m.keyword) return m.keyword.replace(/[::]/g, '').trim(); + if (m.regex) { + const sanitized = m.regex.replace(/[^a-zA-Z0-9_]/g, '').trim(); + return sanitized || `metric${idx + 1}`; + } + return `metric${idx + 1}`; + }); + const metricDataArrays = {}; + metricNames.forEach(name => { + metricDataArrays[name] = parsedData + .filter(file => file.metricsData[name] && file.metricsData[name].length > 0) + .map(file => ({ name: file.name, data: file.metricsData[name] })); + }); + + if (metrics.length === 0) { return (
-
- - - -

🎯 请选择要显示的图表

-

👈 在左侧显示选项中勾选 "显示 Loss 函数" 或 "显示 Grad Norm"

); } - const lossDataArray = parsedData - .filter(file => file.lossData && file.lossData.length > 0) - .map(file => ({ name: file.name, data: file.lossData })); - - const gradNormDataArray = parsedData - .filter(file => file.gradNormData && file.gradNormData.length > 0) - .map(file => ({ name: file.name, data: file.gradNormData })); + const metricElements = metrics.map((metric, idx) => { + const key = metric.name || metric.keyword || `metric${idx + 1}`; + const dataArray = metricDataArrays[key] || []; + const showComparison = dataArray.length === 2; + + let stats = null; + if (showComparison) { + const normalDiff = getComparisonData(dataArray[0].data, dataArray[1].data, 'normal'); + const absDiff = getComparisonData(dataArray[0].data, dataArray[1].data, 'absolute'); + const relDiff = getComparisonData(dataArray[0].data, dataArray[1].data, 'relative'); + const mean = arr => (arr.reduce((s, p) => s + p.y, 0) / arr.length) || 0; + stats = { + meanNormal: mean(normalDiff), + meanAbsolute: mean(absDiff), + meanRelative: mean(relDiff) + }; + } - // 计算显示的图表数量来决定布局 - const enabledFiles = files.filter(file => file.enabled !== false); - const showingLossCharts = showLoss && lossDataArray.length > 0; - const showingGradNormCharts = showGradNorm && gradNormDataArray.length > 0; - const showingLossComparison = showLoss && enabledFiles.length === 2 && lossDataArray.length === 2; - const showingGradNormComparison = showGradNorm && enabledFiles.length === 2 && gradNormDataArray.length === 2; - - // 计算实际显示的图表列数(不是图表总数) - const showingLossColumn = showingLossCharts || showingLossComparison; - const showingGradNormColumn = showingGradNormCharts || showingGradNormComparison; - const columnsShowing = (showingLossColumn ? 1 : 0) + (showingGradNormColumn ? 1 : 0); - - // 动态决定布局:如果只显示一列,使用全宽;否则使用两列 - const useFullWidth = columnsShowing <= 1; - const gridCols = useFullWidth ? 'grid-cols-1' : 'grid-cols-1 lg:grid-cols-2'; + return ( +
+ + + + {showComparison && ( + + + + )} + {stats && ( +
+

{key} 差值统计

+
+

Mean Difference: {stats.meanNormal.toFixed(6)}

+

Mean Absolute Error: {stats.meanAbsolute.toFixed(6)}

+

Mean Relative Error: {stats.meanRelative.toFixed(6)}

+
+
+ )} +
+ ); + }); return ( -
- {/* Loss Charts Column */} - {(showingLossCharts || showingLossComparison) && ( -
- {showingLossCharts && ( - - - - )} - - {/* Loss Comparison Chart (for 2 files) */} - {showingLossComparison && ( - - - - )} -
- )} - - {/* Grad Norm Charts Column */} - {(showingGradNormCharts || showingGradNormComparison) && ( -
- {showingGradNormCharts && ( - - - - )} - - {/* Grad Norm Comparison Chart (for 2 files) */} - {showingGradNormComparison && ( - - - - )} -
- )} - - {/* Statistics for comparison - spans all columns when showing both types */} - {enabledFiles.length === 2 && (showingLossComparison || showingGradNormComparison) && ( -
-

差值分析统计

-
- {showingLossComparison && ( -
-

Loss 差值统计

-
- {(() => { - const normalDiff = getComparisonData(lossDataArray[0], lossDataArray[1], 'normal'); - const absoluteDiff = getComparisonData(lossDataArray[0], lossDataArray[1], 'absolute'); - const relativeDiff = getComparisonData(lossDataArray[0], lossDataArray[1], 'relative'); - - const meanNormal = normalDiff.reduce((sum, p) => sum + p.y, 0) / normalDiff.length; - const meanAbsolute = absoluteDiff.reduce((sum, p) => sum + p.y, 0) / absoluteDiff.length; - const meanRelative = relativeDiff.reduce((sum, p) => sum + p.y, 0) / relativeDiff.length; - - return ( - <> -

Mean Difference: {meanNormal.toFixed(6)}

-

Mean Absolute Error: {meanAbsolute.toFixed(6)}

-

Mean Relative Error: {meanRelative.toFixed(6)}

- - ); - })()} -
-
- )} - {showingGradNormComparison && ( -
-

Grad Norm 差值统计

-
- {(() => { - const normalDiff = getComparisonData(gradNormDataArray[0], gradNormDataArray[1], 'normal'); - const absoluteDiff = getComparisonData(gradNormDataArray[0], gradNormDataArray[1], 'absolute'); - const relativeDiff = getComparisonData(gradNormDataArray[0], gradNormDataArray[1], 'relative'); - - const meanNormal = normalDiff.reduce((sum, p) => sum + p.y, 0) / normalDiff.length; - const meanAbsolute = absoluteDiff.reduce((sum, p) => sum + p.y, 0) / absoluteDiff.length; - const meanRelative = relativeDiff.reduce((sum, p) => sum + p.y, 0) / relativeDiff.length; - - return ( - <> -

Mean Difference: {meanNormal.toFixed(6)}

-

Mean Absolute Error: {meanAbsolute.toFixed(6)}

-

Mean Relative Error: {meanRelative.toFixed(6)}

- - ); - })()} -
-
- )} -
-
- )} +
+ {metricElements}
); } diff --git a/src/components/FileConfigModal.jsx b/src/components/FileConfigModal.jsx index ad3d850..6428b20 100644 --- a/src/components/FileConfigModal.jsx +++ b/src/components/FileConfigModal.jsx @@ -1,5 +1,6 @@ import React, { useState, useEffect } from 'react'; import { X, Settings, TrendingDown, TrendingUp, Sliders, BarChart3, Target, Code, Zap } from 'lucide-react'; +import { METRIC_PRESETS } from '../metricPresets.js'; // 匹配模式枚举 const MATCH_MODES = { @@ -23,18 +24,19 @@ const MODE_CONFIG = { } }; +function getMetricTitle(metric, index) { + if (metric.name && metric.name.trim()) return metric.name.trim(); + if (metric.keyword) return metric.keyword.replace(/[::]/g, '').trim(); + if (metric.regex) { + const sanitized = metric.regex.replace(/[^a-zA-Z0-9_]/g, '').trim(); + return sanitized || `Metric ${index + 1}`; + } + return `Metric ${index + 1}`; +} + export function FileConfigModal({ file, isOpen, onClose, onSave, globalParsingConfig }) { const [config, setConfig] = useState({ - loss: { - mode: 'keyword', - keyword: 'loss:', - regex: 'loss:\\s*([\\d.eE+-]+)' - }, - gradNorm: { - mode: 'keyword', - keyword: 'norm:', - regex: 'grad[\\s_]norm:\\s*([\\d.eE+-]+)' - }, + metrics: [], dataRange: { start: 0, // 起始位置,默认为0(第一个数据点) end: undefined, // 结束位置,默认为undefined(最后一个数据点) @@ -47,8 +49,7 @@ export function FileConfigModal({ file, isOpen, onClose, onSave, globalParsingCo // 如果文件有配置,使用文件配置,否则使用全局配置 const fileConfig = file.config || {}; setConfig({ - loss: fileConfig.loss || globalParsingConfig.loss, - gradNorm: fileConfig.gradNorm || globalParsingConfig.gradNorm, + metrics: fileConfig.metrics || globalParsingConfig.metrics, dataRange: fileConfig.dataRange || { start: 0, end: undefined, @@ -63,13 +64,23 @@ export function FileConfigModal({ file, isOpen, onClose, onSave, globalParsingCo onClose(); }; - const handleConfigChange = (type, field, value) => { + const handleMetricChange = (index, field, value) => { setConfig(prev => ({ ...prev, - [type]: { - ...prev[type], - [field]: value - } + metrics: prev.metrics.map((m, i) => + i === index ? { ...m, [field]: value } : m + ) + })); + }; + + const applyPreset = (index, presetLabel) => { + const preset = METRIC_PRESETS.find(p => p.label === presetLabel); + if (!preset) return; + setConfig(prev => ({ + ...prev, + metrics: prev.metrics.map((m, i) => + i === index ? { ...m, ...preset } : m + ) })); }; @@ -87,17 +98,29 @@ export function FileConfigModal({ file, isOpen, onClose, onSave, globalParsingCo const syncFromGlobal = () => { setConfig(prev => ({ ...prev, - loss: { ...globalParsingConfig.loss }, - gradNorm: { ...globalParsingConfig.gradNorm } + metrics: globalParsingConfig.metrics.map(m => ({ ...m })) })); }; // 渲染配置项的函数 - const renderConfigPanel = (type, configItem) => { + const renderConfigPanel = (type, configItem, index) => { const ModeIcon = MODE_CONFIG[configItem.mode].icon; - + return (
+
+ + +
{/* 模式选择 */}
handleConfigChange(type, 'keyword', e.target.value)} + onChange={(e) => handleMetricChange(index, 'keyword', e.target.value)} className="w-full px-2 py-1 text-sm border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500 focus:outline-none" - placeholder={type === 'loss' ? 'loss:' : 'norm:'} + placeholder="keyword" />

支持模糊匹配,如 "loss" 可匹配 "training_loss" @@ -147,9 +170,9 @@ export function FileConfigModal({ file, isOpen, onClose, onSave, globalParsingCo handleConfigChange(type, 'regex', e.target.value)} + onChange={(e) => handleMetricChange(index, 'regex', e.target.value)} className="w-full px-2 py-1 text-sm border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500 focus:outline-none font-mono" - placeholder={type === 'loss' ? 'loss:\\s*([\\d.eE+-]+)' : 'grad[\\s_]norm:\\s*([\\d.eE+-]+)'} + placeholder="value:\\s*([\\d.eE+-]+)" />

使用捕获组 () 来提取数值 @@ -210,23 +233,15 @@ export function FileConfigModal({ file, isOpen, onClose, onSave, globalParsingCo

- {/* Loss 配置 */} -
-

-

- {renderConfigPanel('loss', config.loss)} -
- - {/* Grad Norm 配置 */} -
-

-

- {renderConfigPanel('gradNorm', config.gradNorm)} -
+ {config.metrics.map((cfg, idx) => ( +
+

+

+ {renderConfigPanel(`metric-${idx}`, cfg, idx)} +
+ ))}
diff --git a/src/components/RegexControls.jsx b/src/components/RegexControls.jsx index 7a5a911..971cce9 100644 --- a/src/components/RegexControls.jsx +++ b/src/components/RegexControls.jsx @@ -1,5 +1,6 @@ import React, { useState, useEffect, useCallback } from 'react'; import { Settings, Zap, Eye, ChevronDown, ChevronUp, Target, Code, ZoomIn } from 'lucide-react'; +import { METRIC_PRESETS } from '../metricPresets.js'; // 匹配模式枚举 const MATCH_MODES = { @@ -23,6 +24,17 @@ const MODE_CONFIG = { } }; +// 根据配置生成友好的标题 +function getMetricTitle(metric, index) { + if (metric.name && metric.name.trim()) return metric.name.trim(); + if (metric.keyword) return metric.keyword.replace(/[::]/g, '').trim(); + if (metric.regex) { + const sanitized = metric.regex.replace(/[^a-zA-Z0-9_]/g, '').trim(); + return sanitized || `Metric ${index + 1}`; + } + return `Metric ${index + 1}`; +} + // 数值提取器类 export class ValueExtractor { // 关键词匹配 @@ -187,14 +199,13 @@ export class ValueExtractor { export function RegexControls({ globalParsingConfig, onGlobalParsingConfigChange, - onRegexChange, uploadedFiles = [], xRange, onXRangeChange, maxStep }) { const [showPreview, setShowPreview] = useState(false); - const [previewResults, setPreviewResults] = useState({ loss: [], gradNorm: [] }); + const [previewResults, setPreviewResults] = useState({}); // 提取数值的通用函数 const extractValues = useCallback((content, mode, config) => { @@ -210,42 +221,24 @@ export function RegexControls({ // 预览匹配结果 const previewMatches = useCallback(() => { - const results = { loss: [], gradNorm: [] }; + const results = {}; uploadedFiles.forEach(file => { if (file.content) { - // Loss匹配 - const lossMatches = extractValues( - file.content, - globalParsingConfig.loss.mode, - globalParsingConfig.loss - ); - results.loss.push({ - fileName: file.name, - count: lossMatches.length, - examples: lossMatches.slice(0, 3).map(m => ({ - value: m.value, - line: m.line, - text: m.text, - format: m.format - })) - }); - - // Grad Norm匹配 - const gradNormMatches = extractValues( - file.content, - globalParsingConfig.gradNorm.mode, - globalParsingConfig.gradNorm - ); - results.gradNorm.push({ - fileName: file.name, - count: gradNormMatches.length, - examples: gradNormMatches.slice(0, 3).map(m => ({ - value: m.value, - line: m.line, - text: m.text, - format: m.format - })) + globalParsingConfig.metrics.forEach((cfg, idx) => { + const matches = extractValues(file.content, cfg.mode, cfg); + const key = getMetricTitle(cfg, idx); + if (!results[key]) results[key] = []; + results[key].push({ + fileName: file.name, + count: matches.length, + examples: matches.slice(0, 3).map(m => ({ + value: m.value, + line: m.line, + text: m.text, + format: m.format + })) + }); }); } }); @@ -257,52 +250,33 @@ export function RegexControls({ const smartRecommend = useCallback(() => { if (uploadedFiles.length === 0) return; - let bestLossConfig = null; - let bestGradNormConfig = null; - let maxLossCount = 0; - let maxGradNormCount = 0; - const allContent = uploadedFiles.map(f => f.content).join('\n'); - - // 测试关键词模式 - const lossKeywords = ['loss', 'training_loss', 'train_loss']; - const gradNormKeywords = ['grad_norm', 'gradient_norm', 'gnorm', 'global_norm']; - - lossKeywords.forEach(keyword => { - const matches = ValueExtractor.extractByKeyword(allContent, keyword); - if (matches.length > maxLossCount) { - maxLossCount = matches.length; - bestLossConfig = { mode: MATCH_MODES.KEYWORD, keyword }; - } - }); - - gradNormKeywords.forEach(keyword => { - const matches = ValueExtractor.extractByKeyword(allContent, keyword); - if (matches.length > maxGradNormCount) { - maxGradNormCount = matches.length; - bestGradNormConfig = { mode: MATCH_MODES.KEYWORD, keyword }; - } - }); - // 应用最佳配置到全局配置 - const newConfig = { ...globalParsingConfig }; - if (bestLossConfig) { - newConfig.loss = { - ...newConfig.loss, - mode: bestLossConfig.mode, - keyword: bestLossConfig.keyword - }; + const newMetrics = globalParsingConfig.metrics.map(m => ({ ...m })); + + if (newMetrics[0]) { + let maxCount = 0; + ['loss', 'training_loss', 'train_loss'].forEach(keyword => { + const matches = ValueExtractor.extractByKeyword(allContent, keyword); + if (matches.length > maxCount) { + maxCount = matches.length; + newMetrics[0] = { ...newMetrics[0], mode: MATCH_MODES.KEYWORD, keyword }; + } + }); } - - if (bestGradNormConfig) { - newConfig.gradNorm = { - ...newConfig.gradNorm, - mode: bestGradNormConfig.mode, - keyword: bestGradNormConfig.keyword - }; + + if (newMetrics[1]) { + let maxCount = 0; + ['grad_norm', 'gradient_norm', 'gnorm', 'global_norm'].forEach(keyword => { + const matches = ValueExtractor.extractByKeyword(allContent, keyword); + if (matches.length > maxCount) { + maxCount = matches.length; + newMetrics[1] = { ...newMetrics[1], mode: MATCH_MODES.KEYWORD, keyword }; + } + }); } - - onGlobalParsingConfigChange(newConfig); + + onGlobalParsingConfigChange({ metrics: newMetrics }); }, [uploadedFiles, globalParsingConfig, onGlobalParsingConfigChange]); // 当配置变化时更新预览 @@ -313,20 +287,36 @@ export function RegexControls({ }, [showPreview, previewMatches]); // 处理配置变化 - const handleConfigChange = (type, field, value) => { - const newConfig = { ...globalParsingConfig }; - newConfig[type] = { ...newConfig[type], [field]: value }; - - // 如果是正则表达式模式的变更,同时更新兼容的正则状态 - if (field === 'regex') { - if (type === 'loss') { - onRegexChange('loss', value); - } else { - onRegexChange('gradNorm', value); + const handleMetricChange = (index, field, value) => { + const newMetrics = [...globalParsingConfig.metrics]; + newMetrics[index] = { ...newMetrics[index], [field]: value }; + onGlobalParsingConfigChange({ metrics: newMetrics }); + }; + + const addMetric = () => { + const newMetrics = [ + ...globalParsingConfig.metrics, + { + name: `metric${globalParsingConfig.metrics.length + 1}`, + mode: 'keyword', + keyword: '', + regex: '' } - } - - onGlobalParsingConfigChange(newConfig); + ]; + onGlobalParsingConfigChange({ metrics: newMetrics }); + }; + + const removeMetric = (index) => { + const newMetrics = globalParsingConfig.metrics.filter((_, i) => i !== index); + onGlobalParsingConfigChange({ metrics: newMetrics }); + }; + + const applyPreset = (index, presetLabel) => { + const preset = METRIC_PRESETS.find(p => p.label === presetLabel); + if (!preset) return; + const newMetrics = [...globalParsingConfig.metrics]; + newMetrics[index] = { ...newMetrics[index], ...preset }; + onGlobalParsingConfigChange({ metrics: newMetrics }); }; const handleXRangeChange = (field, value) => { @@ -335,11 +325,30 @@ export function RegexControls({ }; // 渲染配置项的函数 - const renderConfigPanel = (type, config, onConfigChange) => { + const renderConfigPanel = (type, config, onConfigChange, index) => { const ModeIcon = MODE_CONFIG[config.mode].icon; - + return (
+
+ + onConfigChange('name', e.target.value)} + className="w-full px-2 py-1 text-sm border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500 focus:outline-none" + /> + +
{/* 模式选择 */}
- {/* Loss 配置 */} -
-

- - Loss 解析配置 -

- {renderConfigPanel('loss', globalParsingConfig.loss, (field, value) => handleConfigChange('loss', field, value))} -
- - {/* Grad Norm 配置 */} -
-

- - Grad Norm 解析配置 -

- {renderConfigPanel('gradnorm', globalParsingConfig.gradNorm, (field, value) => handleConfigChange('gradNorm', field, value))} -
+ {globalParsingConfig.metrics.map((cfg, idx) => ( +
+ +

+ + {getMetricTitle(cfg, idx)} 解析配置 +

+ {renderConfigPanel(`metric-${idx}`, cfg, (field, value) => handleMetricChange(idx, field, value), idx)} +
+ ))} +
@@ -502,50 +516,29 @@ export function RegexControls({

匹配预览

- {previewResults.loss.map((result, idx) => ( -
-
- Loss - {result.fileName} - ({result.count} 个匹配) -
- {result.examples.length > 0 && ( -
- {result.examples.map((example, exIdx) => ( -
- {example.value} - (第{example.line}行) - {example.format && ( - [{example.format}] - )} -
{example.text}
-
- ))} + {Object.entries(previewResults).map(([key, results]) => ( + results.map((result, idx) => ( +
+
+ {key} - {result.fileName} + ({result.count} 个匹配)
- )} -
- ))} - - {previewResults.gradNorm.map((result, idx) => ( -
-
- Grad Norm - {result.fileName} - ({result.count} 个匹配) + {result.examples.length > 0 && ( +
+ {result.examples.map((example, exIdx) => ( +
+ {example.value} + (第{example.line}行) + {example.format && ( + [{example.format}] + )} +
{example.text}
+
+ ))} +
+ )}
- {result.examples.length > 0 && ( -
- {result.examples.map((example, exIdx) => ( -
- {example.value} - (第{example.line}行) - {example.format && ( - [{example.format}] - )} -
{example.text}
-
- ))} -
- )} -
+ )) ))}
diff --git a/src/metricPresets.js b/src/metricPresets.js new file mode 100644 index 0000000..8f0bd8d --- /dev/null +++ b/src/metricPresets.js @@ -0,0 +1,6 @@ +export const METRIC_PRESETS = [ + { label: 'Loss', name: 'Loss', mode: 'keyword', keyword: 'loss:' }, + { label: 'Grad Norm', name: 'Grad Norm', mode: 'keyword', keyword: 'norm:' }, + { label: 'Accuracy', name: 'Accuracy', mode: 'keyword', keyword: 'acc:' }, + { label: 'Learning Rate', name: 'Learning Rate', mode: 'keyword', keyword: 'lr:' } +];