Skip to content

Commit 03a481e

Browse files
committed
feat: support multi-file comparisons
1 parent b592eb9 commit 03a481e

6 files changed

Lines changed: 206 additions & 89 deletions

File tree

public/locales/en/translation.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@
1414
"fileList.delete": "Remove file {{name}}",
1515
"comparison.title": "⚖️ Compare Mode",
1616
"comparison.select": "Select comparison mode",
17+
"comparison.multiFileMode": "Multi-file comparison mode",
18+
"comparison.modeBaseline": "Baseline vs others",
19+
"comparison.modePairwise": "Pairwise comparisons",
20+
"comparison.baselineFile": "Baseline file",
1721
"comparison.normal": "📊 Mean Error (normal)",
1822
"comparison.normalDesc": "Mean error without absolute value",
1923
"comparison.absolute": "📈 Mean Error (absolute)",

public/locales/zh/translation.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@
1414
"fileList.delete": "删除文件 {{name}}",
1515
"comparison.title": "⚖️ 对比模式",
1616
"comparison.select": "选择数据对比模式",
17+
"comparison.multiFileMode": "多文件对比模式",
18+
"comparison.modeBaseline": "基准文件对比",
19+
"comparison.modePairwise": "成对比较",
20+
"comparison.baselineFile": "基准文件",
1721
"comparison.normal": "📊 平均误差 (normal)",
1822
"comparison.normalDesc": "未取绝对值的平均误差",
1923
"comparison.absolute": "📈 平均误差 (absolute)",

src/App.jsx

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ function App() {
4545
});
4646

