From a151e73f4bc7705edbbdb32ae59918a44cc5662d Mon Sep 17 00:00:00 2001 From: JavaZero <71128095+JavaZeroo@users.noreply.github.com> Date: Tue, 19 Aug 2025 19:42:00 +0800 Subject: [PATCH 1/4] feat: support step keyword for x-axis --- src/App.jsx | 12 +- src/components/ChartContainer.jsx | 159 +++++++++++------- src/components/FileConfigModal.jsx | 43 +++-- src/components/RegexControls.jsx | 79 ++++++--- .../__tests__/ChartContainer.test.jsx | 6 +- src/utils/__tests__/getMinSteps.test.js | 17 +- src/utils/getMinSteps.js | 5 +- 7 files changed, 208 insertions(+), 113 deletions(-) diff --git a/src/App.jsx b/src/App.jsx index fce2a15..950e072 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -27,7 +27,9 @@ function App() { keyword: 'norm:', regex: 'grad[\\s_]norm:\\s*([\\d.eE+-]+)' } - ] + ], + useStepKeyword: false, + stepKeyword: 'step:' }); const [compareMode, setCompareMode] = useState('normal'); @@ -52,7 +54,9 @@ function App() { start: 0, // 默认从第一个数据点开始 end: undefined, // 默认到最后一个数据点 useRange: false // 保留这个字段用于向后兼容,但默认不启用 - } + }, + useStepKeyword: globalParsingConfig.useStepKeyword, + stepKeyword: globalParsingConfig.stepKeyword } })); setUploadedFiles(prev => mergeFilesWithReplacement(prev, filesWithDefaults)); @@ -124,7 +128,9 @@ function App() { ...file, config: { ...file.config, - metrics: newConfig.metrics.map(m => ({ ...m })) + metrics: newConfig.metrics.map(m => ({ ...m })), + useStepKeyword: newConfig.useStepKeyword, + stepKeyword: newConfig.stepKeyword } }))); }, []); diff --git a/src/components/ChartContainer.jsx b/src/components/ChartContainer.jsx index b10f0f5..2f4e2bc 100644 --- a/src/components/ChartContainer.jsx +++ b/src/components/ChartContainer.jsx @@ -38,14 +38,17 @@ const ChartWrapper = ({ data, options, chartId, onRegisterChart, onSyncHover }) const enhancedOptions = { ...options, - onHover: (event, activeElements) => { - if (activeElements.length > 0) { - const step = activeElements[0].index; - onSyncHover(step, chartId); - } else { - onSyncHover(null, chartId); - } - }, + onHover: (event, activeElements) => { + if (activeElements.length > 0 && chartRef.current) { + const { datasetIndex, index } = activeElements[0]; + const dataset = chartRef.current.data.datasets[datasetIndex]; + const point = dataset.data[index]; + const step = point.x; + onSyncHover(step, chartId); + } else { + onSyncHover(null, chartId); + } + }, events: ['mousemove', 'mouseout', 'click', 'touchstart', 'touchmove'], }; @@ -85,8 +88,9 @@ export default function ChartContainer({ } 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 idx = dataset.data.findIndex(p => p.x === step); + if (idx !== -1) { + activeElements.push({ datasetIndex, index: idx }); } }); chart.setActiveElements(activeElements); @@ -96,47 +100,72 @@ export default function ChartContainer({ }); }, []); - const parsedData = useMemo(() => { - 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 metricsData = {}; + const parsedData = useMemo(() => { + 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 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()); + const stepCfg = { + enabled: file.config?.useStepKeyword, + keyword: file.config?.stepKeyword || 'step:' + }; + + const extractStep = (line) => { + if (!stepCfg.enabled) return null; + const idx = line.toLowerCase().indexOf(stepCfg.keyword.toLowerCase()); if (idx !== -1) { - const after = line.substring(idx + keyword.length); - const match = after.match(numberRegex); + const after = line.substring(idx + stepCfg.keyword.length); + const match = after.match(/[+-]?\d+/); if (match) { - const v = parseFloat(match[0]); - if (!isNaN(v)) results.push(v); + const s = parseInt(match[0], 10); + if (!isNaN(s)) return s; } } - }); - return results; - }; + return null; + }; - 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); + const extractByKeyword = (linesArr, keyword) => { + const results = []; + const numberRegex = /[+-]?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/; + linesArr.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)) { + const step = extractStep(line); + results.push({ x: step !== null ? step : results.length, y: v }); + } + } } }); - } - metricsData[metric.name || metric.keyword] = values.map((v, i) => ({ x: i, y: v })); - }); + return results; + }; + + metrics.forEach(metric => { + let points = []; + if (metric.mode === 'keyword') { + points = extractByKeyword(lines, 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)) { + const step = extractStep(line); + points.push({ x: step !== null ? step : points.length, y: v }); + } + } + }); + } + metricsData[metric.name || metric.keyword] = points; + }); const range = file.config?.dataRange; if (range && (range.start > 0 || range.end !== undefined)) { @@ -147,7 +176,7 @@ export default function ChartContainer({ const endIndex = Math.min(data.length, end); return data.slice(start, endIndex); }; - const reindex = data => data.map((p, idx) => ({ x: idx, y: p.y })); + const reindex = data => stepCfg.enabled ? data : data.map((p, idx) => ({ x: idx, y: p.y })); Object.keys(metricsData).forEach(k => { metricsData[k] = reindex(applyRange(metricsData[k])); }); @@ -203,29 +232,31 @@ export default function ChartContainer({ }); const getComparisonData = (data1, data2, mode) => { - const minLength = Math.min(data1.length, data2.length); + const map2 = new Map(data2.map(p => [p.x, p.y])); 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; + data1.forEach(p1 => { + if (map2.has(p1.x)) { + const v1 = p1.y; + const v2 = map2.get(p1.x); + 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; } - default: - diff = v2 - v1; + result.push({ x: p1.x, y: diff }); } - result.push({ x: i, y: diff }); - } + }); return result; }; diff --git a/src/components/FileConfigModal.jsx b/src/components/FileConfigModal.jsx index 6428b20..3316e94 100644 --- a/src/components/FileConfigModal.jsx +++ b/src/components/FileConfigModal.jsx @@ -35,27 +35,34 @@ function getMetricTitle(metric, index) { } export function FileConfigModal({ file, isOpen, onClose, onSave, globalParsingConfig }) { - const [config, setConfig] = useState({ - metrics: [], - dataRange: { - start: 0, // 起始位置,默认为0(第一个数据点) - end: undefined, // 结束位置,默认为undefined(最后一个数据点) - useRange: false // 保留用于向后兼容 - } - }); + const [config, setConfig] = useState({ + metrics: [], + dataRange: { + start: 0, // 起始位置,默认为0(第一个数据点) + end: undefined, // 结束位置,默认为undefined(最后一个数据点) + useRange: false // 保留用于向后兼容 + }, + useStepKeyword: false, + stepKeyword: 'step:' + }); useEffect(() => { if (file && isOpen) { // 如果文件有配置,使用文件配置,否则使用全局配置 const fileConfig = file.config || {}; - setConfig({ - metrics: fileConfig.metrics || globalParsingConfig.metrics, - dataRange: fileConfig.dataRange || { - start: 0, - end: undefined, - useRange: false - } - }); + setConfig({ + metrics: fileConfig.metrics || globalParsingConfig.metrics, + dataRange: fileConfig.dataRange || { + start: 0, + end: undefined, + useRange: false + }, + useStepKeyword: + fileConfig.useStepKeyword !== undefined + ? fileConfig.useStepKeyword + : globalParsingConfig.useStepKeyword, + stepKeyword: fileConfig.stepKeyword || globalParsingConfig.stepKeyword || 'step:' + }); } }, [file, isOpen, globalParsingConfig]); @@ -98,7 +105,9 @@ export function FileConfigModal({ file, isOpen, onClose, onSave, globalParsingCo const syncFromGlobal = () => { setConfig(prev => ({ ...prev, - metrics: globalParsingConfig.metrics.map(m => ({ ...m })) + metrics: globalParsingConfig.metrics.map(m => ({ ...m })), + useStepKeyword: globalParsingConfig.useStepKeyword, + stepKeyword: globalParsingConfig.stepKeyword })); }; diff --git a/src/components/RegexControls.jsx b/src/components/RegexControls.jsx index 971cce9..46b0195 100644 --- a/src/components/RegexControls.jsx +++ b/src/components/RegexControls.jsx @@ -207,6 +207,20 @@ export function RegexControls({ const [showPreview, setShowPreview] = useState(false); const [previewResults, setPreviewResults] = useState({}); + const handleStepToggle = useCallback((checked) => { + onGlobalParsingConfigChange({ + ...globalParsingConfig, + useStepKeyword: checked + }); + }, [globalParsingConfig, onGlobalParsingConfigChange]); + + const handleStepKeywordChange = useCallback((value) => { + onGlobalParsingConfigChange({ + ...globalParsingConfig, + stepKeyword: value + }); + }, [globalParsingConfig, onGlobalParsingConfigChange]); + // 提取数值的通用函数 const extractValues = useCallback((content, mode, config) => { switch (mode) { @@ -449,28 +463,51 @@ export function RegexControls({
- {globalParsingConfig.metrics.map((cfg, idx) => ( -
- -

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

- {renderConfigPanel(`metric-${idx}`, cfg, (field, value) => handleMetricChange(idx, field, value), idx)} + {globalParsingConfig.metrics.map((cfg, idx) => ( +
+ +

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

+ {renderConfigPanel(`metric-${idx}`, cfg, (field, value) => handleMetricChange(idx, field, value), idx)} +
+ ))} + + +
+
+ + {globalParsingConfig.useStepKeyword && ( + handleStepKeywordChange(e.target.value)} + placeholder="step:" + /> + )} +
- ))} -
diff --git a/src/components/__tests__/ChartContainer.test.jsx b/src/components/__tests__/ChartContainer.test.jsx index 62a9315..7737000 100644 --- a/src/components/__tests__/ChartContainer.test.jsx +++ b/src/components/__tests__/ChartContainer.test.jsx @@ -102,9 +102,9 @@ describe('ChartContainer', () => { expect(onMaxStepChange).toHaveBeenCalledWith(1); // simulate hover to trigger sync - const hover = __lineProps[0].options.onHover; - hover({}, [{ index: 0 }]); - expect(__charts[1].setActiveElements).toHaveBeenCalled(); + const hover = __lineProps[0].options.onHover; + hover({}, [{ index: 0, datasetIndex: 0 }]); + expect(__charts[1].setActiveElements).toHaveBeenCalled(); }); it('parses metrics, applies range and triggers callbacks', () => { diff --git a/src/utils/__tests__/getMinSteps.test.js b/src/utils/__tests__/getMinSteps.test.js index 79c4ab8..7fb80f0 100644 --- a/src/utils/__tests__/getMinSteps.test.js +++ b/src/utils/__tests__/getMinSteps.test.js @@ -4,16 +4,25 @@ import { getMinSteps } from '../getMinSteps.js'; describe('getMinSteps', () => { it('returns minimum length among enabled files', () => { const parsed = [ - { enabled: true, metricsData: { a: [{},{},{}], b: [{},{}] } }, - { enabled: true, metricsData: { a: [{},{},{} ,{}], b: [{},{} ,{}] } }, + { + enabled: true, + metricsData: { a: [{ x: 0 }, { x: 1 }, { x: 2 }], b: [{ x: 0 }, { x: 1 }] } + }, + { + enabled: true, + metricsData: { + a: [{ x: 0 }, { x: 1 }, { x: 2 }, { x: 3 }], + b: [{ x: 0 }, { x: 1 }, { x: 2 }] + } + }, ]; expect(getMinSteps(parsed)).toBe(3); }); it('ignores disabled files', () => { const parsed = [ - { enabled: false, metricsData: { a: [{},{},{}] } }, - { enabled: true, metricsData: { a: [{},{}] } } + { enabled: false, metricsData: { a: [{ x: 0 }, { x: 1 }, { x: 2 }] } }, + { enabled: true, metricsData: { a: [{ x: 0 }, { x: 1 }] } } ]; expect(getMinSteps(parsed)).toBe(2); }); diff --git a/src/utils/getMinSteps.js b/src/utils/getMinSteps.js index 9ae22f2..1c91fea 100644 --- a/src/utils/getMinSteps.js +++ b/src/utils/getMinSteps.js @@ -4,7 +4,10 @@ export function getMinSteps(parsedFiles = []) { const lengths = enabled.map(file => { const datasets = Object.values(file.metricsData || {}); if (datasets.length === 0) return 0; - return datasets.reduce((m, d) => Math.max(m, d.length), 0); + return datasets.reduce( + (m, d) => Math.max(m, d.length > 0 ? d[d.length - 1].x + 1 : 0), + 0 + ); }); return Math.min(...lengths); } From c104dbe5d065a84e4226b119dacda08d82ecfc8a Mon Sep 17 00:00:00 2001 From: JavaZero <71128095+JavaZeroo@users.noreply.github.com> Date: Tue, 19 Aug 2025 19:58:58 +0800 Subject: [PATCH 2/4] fix: align tooltips by x coordinate --- src/components/ChartContainer.jsx | 4 ++-- src/components/__tests__/ChartContainer.test.jsx | 11 ++++++++--- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/components/ChartContainer.jsx b/src/components/ChartContainer.jsx index 2f4e2bc..7a8ca58 100644 --- a/src/components/ChartContainer.jsx +++ b/src/components/ChartContainer.jsx @@ -291,7 +291,7 @@ export default function ChartContainer({ animations: { colors: false, x: false, y: false }, hover: { animationDuration: 0 }, responsiveAnimationDuration: 0, - interaction: { mode: 'index', intersect: false }, + interaction: { mode: 'x', intersect: false }, plugins: { zoom: { pan: { @@ -340,7 +340,7 @@ export default function ChartContainer({ } }, tooltip: { - mode: 'index', + mode: 'x', intersect: false, animation: false, backgroundColor: 'rgba(15, 23, 42, 0.92)', diff --git a/src/components/__tests__/ChartContainer.test.jsx b/src/components/__tests__/ChartContainer.test.jsx index 7737000..3f2a7c8 100644 --- a/src/components/__tests__/ChartContainer.test.jsx +++ b/src/components/__tests__/ChartContainer.test.jsx @@ -101,10 +101,15 @@ describe('ChartContainer', () => { screen.getByText(/差值统计/); expect(onMaxStepChange).toHaveBeenCalledWith(1); + // interaction and tooltip should use x mode to avoid index-based coupling + const opts = __lineProps[0].options; + expect(opts.interaction.mode).toBe('x'); + expect(opts.plugins.tooltip.mode).toBe('x'); + // simulate hover to trigger sync - const hover = __lineProps[0].options.onHover; - hover({}, [{ index: 0, datasetIndex: 0 }]); - expect(__charts[1].setActiveElements).toHaveBeenCalled(); + const hover = __lineProps[0].options.onHover; + hover({}, [{ index: 0, datasetIndex: 0 }]); + expect(__charts[1].setActiveElements).toHaveBeenCalled(); }); it('parses metrics, applies range and triggers callbacks', () => { From 4ea1cc42a46f920745394d1ac2b618985708fe5e Mon Sep 17 00:00:00 2001 From: JavaZeroo <2487163254@qq.com> Date: Wed, 20 Aug 2025 16:41:02 +0800 Subject: [PATCH 3/4] feat: enhance chart synchronization and hover functionality --- src/components/ChartContainer.jsx | 160 +++++++++++++----- .../__tests__/ChartContainer.test.jsx | 4 +- 2 files changed, 117 insertions(+), 47 deletions(-) diff --git a/src/components/ChartContainer.jsx b/src/components/ChartContainer.jsx index 7a8ca58..bf8c045 100644 --- a/src/components/ChartContainer.jsx +++ b/src/components/ChartContainer.jsx @@ -26,7 +26,7 @@ ChartJS.register( zoomPlugin ); -const ChartWrapper = ({ data, options, chartId, onRegisterChart, onSyncHover }) => { +const ChartWrapper = ({ data, options, chartId, onRegisterChart, onSyncHover, syncRef }) => { const chartRef = useRef(null); const handleChartRef = useCallback((ref) => { @@ -38,17 +38,38 @@ const ChartWrapper = ({ data, options, chartId, onRegisterChart, onSyncHover }) const enhancedOptions = { ...options, - onHover: (event, activeElements) => { - if (activeElements.length > 0 && chartRef.current) { - const { datasetIndex, index } = activeElements[0]; + onHover: (event, activeElements) => { + if (syncRef?.current) return; + if (activeElements.length > 0 && chartRef.current) { + // 找到距离鼠标最近的数据点 + let closestElement = activeElements[0]; + let minDistance = Infinity; + + const canvasRect = chartRef.current.canvas.getBoundingClientRect(); + const mouseX = event.native ? event.native.clientX - canvasRect.left : event.x; + + activeElements.forEach(element => { + const { datasetIndex, index } = element; const dataset = chartRef.current.data.datasets[datasetIndex]; const point = dataset.data[index]; - const step = point.x; - onSyncHover(step, chartId); - } else { - onSyncHover(null, chartId); - } - }, + const pixelX = chartRef.current.scales.x.getPixelForValue(point.x); + const distance = Math.abs(mouseX - pixelX); + + if (distance < minDistance) { + minDistance = distance; + closestElement = element; + } + }); + + const { datasetIndex, index } = closestElement; + const dataset = chartRef.current.data.datasets[datasetIndex]; + const point = dataset.data[index]; + const step = point.x; + onSyncHover(step, chartId); + } else { + onSyncHover(null, chartId); + } + }, events: ['mousemove', 'mouseout', 'click', 'touchstart', 'touchmove'], }; @@ -74,30 +95,62 @@ export default function ChartContainer({ onMaxStepChange }) { const chartRefs = useRef(new Map()); + const syncLockRef = useRef(false); const registerChart = useCallback((id, inst) => { chartRefs.current.set(id, inst); }, []); const syncHoverToAllCharts = useCallback((step, sourceId) => { + if (syncLockRef.current) return; + syncLockRef.current = true; chartRefs.current.forEach((chart, id) => { - if (!chart) return; + if (!chart || !chart.data || !chart.data.datasets) return; if (step === null) { chart.setActiveElements([]); - chart.tooltip.setActiveElements([]); - chart.update('none'); + chart.tooltip.setActiveElements([], { x: 0, y: 0 }); + chart.draw(); } else if (id !== sourceId) { const activeElements = []; + const seen = new Set(); // 防止重复添加相同的数据点 chart.data.datasets.forEach((dataset, datasetIndex) => { - const idx = dataset.data.findIndex(p => p.x === step); - if (idx !== -1) { - activeElements.push({ datasetIndex, index: idx }); + if (!dataset || !dataset.data || !Array.isArray(dataset.data)) return; + const idx = dataset.data.findIndex(p => p && typeof p.x !== 'undefined' && p.x === step); + if (idx !== -1 && dataset.data[idx]) { + const elementKey = `${datasetIndex}-${idx}`; + if (!seen.has(elementKey)) { + // 验证元素的有效性 + if (datasetIndex >= 0 && datasetIndex < chart.data.datasets.length && + idx >= 0 && idx < dataset.data.length) { + activeElements.push({ datasetIndex, index: idx }); + seen.add(elementKey); + } + } } }); - chart.setActiveElements(activeElements); - chart.tooltip.setActiveElements(activeElements, { x: 0, y: 0 }); - chart.update('none'); + + // 只有当activeElements不为空且所有元素都有效时才设置 + if (activeElements.length > 0) { + try { + const pos = { x: chart.scales.x.getPixelForValue(step), y: 0 }; + chart.setActiveElements(activeElements); + chart.tooltip.setActiveElements(activeElements, pos); + chart.draw(); + } catch (error) { + console.warn('Error setting active elements:', error); + // 如果出错,清除所有activeElements + chart.setActiveElements([]); + chart.tooltip.setActiveElements([], { x: 0, y: 0 }); + chart.draw(); + } + } else { + // 如果没有找到有效的activeElements,清除当前的 + chart.setActiveElements([]); + chart.tooltip.setActiveElements([], { x: 0, y: 0 }); + chart.draw(); + } } }); + syncLockRef.current = false; }, []); const parsedData = useMemo(() => { @@ -206,30 +259,41 @@ export default function ChartContainer({ }, [parsedData, onXRangeChange]); 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 createChartData = dataArray => { + // 确保没有重复的 datasets + const uniqueItems = dataArray.reduce((acc, item) => { + const exists = acc.find(existing => existing.name === item.name); + if (!exists) { + acc.push(item); + } + return acc; + }, []); + + return { + datasets: uniqueItems.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 map2 = new Map(data2.map(p => [p.x, p.y])); @@ -291,7 +355,7 @@ export default function ChartContainer({ animations: { colors: false, x: false, y: false }, hover: { animationDuration: 0 }, responsiveAnimationDuration: 0, - interaction: { mode: 'x', intersect: false }, + interaction: { mode: 'nearest', intersect: false, axis: 'x' }, plugins: { zoom: { pan: { @@ -340,8 +404,9 @@ export default function ChartContainer({ } }, tooltip: { - mode: 'x', + mode: 'nearest', intersect: false, + axis: 'x', animation: false, backgroundColor: 'rgba(15, 23, 42, 0.92)', titleColor: '#f1f5f9', @@ -364,7 +429,8 @@ export default function ChartContainer({ }, label: function (context) { const value = Number(context.parsed.y.toPrecision(4)); - return ` ${value}`; + const label = context.dataset?.label || 'Dataset'; + return ` ${label}: ${value}`; }, labelColor: function (context) { return { @@ -548,6 +614,7 @@ export default function ChartContainer({ chartId={`metric-comp-${idx}`} onRegisterChart={registerChart} onSyncHover={syncHoverToAllCharts} + syncRef={syncLockRef} data={compData} options={compOptions} /> @@ -562,6 +629,7 @@ export default function ChartContainer({ chartId={`metric-${idx}`} onRegisterChart={registerChart} onSyncHover={syncHoverToAllCharts} + syncRef={syncLockRef} data={createChartData(dataArray)} options={options} /> diff --git a/src/components/__tests__/ChartContainer.test.jsx b/src/components/__tests__/ChartContainer.test.jsx index 3f2a7c8..fb596c4 100644 --- a/src/components/__tests__/ChartContainer.test.jsx +++ b/src/components/__tests__/ChartContainer.test.jsx @@ -33,7 +33,8 @@ vi.mock('react-chartjs-2', async () => { data: props.data, setActiveElements: vi.fn(), tooltip: { setActiveElements: vi.fn() }, - update: vi.fn(), + draw: vi.fn(), + scales: { x: { getPixelForValue: vi.fn(() => 0) } }, }; charts.push(chart); if (typeof ref === 'function') ref(chart); @@ -110,6 +111,7 @@ describe('ChartContainer', () => { const hover = __lineProps[0].options.onHover; hover({}, [{ index: 0, datasetIndex: 0 }]); expect(__charts[1].setActiveElements).toHaveBeenCalled(); + expect(__charts[1].draw).toHaveBeenCalled(); }); it('parses metrics, applies range and triggers callbacks', () => { From 70fcbcf8d82f9c96768bce1a8681535f5aae16b3 Mon Sep 17 00:00:00 2001 From: JavaZeroo <2487163254@qq.com> Date: Wed, 20 Aug 2025 16:54:21 +0800 Subject: [PATCH 4/4] fix: improve tooltip and interaction modes for precise point selection --- src/components/ChartContainer.jsx | 31 ++++++++++--------- .../__tests__/ChartContainer.test.jsx | 8 +++-- 2 files changed, 22 insertions(+), 17 deletions(-) diff --git a/src/components/ChartContainer.jsx b/src/components/ChartContainer.jsx index bf8c045..a5f124a 100644 --- a/src/components/ChartContainer.jsx +++ b/src/components/ChartContainer.jsx @@ -45,21 +45,24 @@ const ChartWrapper = ({ data, options, chartId, onRegisterChart, onSyncHover, sy let closestElement = activeElements[0]; let minDistance = Infinity; - const canvasRect = chartRef.current.canvas.getBoundingClientRect(); - const mouseX = event.native ? event.native.clientX - canvasRect.left : event.x; - - activeElements.forEach(element => { - const { datasetIndex, index } = element; - const dataset = chartRef.current.data.datasets[datasetIndex]; - const point = dataset.data[index]; - const pixelX = chartRef.current.scales.x.getPixelForValue(point.x); - const distance = Math.abs(mouseX - pixelX); + // 检查canvas是否存在(在测试环境中可能不存在) + if (chartRef.current.canvas && chartRef.current.canvas.getBoundingClientRect) { + const canvasRect = chartRef.current.canvas.getBoundingClientRect(); + const mouseX = event.native ? event.native.clientX - canvasRect.left : event.x; - if (distance < minDistance) { - minDistance = distance; - closestElement = element; - } - }); + activeElements.forEach(element => { + const { datasetIndex, index } = element; + const dataset = chartRef.current.data.datasets[datasetIndex]; + const point = dataset.data[index]; + const pixelX = chartRef.current.scales.x.getPixelForValue(point.x); + const distance = Math.abs(mouseX - pixelX); + + if (distance < minDistance) { + minDistance = distance; + closestElement = element; + } + }); + } const { datasetIndex, index } = closestElement; const dataset = chartRef.current.data.datasets[datasetIndex]; diff --git a/src/components/__tests__/ChartContainer.test.jsx b/src/components/__tests__/ChartContainer.test.jsx index fb596c4..cb4d643 100644 --- a/src/components/__tests__/ChartContainer.test.jsx +++ b/src/components/__tests__/ChartContainer.test.jsx @@ -102,10 +102,12 @@ describe('ChartContainer', () => { screen.getByText(/差值统计/); expect(onMaxStepChange).toHaveBeenCalledWith(1); - // interaction and tooltip should use x mode to avoid index-based coupling + // interaction and tooltip should use nearest mode for precise point selection const opts = __lineProps[0].options; - expect(opts.interaction.mode).toBe('x'); - expect(opts.plugins.tooltip.mode).toBe('x'); + expect(opts.interaction.mode).toBe('nearest'); + expect(opts.interaction.axis).toBe('x'); + expect(opts.plugins.tooltip.mode).toBe('nearest'); + expect(opts.plugins.tooltip.axis).toBe('x'); // simulate hover to trigger sync const hover = __lineProps[0].options.onHover;