From 7e64719f2718a6da807993ff1d99005149804c08 Mon Sep 17 00:00:00 2001
From: JavaZero <71128095+JavaZeroo@users.noreply.github.com>
Date: Thu, 14 Aug 2025 10:21:26 +0800
Subject: [PATCH 1/6] feat: support step keyword positioning
---
src/App.jsx | 28 +++-
src/components/ChartContainer.jsx | 134 ++++++++++++++----
.../__tests__/ChartContainer.test.jsx | 29 ++--
3 files changed, 150 insertions(+), 41 deletions(-)
diff --git a/src/App.jsx b/src/App.jsx
index fce2a15..1550d1e 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -40,6 +40,8 @@ function App() {
const [xRange, setXRange] = useState({ min: undefined, max: undefined });
const [maxStep, setMaxStep] = useState(0);
const [sidebarVisible, setSidebarVisible] = useState(true);
+ const [useStepKeyword, setUseStepKeyword] = useState(false);
+ const [stepKeyword, setStepKeyword] = useState('step:');
const handleFilesUploaded = useCallback((files) => {
const filesWithDefaults = files.map(file => ({
@@ -340,7 +342,29 @@ function App() {
基准线设置
@@ -414,6 +438,8 @@ function App() {
xRange={xRange}
onXRangeChange={setXRange}
onMaxStepChange={setMaxStep}
+ useStepKeyword={useStepKeyword}
+ stepKeyword={stepKeyword}
/>
diff --git a/src/components/ChartContainer.jsx b/src/components/ChartContainer.jsx
index b10f0f5..7e1eae8 100644
--- a/src/components/ChartContainer.jsx
+++ b/src/components/ChartContainer.jsx
@@ -68,7 +68,9 @@ export default function ChartContainer({
absoluteBaseline = 0.005,
xRange = { min: undefined, max: undefined },
onXRangeChange,
- onMaxStepChange
+ onMaxStepChange,
+ useStepKeyword = false,
+ stepKeyword = 'step:'
}) {
const chartRefs = useRef(new Map());
const registerChart = useCallback((id, inst) => {
@@ -103,17 +105,60 @@ export default function ChartContainer({
const lines = file.content.split('\n');
const metricsData = {};
- const extractByKeyword = (content, keyword) => {
+ const numberRegex = /[+-]?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/;
+
+ const extractByKeyword = (keyword) => {
const results = [];
- const numberRegex = /[+-]?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/;
- content.split('\n').forEach(line => {
- const idx = line.toLowerCase().indexOf(keyword.toLowerCase());
+ const lowerMetric = keyword.toLowerCase();
+ lines.forEach(line => {
+ const idx = line.toLowerCase().indexOf(lowerMetric);
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);
+ if (!isNaN(v)) {
+ let x = results.length;
+ if (useStepKeyword) {
+ const stepIdx = line.toLowerCase().indexOf(stepKeyword.toLowerCase());
+ if (stepIdx !== -1) {
+ const stepAfter = line.substring(stepIdx + stepKeyword.length);
+ const stepMatch = stepAfter.match(numberRegex);
+ if (stepMatch) {
+ const s = parseFloat(stepMatch[0]);
+ if (!isNaN(s)) x = s;
+ }
+ }
+ }
+ results.push({ x, y: v });
+ }
+ }
+ }
+ });
+ return results;
+ };
+
+ const extractByRegex = (reg) => {
+ const results = [];
+ lines.forEach(line => {
+ reg.lastIndex = 0;
+ const m = reg.exec(line);
+ if (m && m[1]) {
+ const v = parseFloat(m[1]);
+ if (!isNaN(v)) {
+ let x = results.length;
+ if (useStepKeyword) {
+ const stepIdx = line.toLowerCase().indexOf(stepKeyword.toLowerCase());
+ if (stepIdx !== -1) {
+ const stepAfter = line.substring(stepIdx + stepKeyword.length);
+ const stepMatch = stepAfter.match(numberRegex);
+ if (stepMatch) {
+ const s = parseFloat(stepMatch[0]);
+ if (!isNaN(s)) x = s;
+ }
+ }
+ }
+ results.push({ x, y: v });
}
}
});
@@ -123,39 +168,38 @@ export default function ChartContainer({
metrics.forEach(metric => {
let values = [];
if (metric.mode === 'keyword') {
- values = extractByKeyword(file.content, metric.keyword);
+ values = extractByKeyword(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);
- }
- });
+ values = extractByRegex(reg);
}
- metricsData[metric.name || metric.keyword] = values.map((v, i) => ({ x: i, y: v }));
+ metricsData[metric.name || metric.keyword] = values;
});
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(range.start) || 0);
- const end = range.end !== undefined ? parseInt(range.end) : data.length;
- const endIndex = Math.min(data.length, end);
- return data.slice(start, endIndex);
+ if (useStepKeyword) {
+ const start = parseInt(range.start) || 0;
+ const end = range.end !== undefined ? parseInt(range.end) : Infinity;
+ return data.filter(p => p.x >= start && p.x <= end);
+ } else {
+ 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 sliced = data.slice(start, endIndex);
+ return sliced.map((p, idx) => ({ x: idx, y: p.y }));
+ }
};
- const reindex = data => data.map((p, idx) => ({ x: idx, y: p.y }));
Object.keys(metricsData).forEach(k => {
- metricsData[k] = reindex(applyRange(metricsData[k]));
+ metricsData[k] = applyRange(metricsData[k]);
});
}
return { ...file, metricsData };
});
- }, [files, metrics]);
+ }, [files, metrics, useStepKeyword, stepKeyword]);
useEffect(() => {
const maxStep = parsedData.reduce((m, f) => {
@@ -166,15 +210,43 @@ export default function ChartContainer({
}, [parsedData, onMaxStepChange]);
useEffect(() => {
- const minSteps = getMinSteps(parsedData);
- if (minSteps > 0) {
- onXRangeChange(prev => {
- const next = { min: 0, max: minSteps - 1 };
- if (prev.min === next.min && prev.max === next.max) return prev;
- return next;
- });
+ if (useStepKeyword) {
+ const ranges = parsedData.map(f => {
+ const datasets = Object.values(f.metricsData);
+ if (datasets.length === 0) return null;
+ let start = -Infinity;
+ let end = Infinity;
+ datasets.forEach(d => {
+ if (d.length > 0) {
+ if (d[0].x > start) start = d[0].x;
+ if (d[d.length - 1].x < end) end = d[d.length - 1].x;
+ }
+ });
+ if (start === -Infinity || end === Infinity) return null;
+ return { start, end };
+ }).filter(Boolean);
+ if (ranges.length > 0) {
+ const start = Math.max(...ranges.map(r => r.start));
+ const end = Math.min(...ranges.map(r => r.end));
+ if (end >= start) {
+ onXRangeChange(prev => {
+ const next = { min: start, max: end };
+ if (prev.min === next.min && prev.max === next.max) return prev;
+ return next;
+ });
+ }
+ }
+ } else {
+ const minSteps = getMinSteps(parsedData);
+ if (minSteps > 0) {
+ onXRangeChange(prev => {
+ const next = { min: 0, max: minSteps - 1 };
+ if (prev.min === next.min && prev.max === next.max) return prev;
+ return next;
+ });
+ }
}
- }, [parsedData, onXRangeChange]);
+ }, [parsedData, onXRangeChange, useStepKeyword]);
const colors = ['#ef4444', '#3b82f6', '#10b981', '#f59e0b', '#8b5cf6', '#f97316'];
const createChartData = dataArray => ({
diff --git a/src/components/__tests__/ChartContainer.test.jsx b/src/components/__tests__/ChartContainer.test.jsx
index 26543e1..b541ac8 100644
--- a/src/components/__tests__/ChartContainer.test.jsx
+++ b/src/components/__tests__/ChartContainer.test.jsx
@@ -63,14 +63,25 @@ describe('ChartContainer', () => {
expect(screen.getByText('🎯 请选择要显示的图表')).toBeInTheDocument();
});
- it('renders charts and triggers callbacks', async () => {
- const { onXRangeChange, onMaxStepChange } = renderComponent({ files: [sampleFile], metrics: [metric] });
- expect(await screen.findByText('📊 loss')).toBeInTheDocument();
- await waitFor(() => {
- expect(onMaxStepChange).toHaveBeenCalledWith(1);
- expect(onXRangeChange).toHaveBeenCalled();
+ it('renders charts and triggers callbacks', async () => {
+ const { onXRangeChange, onMaxStepChange } = renderComponent({ files: [sampleFile], metrics: [metric] });
+ expect(await screen.findByText('📊 loss')).toBeInTheDocument();
+ await waitFor(() => {
+ expect(onMaxStepChange).toHaveBeenCalledWith(1);
+ expect(onXRangeChange).toHaveBeenCalled();
+ });
+ const cb = onXRangeChange.mock.calls[0][0];
+ expect(cb({})).toEqual({ min: 0, max: 1 });
+ });
+
+ it('uses step keyword for x positions when enabled', async () => {
+ const file = { name: 's.log', id: '2', content: 'step: 2 loss: 1\nstep: 4 loss: 2' };
+ const { onXRangeChange, onMaxStepChange } = renderComponent({ files: [file], metrics: [metric], useStepKeyword: true, stepKeyword: 'step:' });
+ await waitFor(() => {
+ expect(onMaxStepChange).toHaveBeenCalledWith(4);
+ expect(onXRangeChange).toHaveBeenCalled();
+ });
+ const cb = onXRangeChange.mock.calls[0][0];
+ expect(cb({})).toEqual({ min: 2, max: 4 });
});
- const cb = onXRangeChange.mock.calls[0][0];
- expect(cb({})).toEqual({ min: 0, max: 1 });
});
-});
From 13551ba2030efd98096ba1e9292463a391a8232e Mon Sep 17 00:00:00 2001
From: JavaZero <71128095+JavaZeroo@users.noreply.github.com>
Date: Thu, 14 Aug 2025 10:43:44 +0800
Subject: [PATCH 2/6] feat: show step range and align comparison
---
src/App.jsx | 1 +
src/components/ChartContainer.jsx | 104 +++++++++---------
src/components/FileConfigModal.jsx | 42 ++++++-
.../__tests__/ChartContainer.test.jsx | 14 ++-
.../__tests__/FileConfigModal.test.jsx | 26 +++++
5 files changed, 130 insertions(+), 57 deletions(-)
create mode 100644 src/components/__tests__/FileConfigModal.test.jsx
diff --git a/src/App.jsx b/src/App.jsx
index 1550d1e..0f0ddd3 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -451,6 +451,7 @@ function App() {
onClose={handleConfigClose}
onSave={handleConfigSave}
globalParsingConfig={globalParsingConfig}
+ stepKeyword={stepKeyword}
/>
);
diff --git a/src/components/ChartContainer.jsx b/src/components/ChartContainer.jsx
index 7e1eae8..0b61d59 100644
--- a/src/components/ChartContainer.jsx
+++ b/src/components/ChartContainer.jsx
@@ -26,6 +26,33 @@ ChartJS.register(
zoomPlugin
);
+export const getComparisonData = (data1, data2, mode) => {
+ const map1 = new Map(data1.map(p => [p.x, p.y]));
+ const map2 = new Map(data2.map(p => [p.x, p.y]));
+ const steps = [...map1.keys()].filter(k => map2.has(k)).sort((a, b) => a - b);
+ return steps.map(step => {
+ const v1 = map1.get(step);
+ const v2 = map2.get(step);
+ let diff;
+ switch (mode) {
+ case 'absolute':
+ diff = Math.abs(v2 - v1);
+ break;
+ case 'relative-normal':
+ diff = v1 !== 0 ? (v2 - v1) / v1 : 0;
+ break;
+ case 'relative': {
+ const ad = Math.abs(v2 - v1);
+ diff = v1 !== 0 ? ad / Math.abs(v1) : 0;
+ break;
+ }
+ default:
+ diff = v2 - v1;
+ }
+ return { x: step, y: diff };
+ });
+};
+
const ChartWrapper = ({ data, options, chartId, onRegisterChart, onSyncHover }) => {
const chartRef = useRef(null);
@@ -248,58 +275,31 @@ export default function ChartContainer({
}
}, [parsedData, onXRangeChange, useStepKeyword]);
- 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 v1 = data1[i].y;
- const v2 = data2[i].y;
- let diff;
- switch (mode) {
- case 'absolute':
- diff = Math.abs(v2 - v1);
- break;
- case 'relative-normal':
- diff = v1 !== 0 ? (v2 - v1) / v1 : 0;
- break;
- case 'relative': {
- const ad = Math.abs(v2 - v1);
- diff = v1 !== 0 ? ad / Math.abs(v1) : 0;
- break;
- }
- default:
- diff = v2 - v1;
- }
- result.push({ x: i, y: diff });
- }
- return result;
- };
+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 calculateYRange = useCallback((dataArray) => {
let min = Infinity;
diff --git a/src/components/FileConfigModal.jsx b/src/components/FileConfigModal.jsx
index 6428b20..898505b 100644
--- a/src/components/FileConfigModal.jsx
+++ b/src/components/FileConfigModal.jsx
@@ -34,7 +34,7 @@ function getMetricTitle(metric, index) {
return `Metric ${index + 1}`;
}
-export function FileConfigModal({ file, isOpen, onClose, onSave, globalParsingConfig }) {
+export function FileConfigModal({ file, isOpen, onClose, onSave, globalParsingConfig, stepKeyword = 'step:' }) {
const [config, setConfig] = useState({
metrics: [],
dataRange: {
@@ -43,6 +43,7 @@ export function FileConfigModal({ file, isOpen, onClose, onSave, globalParsingCo
useRange: false // 保留用于向后兼容
}
});
+ const [stepRange, setStepRange] = useState(null);
useEffect(() => {
if (file && isOpen) {
@@ -56,8 +57,38 @@ export function FileConfigModal({ file, isOpen, onClose, onSave, globalParsingCo
useRange: false
}
});
+
+ // 计算日志文件包含的 step 范围
+ if (file.content) {
+ const lines = file.content.split('\n');
+ const numberRegex = /[+-]?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/;
+ const keywordLower = stepKeyword.toLowerCase();
+ let min = Infinity;
+ let max = -Infinity;
+ lines.forEach(line => {
+ const idx = line.toLowerCase().indexOf(keywordLower);
+ if (idx !== -1) {
+ const after = line.substring(idx + stepKeyword.length);
+ const match = after.match(numberRegex);
+ if (match) {
+ const v = parseFloat(match[0]);
+ if (!isNaN(v)) {
+ if (v < min) min = v;
+ if (v > max) max = v;
+ }
+ }
+ }
+ });
+ if (min !== Infinity && max !== -Infinity) {
+ setStepRange({ start: min, end: max });
+ } else {
+ setStepRange(null);
+ }
+ } else {
+ setStepRange(null);
+ }
}
- }, [file, isOpen, globalParsingConfig]);
+ }, [file, isOpen, globalParsingConfig, stepKeyword]);
const handleSave = () => {
onSave(file.id, config);
@@ -256,7 +287,12 @@ export function FileConfigModal({ file, isOpen, onClose, onSave, globalParsingCo
配置要显示的数据点范围。默认显示全部数据(从第一个到最后一个数据点)。
-
+ {stepRange && (
+
+ 当前日志包含步骤: {stepRange.start} - {stepRange.end}
+
+ )}
+
diff --git a/src/components/__tests__/ChartContainer.test.jsx b/src/components/__tests__/ChartContainer.test.jsx
index b541ac8..afeca2e 100644
--- a/src/components/__tests__/ChartContainer.test.jsx
+++ b/src/components/__tests__/ChartContainer.test.jsx
@@ -26,7 +26,7 @@ vi.mock('chart.js', () => {
vi.mock('chartjs-plugin-zoom', () => ({ default: {} }));
-import ChartContainer from '../ChartContainer.jsx';
+import ChartContainer, { getComparisonData } from '../ChartContainer.jsx';
const sampleFile = {
name: 'test.log',
@@ -74,7 +74,7 @@ describe('ChartContainer', () => {
expect(cb({})).toEqual({ min: 0, max: 1 });
});
- it('uses step keyword for x positions when enabled', async () => {
+ it('uses step keyword for x positions when enabled', async () => {
const file = { name: 's.log', id: '2', content: 'step: 2 loss: 1\nstep: 4 loss: 2' };
const { onXRangeChange, onMaxStepChange } = renderComponent({ files: [file], metrics: [metric], useStepKeyword: true, stepKeyword: 'step:' });
await waitFor(() => {
@@ -84,4 +84,14 @@ describe('ChartContainer', () => {
const cb = onXRangeChange.mock.calls[0][0];
expect(cb({})).toEqual({ min: 2, max: 4 });
});
+
+ it('computes comparison only on overlapping steps', () => {
+ const d1 = [{ x: 1, y: 1 }, { x: 2, y: 2 }, { x: 3, y: 3 }];
+ const d2 = [{ x: 2, y: 2 }, { x: 3, y: 4 }, { x: 4, y: 5 }];
+ const res = getComparisonData(d1, d2, 'normal');
+ expect(res).toEqual([
+ { x: 2, y: 0 },
+ { x: 3, y: 1 }
+ ]);
+ });
});
diff --git a/src/components/__tests__/FileConfigModal.test.jsx b/src/components/__tests__/FileConfigModal.test.jsx
new file mode 100644
index 0000000..3967052
--- /dev/null
+++ b/src/components/__tests__/FileConfigModal.test.jsx
@@ -0,0 +1,26 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import { describe, it, expect } from 'vitest';
+import '@testing-library/jest-dom/vitest';
+import { FileConfigModal } from '../FileConfigModal.jsx';
+
+describe('FileConfigModal', () => {
+ it('displays step range for log file', () => {
+ const file = {
+ id: '1',
+ name: 'a.log',
+ content: 'step: 70 loss: 1\nstep: 210 loss: 2'
+ };
+ render(
+
{}}
+ onSave={() => {}}
+ globalParsingConfig={{ metrics: [] }}
+ stepKeyword="step:"
+ />
+ );
+ expect(screen.getByText('当前日志包含步骤: 70 - 210')).toBeInTheDocument();
+ });
+});
From 57851e556ef4d3f521e8896922cd666f83329bb4 Mon Sep 17 00:00:00 2001
From: JavaZero <71128095+JavaZeroo@users.noreply.github.com>
Date: Thu, 14 Aug 2025 18:49:15 +0800
Subject: [PATCH 3/6] fix: sync hover using step values
---
src/components/ChartContainer.jsx | 24 ++++++++++++-------
.../__tests__/ChartContainer.test.jsx | 11 ++++++++-
2 files changed, 26 insertions(+), 9 deletions(-)
diff --git a/src/components/ChartContainer.jsx b/src/components/ChartContainer.jsx
index 0b61d59..85b23d6 100644
--- a/src/components/ChartContainer.jsx
+++ b/src/components/ChartContainer.jsx
@@ -53,6 +53,17 @@ export const getComparisonData = (data1, data2, mode) => {
});
};
+export const getActiveElementsAtStep = (datasets, step) => {
+ const activeElements = [];
+ datasets.forEach((dataset, datasetIndex) => {
+ const index = dataset.data.findIndex(p => p.x === step);
+ if (index !== -1) {
+ activeElements.push({ datasetIndex, index });
+ }
+ });
+ return activeElements;
+};
+
const ChartWrapper = ({ data, options, chartId, onRegisterChart, onSyncHover }) => {
const chartRef = useRef(null);
@@ -66,8 +77,10 @@ const ChartWrapper = ({ data, options, chartId, onRegisterChart, onSyncHover })
const enhancedOptions = {
...options,
onHover: (event, activeElements) => {
- if (activeElements.length > 0) {
- const step = activeElements[0].index;
+ if (activeElements.length > 0 && chartRef.current) {
+ const { datasetIndex, index } = activeElements[0];
+ const point = chartRef.current.data?.datasets?.[datasetIndex]?.data?.[index];
+ const step = typeof point?.x === 'number' ? point.x : index;
onSyncHover(step, chartId);
} else {
onSyncHover(null, chartId);
@@ -112,12 +125,7 @@ export default function ChartContainer({
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 });
- }
- });
+ const activeElements = getActiveElementsAtStep(chart.data.datasets, step);
chart.setActiveElements(activeElements);
chart.tooltip.setActiveElements(activeElements, { x: 0, y: 0 });
chart.update('none');
diff --git a/src/components/__tests__/ChartContainer.test.jsx b/src/components/__tests__/ChartContainer.test.jsx
index afeca2e..5358093 100644
--- a/src/components/__tests__/ChartContainer.test.jsx
+++ b/src/components/__tests__/ChartContainer.test.jsx
@@ -26,7 +26,7 @@ vi.mock('chart.js', () => {
vi.mock('chartjs-plugin-zoom', () => ({ default: {} }));
-import ChartContainer, { getComparisonData } from '../ChartContainer.jsx';
+import ChartContainer, { getComparisonData, getActiveElementsAtStep } from '../ChartContainer.jsx';
const sampleFile = {
name: 'test.log',
@@ -94,4 +94,13 @@ describe('ChartContainer', () => {
{ x: 3, y: 1 }
]);
});
+
+ it('finds active elements by step value', () => {
+ const datasets = [
+ { data: [{ x: 2, y: 1 }, { x: 4, y: 2 }] },
+ { data: [{ x: 1, y: 3 }, { x: 2, y: 4 }, { x: 5, y: 6 }] }
+ ];
+ const result = getActiveElementsAtStep(datasets, 2);
+ expect(result).toEqual([{ datasetIndex: 0, index: 0 }, { datasetIndex: 1, index: 1 }]);
+ });
});
From 9c16549580491a7208e37b26d14591eb8a8a0c0a Mon Sep 17 00:00:00 2001
From: JavaZero <71128095+JavaZeroo@users.noreply.github.com>
Date: Thu, 14 Aug 2025 19:31:53 +0800
Subject: [PATCH 4/6] fix: expand single-step overlap range
---
src/components/ChartContainer.jsx | 8 +++++++-
src/components/__tests__/ChartContainer.test.jsx | 14 ++++++++++++++
2 files changed, 21 insertions(+), 1 deletion(-)
diff --git a/src/components/ChartContainer.jsx b/src/components/ChartContainer.jsx
index 85b23d6..97f7516 100644
--- a/src/components/ChartContainer.jsx
+++ b/src/components/ChartContainer.jsx
@@ -264,8 +264,14 @@ export default function ChartContainer({
const start = Math.max(...ranges.map(r => r.start));
const end = Math.min(...ranges.map(r => r.end));
if (end >= start) {
+ let min = start;
+ let max = end;
+ if (min === max) {
+ min -= 1;
+ max += 1;
+ }
onXRangeChange(prev => {
- const next = { min: start, max: end };
+ const next = { min, max };
if (prev.min === next.min && prev.max === next.max) return prev;
return next;
});
diff --git a/src/components/__tests__/ChartContainer.test.jsx b/src/components/__tests__/ChartContainer.test.jsx
index 5358093..5f709de 100644
--- a/src/components/__tests__/ChartContainer.test.jsx
+++ b/src/components/__tests__/ChartContainer.test.jsx
@@ -85,6 +85,20 @@ describe('ChartContainer', () => {
expect(cb({})).toEqual({ min: 2, max: 4 });
});
+ it('expands range when only a single overlapping step exists', async () => {
+ const files = [
+ { name: 'a.log', id: 'a', content: 'step:1 loss:1\nstep:2 loss:2\nstep:3 loss:3' },
+ { name: 'b.log', id: 'b', content: 'step:2 loss:4\nstep:3 loss:5' },
+ { name: 'c.log', id: 'c', content: 'step:3 loss:6\nstep:4 loss:7' }
+ ];
+ const { onXRangeChange } = renderComponent({ files, metrics: [metric], useStepKeyword: true, stepKeyword: 'step:' });
+ await waitFor(() => {
+ expect(onXRangeChange).toHaveBeenCalled();
+ });
+ const cb = onXRangeChange.mock.calls.at(-1)[0];
+ expect(cb({})).toEqual({ min: 2, max: 4 });
+ });
+
it('computes comparison only on overlapping steps', () => {
const d1 = [{ x: 1, y: 1 }, { x: 2, y: 2 }, { x: 3, y: 3 }];
const d2 = [{ x: 2, y: 2 }, { x: 3, y: 4 }, { x: 4, y: 5 }];
From 8ed8fd655bc44b46d977b9b4d200afd73a5d6e58 Mon Sep 17 00:00:00 2001
From: JavaZero <71128095+JavaZeroo@users.noreply.github.com>
Date: Sat, 16 Aug 2025 10:17:40 +0800
Subject: [PATCH 5/6] fix: sync hover by exact step
---
src/components/ChartContainer.jsx | 27 ++++++++++---------
.../__tests__/ChartContainer.test.jsx | 9 +++++++
2 files changed, 24 insertions(+), 12 deletions(-)
diff --git a/src/components/ChartContainer.jsx b/src/components/ChartContainer.jsx
index 97f7516..946f377 100644
--- a/src/components/ChartContainer.jsx
+++ b/src/components/ChartContainer.jsx
@@ -76,22 +76,25 @@ const ChartWrapper = ({ data, options, chartId, onRegisterChart, onSyncHover })
const enhancedOptions = {
...options,
- onHover: (event, activeElements) => {
- if (activeElements.length > 0 && chartRef.current) {
- const { datasetIndex, index } = activeElements[0];
- const point = chartRef.current.data?.datasets?.[datasetIndex]?.data?.[index];
- const step = typeof point?.x === 'number' ? point.x : index;
- onSyncHover(step, chartId);
+ onHover: (event) => {
+ if (!chartRef.current) return;
+ const chart = chartRef.current;
+ const x = event.x;
+ const withinChart =
+ x >= chart.chartArea.left && x <= chart.chartArea.right;
+ if (withinChart) {
+ const step = Math.round(chart.scales.x.getValueForPixel(x));
+ onSyncHover(step);
} else {
- onSyncHover(null, chartId);
+ onSyncHover(null);
}
},
events: ['mousemove', 'mouseout', 'click', 'touchstart', 'touchmove'],
};
const handleContainerMouseLeave = useCallback(() => {
- onSyncHover(null, chartId);
- }, [onSyncHover, chartId]);
+ onSyncHover(null);
+ }, [onSyncHover]);
return (
@@ -117,14 +120,14 @@ export default function ChartContainer({
chartRefs.current.set(id, inst);
}, []);
- const syncHoverToAllCharts = useCallback((step, sourceId) => {
- chartRefs.current.forEach((chart, id) => {
+ const syncHoverToAllCharts = useCallback((step) => {
+ chartRefs.current.forEach((chart) => {
if (!chart) return;
if (step === null) {
chart.setActiveElements([]);
chart.tooltip.setActiveElements([]);
chart.update('none');
- } else if (id !== sourceId) {
+ } else {
const activeElements = getActiveElementsAtStep(chart.data.datasets, step);
chart.setActiveElements(activeElements);
chart.tooltip.setActiveElements(activeElements, { x: 0, y: 0 });
diff --git a/src/components/__tests__/ChartContainer.test.jsx b/src/components/__tests__/ChartContainer.test.jsx
index 5f709de..077ada2 100644
--- a/src/components/__tests__/ChartContainer.test.jsx
+++ b/src/components/__tests__/ChartContainer.test.jsx
@@ -117,4 +117,13 @@ describe('ChartContainer', () => {
const result = getActiveElementsAtStep(datasets, 2);
expect(result).toEqual([{ datasetIndex: 0, index: 0 }, { datasetIndex: 1, index: 1 }]);
});
+
+ it('returns empty array when no dataset contains the step', () => {
+ const datasets = [
+ { data: [{ x: 210, y: 1 }, { x: 220, y: 2 }] },
+ { data: [{ x: 5, y: 3 }, { x: 6, y: 4 }] }
+ ];
+ const result = getActiveElementsAtStep(datasets, 0);
+ expect(result).toEqual([]);
+ });
});
From 8ebfbceb42150dbc9acf1103c00f3e351d943225 Mon Sep 17 00:00:00 2001
From: JavaZero <71128095+JavaZeroo@users.noreply.github.com>
Date: Mon, 18 Aug 2025 09:22:17 +0800
Subject: [PATCH 6/6] fix: clear hover for non-existent steps
---
src/components/ChartContainer.jsx | 35 ++++++++++++-------
.../__tests__/ChartContainer.test.jsx | 31 +++++++++++++++-
2 files changed, 52 insertions(+), 14 deletions(-)
diff --git a/src/components/ChartContainer.jsx b/src/components/ChartContainer.jsx
index 946f377..2ead347 100644
--- a/src/components/ChartContainer.jsx
+++ b/src/components/ChartContainer.jsx
@@ -103,6 +103,27 @@ const ChartWrapper = ({ data, options, chartId, onRegisterChart, onSyncHover })
);
};
+export const syncHoverToCharts = (charts, step) => {
+ charts.forEach((chart) => {
+ if (!chart) return;
+ if (step === null) {
+ chart.setActiveElements([]);
+ chart.tooltip.setActiveElements([]);
+ chart.update('none');
+ } else {
+ const activeElements = getActiveElementsAtStep(chart.data.datasets, step);
+ chart.setActiveElements(activeElements);
+ if (activeElements.length > 0) {
+ const xPixel = chart.scales.x.getPixelForValue(step);
+ chart.tooltip.setActiveElements(activeElements, { x: xPixel, y: 0 });
+ } else {
+ chart.tooltip.setActiveElements([]);
+ }
+ chart.update('none');
+ }
+ });
+};
+
export default function ChartContainer({
files,
metrics = [],
@@ -121,19 +142,7 @@ export default function ChartContainer({
}, []);
const syncHoverToAllCharts = useCallback((step) => {
- chartRefs.current.forEach((chart) => {
- if (!chart) return;
- if (step === null) {
- chart.setActiveElements([]);
- chart.tooltip.setActiveElements([]);
- chart.update('none');
- } else {
- const activeElements = getActiveElementsAtStep(chart.data.datasets, step);
- chart.setActiveElements(activeElements);
- chart.tooltip.setActiveElements(activeElements, { x: 0, y: 0 });
- chart.update('none');
- }
- });
+ syncHoverToCharts(chartRefs.current, step);
}, []);
const parsedData = useMemo(() => {
diff --git a/src/components/__tests__/ChartContainer.test.jsx b/src/components/__tests__/ChartContainer.test.jsx
index 077ada2..16fe0e0 100644
--- a/src/components/__tests__/ChartContainer.test.jsx
+++ b/src/components/__tests__/ChartContainer.test.jsx
@@ -26,7 +26,7 @@ vi.mock('chart.js', () => {
vi.mock('chartjs-plugin-zoom', () => ({ default: {} }));
-import ChartContainer, { getComparisonData, getActiveElementsAtStep } from '../ChartContainer.jsx';
+import ChartContainer, { getComparisonData, getActiveElementsAtStep, syncHoverToCharts } from '../ChartContainer.jsx';
const sampleFile = {
name: 'test.log',
@@ -126,4 +126,33 @@ describe('ChartContainer', () => {
const result = getActiveElementsAtStep(datasets, 0);
expect(result).toEqual([]);
});
+
+ it('clears highlights when step is absent', () => {
+ const chart = {
+ setActiveElements: vi.fn(),
+ tooltip: { setActiveElements: vi.fn() },
+ update: vi.fn(),
+ data: { datasets: [{ data: [{ x: 210, y: 1 }] }] }
+ };
+ const charts = new Map([["a", chart]]);
+ syncHoverToCharts(charts, 0);
+ expect(chart.setActiveElements).toHaveBeenCalledWith([]);
+ expect(chart.tooltip.setActiveElements).toHaveBeenCalledWith([]);
+ expect(chart.update).toHaveBeenCalledWith('none');
+ });
+
+ it('positions tooltip at matching step', () => {
+ const chart = {
+ setActiveElements: vi.fn(),
+ tooltip: { setActiveElements: vi.fn() },
+ update: vi.fn(),
+ data: { datasets: [{ data: [{ x: 210, y: 1 }] }] },
+ scales: { x: { getPixelForValue: vi.fn().mockReturnValue(123) } }
+ };
+ const charts = new Map([["a", chart]]);
+ syncHoverToCharts(charts, 210);
+ expect(chart.setActiveElements).toHaveBeenCalledWith([{ datasetIndex: 0, index: 0 }]);
+ expect(chart.tooltip.setActiveElements).toHaveBeenCalledWith([{ datasetIndex: 0, index: 0 }], { x: 123, y: 0 });
+ expect(chart.update).toHaveBeenCalledWith('none');
+ });
});