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() {

📊 图表显示

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

- +
+

Step 设置

+ + {useStepKeyword && ( + setStepKeyword(e.target.value)} + className="mt-2 w-full px-2 py-1 text-xs border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500 focus:outline-none" + placeholder="step:" + aria-label="step keyword" + /> + )} +
+

基准线设置

@@ -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'); + }); });