diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 1aa629c..9b51484 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -12,15 +12,19 @@ "fileList.disabled": "Disabled", "fileList.config": "Configure file {{name}}", "fileList.delete": "Remove file {{name}}", - "comparison.title": "⚖️ Compare Mode", + "comparison.title": "Compare Mode", "comparison.select": "Select comparison mode", - "comparison.normal": "📊 Mean Error (normal)", + "comparison.multiFileMode": "Multi-file comparison mode", + "comparison.modeBaseline": "Baseline vs others", + "comparison.modePairwise": "Pairwise comparisons", + "comparison.baselineFile": "Baseline file", + "comparison.normal": "Mean Error (normal)", "comparison.normalDesc": "Mean error without absolute value", - "comparison.absolute": "📈 Mean Error (absolute)", + "comparison.absolute": "Mean Error (absolute)", "comparison.absoluteDesc": "Mean of absolute differences", - "comparison.relativeNormal": "📉 Relative Error (normal)", + "comparison.relativeNormal": "Relative Error (normal)", "comparison.relativeNormalDesc": "Relative error without absolute value", - "comparison.relative": "📊 Mean Relative Error (absolute)", + "comparison.relative": "Mean Relative Error (absolute)", "comparison.relativeDesc": "Mean of absolute relative error", "themeToggle.aria": "Toggle theme", "chart.noData": "📊 No data", @@ -61,11 +65,18 @@ "chart.area": "Chart display area", "chart.actions": "Chart action buttons", "chart.diffLabel": "{{title}} difference", - "comparison.panelTitle": "⚖️ {{key}} comparison ({{mode}})", + "comparison.panelTitle": "{{key}} comparison ({{mode}})", "comparison.meanNormal": "Mean error (normal): {{value}}", "comparison.meanAbsolute": "Mean error (absolute): {{value}}", "comparison.relativeError": "Relative error (normal): {{value}}", "comparison.meanRelative": "Mean relative error (absolute): {{value}}", + "comparison.pair": "Pair", + "comparison.meanNormalLabel": "Mean error (normal)", + "comparison.meanAbsoluteLabel": "Mean error (absolute)", + "comparison.relativeErrorLabel": "Relative error (normal)", + "comparison.meanRelativeLabel": "Mean relative error (absolute)", + "comparison.maxAbsoluteLabel": "Max absolute error", + "comparison.maxRelativeLabel": "Max relative error", "regex.preset": "Preset", "regex.selectPreset": "Select preset", "regex.mode": "Match Mode", diff --git a/public/locales/zh/translation.json b/public/locales/zh/translation.json index 8ae331d..200b4ab 100644 --- a/public/locales/zh/translation.json +++ b/public/locales/zh/translation.json @@ -12,15 +12,19 @@ "fileList.disabled": "已禁用", "fileList.config": "配置文件 {{name}}", "fileList.delete": "删除文件 {{name}}", - "comparison.title": "⚖️ 对比模式", + "comparison.title": "对比模式", "comparison.select": "选择数据对比模式", - "comparison.normal": "📊 平均误差 (normal)", + "comparison.multiFileMode": "多文件对比模式", + "comparison.modeBaseline": "基准文件对比", + "comparison.modePairwise": "成对比较", + "comparison.baselineFile": "基准文件", + "comparison.normal": "平均误差 (normal)", "comparison.normalDesc": "未取绝对值的平均误差", - "comparison.absolute": "📈 平均误差 (absolute)", + "comparison.absolute": "平均误差 (absolute)", "comparison.absoluteDesc": "绝对值差值的平均", - "comparison.relativeNormal": "📉 相对误差 (normal)", + "comparison.relativeNormal": "相对误差 (normal)", "comparison.relativeNormalDesc": "不取绝对值的相对误差", - "comparison.relative": "📊 平均相对误差 (absolute)", + "comparison.relative": "平均相对误差 (absolute)", "comparison.relativeDesc": "绝对相对误差的平均", "themeToggle.aria": "切换主题", "chart.noData": "📊 暂无数据", @@ -61,11 +65,18 @@ "chart.area": "图表显示区域", "chart.actions": "图表操作按钮", "chart.diffLabel": "{{title}} 差值", - "comparison.panelTitle": "⚖️ {{key}} 对比分析 ({{mode}})", + "comparison.panelTitle": "{{key}} 对比分析 ({{mode}})", "comparison.meanNormal": "平均误差 (normal): {{value}}", "comparison.meanAbsolute": "平均误差 (absolute): {{value}}", "comparison.relativeError": "相对误差 (normal): {{value}}", "comparison.meanRelative": "平均相对误差 (absolute): {{value}}", + "comparison.pair": "文件对", + "comparison.meanNormalLabel": "平均误差 (normal)", + "comparison.meanAbsoluteLabel": "平均误差 (absolute)", + "comparison.relativeErrorLabel": "相对误差 (normal)", + "comparison.meanRelativeLabel": "平均相对误差 (absolute)", + "comparison.maxAbsoluteLabel": "最大绝对误差", + "comparison.maxRelativeLabel": "最大相对误差", "regex.preset": "预设", "regex.selectPreset": "选择预设", "regex.mode": "匹配模式", diff --git a/src/App.jsx b/src/App.jsx index 961d291..4a942e6 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -45,6 +45,8 @@ function App() { }); const [compareMode, setCompareMode] = useState('normal'); + const [multiFileMode, setMultiFileMode] = useState('baseline'); + const [baselineFile, setBaselineFile] = useState(''); const [relativeBaseline, setRelativeBaseline] = useState(0.002); const [absoluteBaseline, setAbsoluteBaseline] = useState(0.005); const [configModalOpen, setConfigModalOpen] = useState(false); @@ -55,6 +57,17 @@ function App() { const [maxStep, setMaxStep] = useState(0); const [sidebarVisible, setSidebarVisible] = useState(true); const savingDisabledRef = useRef(false); + const enabledFiles = uploadedFiles.filter(file => file.enabled); + + useEffect(() => { + if (enabledFiles.length > 0) { + if (!enabledFiles.find(f => f.name === baselineFile)) { + setBaselineFile(enabledFiles[0].name); + } + } else { + setBaselineFile(''); + } + }, [enabledFiles, baselineFile]); // Persist configuration to localStorage useEffect(() => { @@ -387,10 +400,15 @@ function App() { onFileConfig={handleFileConfig} /> - {uploadedFiles.filter(file => file.enabled).length === 2 && ( + {enabledFiles.length >= 2 && ( )} @@ -475,6 +493,8 @@ function App() { files={uploadedFiles} metrics={globalParsingConfig.metrics} compareMode={compareMode} + multiFileMode={multiFileMode} + baselineFile={baselineFile} relativeBaseline={relativeBaseline} absoluteBaseline={absoluteBaseline} xRange={xRange} diff --git a/src/components/ChartContainer.jsx b/src/components/ChartContainer.jsx index 0e84f03..8a6c5e3 100644 --- a/src/components/ChartContainer.jsx +++ b/src/components/ChartContainer.jsx @@ -93,6 +93,8 @@ export default function ChartContainer({ files, metrics = [], compareMode, + multiFileMode = 'baseline', + baselineFile, relativeBaseline = 0.002, absoluteBaseline = 0.005, xRange = { min: undefined, max: undefined }, @@ -531,40 +533,74 @@ export default function ChartContainer({ elements: { point: { radius: 0 } } }), [xRange, onXRangeChange]); - const createComparisonChartData = (item1, item2, title) => { - const comparisonData = getComparisonData(item1.data, item2.data, compareMode); - const baseline = + const buildComparisonChartData = (dataArray) => { + const baselineVal = compareMode === 'relative' || compareMode === 'relative-normal' ? relativeBaseline : compareMode === 'absolute' ? absoluteBaseline : 0; - const datasets = [ - { - label: t('chart.diffLabel', { title }), - data: comparisonData, - borderColor: '#dc2626', - backgroundColor: '#dc2626', + const datasets = []; + const stats = []; + const addPair = (base, target, colorIdx) => { + const diffData = getComparisonData(base.data, target.data, compareMode); + const color = colors[colorIdx % colors.length]; + datasets.push({ + label: `${target.name} vs ${base.name}`, + data: diffData, + borderColor: color, + backgroundColor: color, borderWidth: 2, fill: false, tension: 0, pointRadius: 0, pointHoverRadius: 4, - pointBackgroundColor: '#dc2626', - pointBorderColor: '#dc2626', + pointBackgroundColor: color, + pointBorderColor: color, pointBorderWidth: 1, - pointHoverBackgroundColor: '#dc2626', - pointHoverBorderColor: '#dc2626', + pointHoverBackgroundColor: color, + pointHoverBorderColor: color, pointHoverBorderWidth: 1, animation: false, animations: { colors: false, x: false, y: false }, - }, - ]; - if (baseline > 0 && (compareMode === 'relative' || compareMode === 'relative-normal' || compareMode === 'absolute')) { - const baselineData = comparisonData.map(p => ({ x: p.x, y: baseline })); + }); + const normalDiff = getComparisonData(base.data, target.data, 'normal'); + const absDiff = getComparisonData(base.data, target.data, 'absolute'); + const relNormalDiff = getComparisonData(base.data, target.data, 'relative-normal'); + const relDiff = getComparisonData(base.data, target.data, 'relative'); + const mean = arr => (arr.reduce((s, p) => s + p.y, 0) / arr.length) || 0; + const max = arr => arr.reduce((m, p) => (p.y > m ? p.y : m), 0); + stats.push({ + label: `${target.name} vs ${base.name}`, + meanNormal: mean(normalDiff), + meanAbsolute: mean(absDiff), + relativeError: mean(relNormalDiff), + meanRelative: mean(relDiff), + maxAbsolute: max(absDiff), + maxRelative: max(relDiff) + }); + }; + + let colorIdx = 0; + if (multiFileMode === 'baseline') { + const base = dataArray.find(d => d.name === baselineFile) || dataArray[0]; + dataArray.forEach(item => { + if (item.name === base.name) return; + addPair(base, item, colorIdx++); + }); + } else { + for (let i = 0; i < dataArray.length; i++) { + for (let j = i + 1; j < dataArray.length; j++) { + addPair(dataArray[i], dataArray[j], colorIdx++); + } + } + } + + if (datasets.length > 0 && baselineVal > 0 && (compareMode === 'relative' || compareMode === 'relative-normal' || compareMode === 'absolute')) { + const baseData = datasets[0].data.map(p => ({ x: p.x, y: baselineVal })); datasets.push({ label: 'Baseline', - data: baselineData, + data: baseData, borderColor: '#10b981', backgroundColor: '#10b981', borderWidth: 2, @@ -583,7 +619,8 @@ export default function ChartContainer({ animations: { colors: false, x: false, y: false }, }); } - return { datasets }; + + return { datasets, stats }; }; if (parsedData.length === 0) { @@ -626,7 +663,7 @@ export default function ChartContainer({ const metricElements = metrics.map((metric, idx) => { const key = metric.name || metric.keyword || `metric${idx + 1}`; const dataArray = metricDataArrays[key] || []; - const showComparison = dataArray.length === 2; + const showComparison = dataArray.length >= 2; const yRange = calculateYRange(dataArray); const options = { @@ -638,28 +675,11 @@ export default function ChartContainer({ }; 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 relNormalDiff = getComparisonData( - dataArray[0].data, - dataArray[1].data, - 'relative-normal' - ); - 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), - relativeError: mean(relNormalDiff), - meanRelative: mean(relDiff) - }; - } - let comparisonChart = null; if (showComparison) { - const compData = createComparisonChartData(dataArray[0], dataArray[1], key); - const compRange = calculateYRange(compData.datasets); + const compResult = buildComparisonChartData(dataArray); + stats = compResult.stats.length > 0 ? compResult.stats : null; + const compRange = calculateYRange(compResult.datasets); const compOptions = { ...chartOptions, scales: { @@ -705,7 +725,7 @@ export default function ChartContainer({ onRegisterChart={registerChart} onSyncHover={syncHoverToAllCharts} syncRef={syncLockRef} - data={compData} + data={{ datasets: compResult.datasets }} options={compOptions} /> @@ -760,14 +780,32 @@ export default function ChartContainer({ {comparisonChart} {stats && ( -
-

{key} {t('chart.diffStats')}

-
-

{t('comparison.meanNormal', { value: stats.meanNormal.toFixed(6) })}

-

{t('comparison.meanAbsolute', { value: stats.meanAbsolute.toFixed(6) })}

-

{t('comparison.relativeError', { value: stats.relativeError.toFixed(6) })}

-

{t('comparison.meanRelative', { value: stats.meanRelative.toFixed(6) })}

-
+
+

{key} {t('chart.diffStats')}

+ + + + + + + + + + + + + {stats.map(s => ( + + + + + + + + + ))} + +
{t('comparison.pair')}{t('comparison.meanNormalLabel')}{t('comparison.meanAbsoluteLabel')}{t('comparison.maxAbsoluteLabel')}{t('comparison.meanRelativeLabel')}{t('comparison.maxRelativeLabel')}
{s.label}{s.meanNormal.toFixed(6)}{s.meanAbsolute.toFixed(6)}{s.maxAbsolute.toFixed(6)}{s.meanRelative.toFixed(6)}{s.maxRelative.toFixed(6)}
)}
diff --git a/src/components/ComparisonControls.jsx b/src/components/ComparisonControls.jsx index 7e72aa0..8b651c6 100644 --- a/src/components/ComparisonControls.jsx +++ b/src/components/ComparisonControls.jsx @@ -2,17 +2,22 @@ import React from 'react'; import { BarChart2 } from 'lucide-react'; import { useTranslation } from 'react-i18next'; - export function ComparisonControls({ - compareMode, - onCompareModeChange - }) { - const { t } = useTranslation(); - const modes = [ - { value: 'normal', label: t('comparison.normal'), description: t('comparison.normalDesc') }, - { value: 'absolute', label: t('comparison.absolute'), description: t('comparison.absoluteDesc') }, - { value: 'relative-normal', label: t('comparison.relativeNormal'), description: t('comparison.relativeNormalDesc') }, - { value: 'relative', label: t('comparison.relative'), description: t('comparison.relativeDesc') } - ]; +export function ComparisonControls({ + compareMode, + onCompareModeChange, + files = [], + baseline, + onBaselineChange, + multiFileMode = 'baseline', + onMultiFileModeChange +}) { + const { t } = useTranslation(); + const modes = [ + { value: 'normal', label: t('comparison.normal'), description: t('comparison.normalDesc') }, + { value: 'absolute', label: t('comparison.absolute'), description: t('comparison.absoluteDesc') }, + { value: 'relative-normal', label: t('comparison.relativeNormal'), description: t('comparison.relativeNormalDesc') }, + { value: 'relative', label: t('comparison.relative'), description: t('comparison.relativeDesc') } + ]; return (
@@ -26,40 +31,109 @@ import { useTranslation } from 'react-i18next'; id="comparison-controls-heading" className="card-title" > - {t('comparison.title')} - + {t('comparison.title')} + + + +
+
+ + {t('comparison.multiFileMode')} + +
+ + +
+ {multiFileMode === 'baseline' && files.length > 1 && ( +
+ + {t('comparison.baselineFile')} + +
+ {files.map(f => ( + + ))} +
+
+ )} +
{t('comparison.select')} {modes.map(mode => (
+ + ))} + +
); } + diff --git a/src/components/__tests__/ComparisonControls.test.jsx b/src/components/__tests__/ComparisonControls.test.jsx index 8ecdb39..3c7d14d 100644 --- a/src/components/__tests__/ComparisonControls.test.jsx +++ b/src/components/__tests__/ComparisonControls.test.jsx @@ -5,15 +5,35 @@ import { ComparisonControls } from '../ComparisonControls'; import i18n from '../../i18n'; describe('ComparisonControls', () => { - it('calls handler when mode changes', async () => { + it('triggers callbacks for mode, strategy, and baseline changes', async () => { const user = userEvent.setup(); - const handleChange = vi.fn(); + const handleMode = vi.fn(); + const handleBaseline = vi.fn(); + const handleStrategy = vi.fn(); + const files = [{ name: 'a.log' }, { name: 'b.log' }]; + render( - + ); const absoluteOption = screen.getByLabelText(i18n.t('comparison.absolute'), { exact: false }); await user.click(absoluteOption); - expect(handleChange).toHaveBeenCalledWith('absolute'); + expect(handleMode).toHaveBeenCalledWith('absolute'); + + const pairwiseButton = screen.getByRole('button', { name: i18n.t('comparison.modePairwise') }); + await user.click(pairwiseButton); + expect(handleStrategy).toHaveBeenCalledWith('pairwise'); + + const baselineRadio = screen.getByLabelText('b.log'); + await user.click(baselineRadio); + expect(handleBaseline).toHaveBeenCalledWith('b.log'); }); });