4747
const [compareMode, setCompareMode] = useState('normal');
48+
const [multiFileMode, setMultiFileMode] = useState('baseline');
49+
const [baselineFile, setBaselineFile] = useState('');
4850
const [relativeBaseline, setRelativeBaseline] = useState(0.002);
4951
const [absoluteBaseline, setAbsoluteBaseline] = useState(0.005);
5052
const [configModalOpen, setConfigModalOpen] = useState(false);
@@ -55,6 +57,17 @@ function App() {
5557
const [maxStep, setMaxStep] = useState(0);
5658
const [sidebarVisible, setSidebarVisible] = useState(true);
5759
const savingDisabledRef = useRef(false);
60+
const enabledFiles = uploadedFiles.filter(file => file.enabled);
61+
62+
useEffect(() => {
63+
if (enabledFiles.length > 0) {
64+
if (!enabledFiles.find(f => f.name === baselineFile)) {
65+
setBaselineFile(enabledFiles[0].name);
66+
}
67+
} else {
68+
setBaselineFile('');
69+
}
70+
}, [enabledFiles, baselineFile]);
5871

5972
// Persist configuration to localStorage
6073
useEffect(() => {
@@ -387,10 +400,15 @@ function App() {
387400
onFileConfig={handleFileConfig}
388401
/>
389402

390-
{uploadedFiles.filter(file => file.enabled).length === 2 && (
403+
{enabledFiles.length >= 2 && (
391404
<ComparisonControls
392405
compareMode={compareMode}
393406
onCompareModeChange={setCompareMode}
407+
files={enabledFiles}
408+
baseline={baselineFile}
409+
onBaselineChange={setBaselineFile}
410+
multiFileMode={multiFileMode}
411+
onMultiFileModeChange={setMultiFileMode}
394412
/>
395413
)}
396414

@@ -475,6 +493,8 @@ function App() {
475493
files={uploadedFiles}
476494
metrics={globalParsingConfig.metrics}
477495
compareMode={compareMode}
496+
multiFileMode={multiFileMode}
497+
baselineFile={baselineFile}
478498
relativeBaseline={relativeBaseline}
479499
absoluteBaseline={absoluteBaseline}
480500
xRange={xRange}

src/components/ChartContainer.jsx

Lines changed: 68 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,8 @@ export default function ChartContainer({
9393
files,
9494
metrics = [],
9595
compareMode,
96+
multiFileMode = 'baseline',
97+
baselineFile,
9698
relativeBaseline = 0.002,
9799
absoluteBaseline = 0.005,
98100
xRange = { min: undefined, max: undefined },
@@ -531,40 +533,71 @@ export default function ChartContainer({
531533
elements: { point: { radius: 0 } }
532534
}), [xRange, onXRangeChange]);
533535

534-
const createComparisonChartData = (item1, item2, title) => {
535-
const comparisonData = getComparisonData(item1.data, item2.data, compareMode);
536-
const baseline =
536+
const buildComparisonChartData = (dataArray) => {
537+
const baselineVal =
537538
compareMode === 'relative' || compareMode === 'relative-normal'
538539
? relativeBaseline
539540
: compareMode === 'absolute'
540541
? absoluteBaseline
541542
: 0;
542-
const datasets = [
543-
{
544-
label: t('chart.diffLabel', { title }),
545-
data: comparisonData,
546-
borderColor: '#dc2626',
547-
backgroundColor: '#dc2626',
543+
const datasets = [];
544+
const stats = [];
545+
const addPair = (base, target, colorIdx) => {
546+
const diffData = getComparisonData(base.data, target.data, compareMode);
547+
const color = colors[colorIdx % colors.length];
548+
datasets.push({
549+
label: `${target.name} vs ${base.name}`,
550+
data: diffData,
551+
borderColor: color,
552+
backgroundColor: color,
548553
borderWidth: 2,
549554
fill: false,
550555
tension: 0,
551556
pointRadius: 0,
552557
pointHoverRadius: 4,
553-
pointBackgroundColor: '#dc2626',
554-
pointBorderColor: '#dc2626',
558+
pointBackgroundColor: color,
559+
pointBorderColor: color,
555560
pointBorderWidth: 1,
556-
pointHoverBackgroundColor: '#dc2626',
557-
pointHoverBorderColor: '#dc2626',
561+
pointHoverBackgroundColor: color,
562+
pointHoverBorderColor: color,
558563
pointHoverBorderWidth: 1,
559564
animation: false,
560565
animations: { colors: false, x: false, y: false },
561-
},
562-
];
563-
if (baseline > 0 && (compareMode === 'relative' || compareMode === 'relative-normal' || compareMode === 'absolute')) {
564-
const baselineData = comparisonData.map(p => ({ x: p.x, y: baseline }));
566+
});
567+
const normalDiff = getComparisonData(base.data, target.data, 'normal');
568+
const absDiff = getComparisonData(base.data, target.data, 'absolute');
569+
const relNormalDiff = getComparisonData(base.data, target.data, 'relative-normal');
570+
const relDiff = getComparisonData(base.data, target.data, 'relative');
571+
const mean = arr => (arr.reduce((s, p) => s + p.y, 0) / arr.length) || 0;
572+
stats.push({
573+
label: `${target.name} vs ${base.name}`,
574+
meanNormal: mean(normalDiff),
575+
meanAbsolute: mean(absDiff),
576+
relativeError: mean(relNormalDiff),
577+
meanRelative: mean(relDiff)
578+
});
579+
};
580+
581+
let colorIdx = 0;
582+
if (multiFileMode === 'baseline') {
583+
const base = dataArray.find(d => d.name === baselineFile) || dataArray[0];
584+
dataArray.forEach(item => {
585+
if (item.name === base.name) return;
586+
addPair(base, item, colorIdx++);
587+
});
588+
} else {
589+
for (let i = 0; i < dataArray.length; i++) {
590+
for (let j = i + 1; j < dataArray.length; j++) {
591+
addPair(dataArray[i], dataArray[j], colorIdx++);
592+
}
593+
}
594+
}
595+
596+
if (datasets.length > 0 && baselineVal > 0 && (compareMode === 'relative' || compareMode === 'relative-normal' || compareMode === 'absolute')) {
597+
const baseData = datasets[0].data.map(p => ({ x: p.x, y: baselineVal }));
565598
datasets.push({
566599
label: 'Baseline',
567-
data: baselineData,
600+
data: baseData,
568601
borderColor: '#10b981',
569602
backgroundColor: '#10b981',
570603
borderWidth: 2,
@@ -583,7 +616,8 @@ export default function ChartContainer({
583616
animations: { colors: false, x: false, y: false },
584617
});
585618
}
586-
return { datasets };
619+
620+
return { datasets, stats };
587621
};
588622

589623
if (parsedData.length === 0) {
@@ -626,7 +660,7 @@ export default function ChartContainer({
626660
const metricElements = metrics.map((metric, idx) => {
627661
const key = metric.name || metric.keyword || `metric${idx + 1}`;
628662
const dataArray = metricDataArrays[key] || [];
629-
const showComparison = dataArray.length === 2;
663+
const showComparison = dataArray.length >= 2;
630664

631665
const yRange = calculateYRange(dataArray);
632666
const options = {
@@ -638,28 +672,11 @@ export default function ChartContainer({
638672
};
639673

640674
let stats = null;
641-
if (showComparison) {
642-
const normalDiff = getComparisonData(dataArray[0].data, dataArray[1].data, 'normal');
643-
const absDiff = getComparisonData(dataArray[0].data, dataArray[1].data, 'absolute');
644-
const relNormalDiff = getComparisonData(
645-
dataArray[0].data,
646-
dataArray[1].data,
647-
'relative-normal'
648-
);
649-
const relDiff = getComparisonData(dataArray[0].data, dataArray[1].data, 'relative');
650-
const mean = arr => (arr.reduce((s, p) => s + p.y, 0) / arr.length) || 0;
651-
stats = {
652-
meanNormal: mean(normalDiff),
653-
meanAbsolute: mean(absDiff),
654-
relativeError: mean(relNormalDiff),
655-
meanRelative: mean(relDiff)
656-
};
657-
}
658-
659675
let comparisonChart = null;
660676
if (showComparison) {
661-
const compData = createComparisonChartData(dataArray[0], dataArray[1], key);
662-
const compRange = calculateYRange(compData.datasets);
677+
const compResult = buildComparisonChartData(dataArray);
678+
stats = compResult.stats.length > 0 ? compResult.stats : null;
679+
const compRange = calculateYRange(compResult.datasets);
663680
const compOptions = {
664681
...chartOptions,
665682
scales: {
@@ -705,7 +722,7 @@ export default function ChartContainer({
705722
onRegisterChart={registerChart}
706723
onSyncHover={syncHoverToAllCharts}
707724
syncRef={syncLockRef}
708-
data={compData}
725+
data={{ datasets: compResult.datasets }}
709726
options={compOptions}
710727
/>
711728
</ResizablePanel>
@@ -762,11 +779,16 @@ export default function ChartContainer({
762779
{stats && (
763780
<div className="card">
764781
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{key} {t('chart.diffStats')}</h4>
765-
<div className="space-y-1 text-xs">
766-
<p>{t('comparison.meanNormal', { value: stats.meanNormal.toFixed(6) })}</p>
767-
<p>{t('comparison.meanAbsolute', { value: stats.meanAbsolute.toFixed(6) })}</p>
768-
<p>{t('comparison.relativeError', { value: stats.relativeError.toFixed(6) })}</p>
769-
<p>{t('comparison.meanRelative', { value: stats.meanRelative.toFixed(6) })}</p>
782+
<div className="space-y-2 text-xs">
783+
{stats.map(s => (
784+
<div key={s.label} className="space-y-1">
785+
<p className="font-medium">{s.label}</p>
786+
<p>{t('comparison.meanNormal', { value: s.meanNormal.toFixed(6) })}</p>
787+
<p>{t('comparison.meanAbsolute', { value: s.meanAbsolute.toFixed(6) })}</p>
788+
<p>{t('comparison.relativeError', { value: s.relativeError.toFixed(6) })}</p>
789+
<p>{t('comparison.meanRelative', { value: s.meanRelative.toFixed(6) })}</p>
790+
</div>
791+
))}
770792
</div>
771793
</div>
772794
)}

src/components/ComparisonControls.jsx

Lines changed: 85 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,22 @@ import React from 'react';
22
import { BarChart2 } from 'lucide-react';
33
import { useTranslation } from 'react-i18next';
44

5-
export function ComparisonControls({
6-
compareMode,
7-
onCompareModeChange
8-
}) {
9-
const { t } = useTranslation();
10-
const modes = [
11-
{ value: 'normal', label: t('comparison.normal'), description: t('comparison.normalDesc') },
12-
{ value: 'absolute', label: t('comparison.absolute'), description: t('comparison.absoluteDesc') },
13-
{ value: 'relative-normal', label: t('comparison.relativeNormal'), description: t('comparison.relativeNormalDesc') },
14-
{ value: 'relative', label: t('comparison.relative'), description: t('comparison.relativeDesc') }
15-
];
5+
export function ComparisonControls({
6+
compareMode,
7+
onCompareModeChange,
8+
files = [],
9+
baseline,
10+
onBaselineChange,
11+
multiFileMode = 'baseline',
12+
onMultiFileModeChange
13+
}) {
14+
const { t } = useTranslation();
15+
const modes = [
16+
{ value: 'normal', label: t('comparison.normal'), description: t('comparison.normalDesc') },
17+
{ value: 'absolute', label: t('comparison.absolute'), description: t('comparison.absoluteDesc') },
18+
{ value: 'relative-normal', label: t('comparison.relativeNormal'), description: t('comparison.relativeNormalDesc') },
19+
{ value: 'relative', label: t('comparison.relative'), description: t('comparison.relativeDesc') }
20+
];
1621

1722
return (
1823
<section className="card" aria-labelledby="comparison-controls-heading">
@@ -26,40 +31,82 @@ import { useTranslation } from 'react-i18next';
2631
id="comparison-controls-heading"
2732
className="card-title"
2833
>
29-
{t('comparison.title')}
30-
</h3>
34+
{t('comparison.title')}
35+
</h3>
36+
</div>
37+
38+
<div className="space-y-4">
39+
<div>
40+
<label
41+
htmlFor="multi-file-mode"
42+
className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1"
43+
>
44+
{t('comparison.multiFileMode')}
45+
</label>
46+
<select
47+
id="multi-file-mode"
48+
value={multiFileMode}
49+
onChange={(e) => onMultiFileModeChange?.(e.target.value)}
50+
className="select"
51+
>
52+
<option value="baseline">{t('comparison.modeBaseline')}</option>
53+
<option value="pairwise">{t('comparison.modePairwise')}</option>
54+
</select>
3155
</div>
3256

57+
{multiFileMode === 'baseline' && files.length > 1 && (
58+
<div>
59+
<label
60+
htmlFor="baseline-file"
61+
className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1"
62+
>
63+
{t('comparison.baselineFile')}
64+
</label>
65+
<select
66+
id="baseline-file"
67+
value={baseline || (files[0]?.name ?? '')}
68+
onChange={(e) => onBaselineChange?.(e.target.value)}
69+
className="select"
70+
>
71+
{files.map(f => (
72+
<option key={f.name} value={f.name}>{f.name}</option>
73+
))}
74+
</select>
75+
</div>
76+
)}
77+
3378
<fieldset className="space-y-2">
3479
<legend className="sr-only">{t('comparison.select')}</legend>
3580
{modes.map(mode => (
3681
<label
37-
key={mode.value}
38-
className="flex items-center cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 p-1 rounded"
39-
>
40-
<input
41-
type="radio"
42-
name="compareMode"
43-
value={mode.value}
44-
checked={compareMode === mode.value}
45-
onChange={(e) => onCompareModeChange(e.target.value)}
46-
className="radio"
47-
aria-describedby={`mode-${mode.value}-description`}
48-
/>
49-
<div className="ml-2">
50-
<div className="text-xs font-medium text-gray-700 dark:text-gray-300">
51-
{mode.label}
52-
</div>
53-
<div
54-
id={`mode-${mode.value}-description`}
55-
className="text-xs text-gray-500 dark:text-gray-400"
56-
>
57-
{mode.description}
82+
key={mode.value}
83+
className="flex items-center cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 p-1 rounded"
84+
>
85+
<input
86+
type="radio"
87+
name="compareMode"
88+
value={mode.value}
89+
checked={compareMode === mode.value}
90+
onChange={(e) => onCompareModeChange(e.target.value)}
91+
className="radio"
92+
aria-describedby={`mode-${mode.value}-description`}
93+
/>
94+
<div className="ml-2">
95+
<div className="text-xs font-medium text-gray-700 dark:text-gray-300">
96+
{mode.label}
97+
</div>
98+
<div
99+
id={`mode-${mode.value}-description`}
100+
className="text-xs text-gray-500 dark:text-gray-400"
101+
>
102+
{mode.description}
103+
</div>
58104
</div>
59-
</div>
60-
</label>
61-
))}
62-
</fieldset>
105+
</label>
106+
))}
107+
</fieldset>
108+
</div>
63109
</section>
64110
);
65111
}
112+

0 commit comments

Comments
 (0)