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