Skip to content

Commit 10456dc

Browse files
committed
feat: support step keyword alignment
1 parent 7d88e19 commit 10456dc

3 files changed

Lines changed: 167 additions & 43 deletions

File tree

src/App.jsx

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ function App() {
4040
const [xRange, setXRange] = useState({ min: undefined, max: undefined });
4141
const [maxStep, setMaxStep] = useState(0);
4242
const [sidebarVisible, setSidebarVisible] = useState(true);
43+
const [alignSteps, setAlignSteps] = useState(false);
44+
const [stepKeyword, setStepKeyword] = useState('step:');
4345

4446
const handleFilesUploaded = useCallback((files) => {
4547
const filesWithDefaults = files.map(file => ({
@@ -340,12 +342,44 @@ function App() {
340342
<h4 className="text-xs font-medium text-gray-700 mb-2">📊 图表显示</h4>
341343
<p className="text-xs text-gray-500">上传文件后自动展示所有已配置的指标图表</p>
342344
</div>
343-
345+
346+
<div className="border-t pt-3">
347+
<h4 className="text-xs font-medium text-gray-700 mb-2">步骤对齐</h4>
348+
<div className="space-y-2">
349+
<label className="flex items-center text-xs text-gray-700">
350+
<input
351+
type="checkbox"
352+
className="mr-2"
353+
checked={alignSteps}
354+
onChange={e => setAlignSteps(e.target.checked)}
355+
/>
356+
启用基于关键词的步骤对齐
357+
</label>
358+
{alignSteps && (
359+
<div>
360+
<label
361+
htmlFor="step-keyword"
362+
className="block text-xs font-medium text-gray-700 mb-1"
363+
>
364+
关键词
365+
</label>
366+
<input
367+
id="step-keyword"
368+
type="text"
369+
value={stepKeyword}
370+
onChange={e => setStepKeyword(e.target.value)}
371+
className="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"
372+
/>
373+
</div>
374+
)}
375+
</div>
376+
</div>
377+
344378
<div className="border-t pt-3">
345379
<h4 className="text-xs font-medium text-gray-700 mb-2">基准线设置</h4>
346380
<div className="space-y-3">
347381
<div>
348-
<label
382+
<label
349383
htmlFor="relative-baseline"
350384
className="block text-xs font-medium text-gray-700 mb-1"
351385
>
@@ -414,6 +448,8 @@ function App() {
414448
xRange={xRange}
415449
onXRangeChange={setXRange}
416450
onMaxStepChange={setMaxStep}
451+
alignSteps={alignSteps}
452+
stepKeyword={stepKeyword}
417453
/>
418454
</section>
419455
</main>

src/components/ChartContainer.jsx

Lines changed: 98 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,9 @@ export default function ChartContainer({
6868
absoluteBaseline = 0.005,
6969
xRange = { min: undefined, max: undefined },
7070
onXRangeChange,
71-
onMaxStepChange
71+
onMaxStepChange,
72+
alignSteps = false,
73+
stepKeyword = 'step:'
7274
}) {
7375
const chartRefs = useRef(new Map());
7476
const registerChart = useCallback((id, inst) => {
@@ -103,40 +105,76 @@ export default function ChartContainer({
103105
const lines = file.content.split('\n');
104106
const metricsData = {};
105107

106-
const extractByKeyword = (content, keyword) => {
107-
const results = [];
108-
const numberRegex = /[+-]?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/;
109-
content.split('\n').forEach(line => {
110-
const idx = line.toLowerCase().indexOf(keyword.toLowerCase());
111-
if (idx !== -1) {
112-
const after = line.substring(idx + keyword.length);
113-
const match = after.match(numberRegex);
114-
if (match) {
115-
const v = parseFloat(match[0]);
116-
if (!isNaN(v)) results.push(v);
108+
const numberRegex = /[+-]?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/;
109+
110+
if (alignSteps) {
111+
const metricKeys = metrics.map((m, idx) => m.name || m.keyword || `metric${idx + 1}`);
112+
metricKeys.forEach(k => { metricsData[k] = []; });
113+
114+
lines.forEach(line => {
115+
const stepIdx = line.toLowerCase().indexOf(stepKeyword.toLowerCase());
116+
if (stepIdx === -1) return;
117+
const afterStep = line.substring(stepIdx + stepKeyword.length);
118+
const stepMatch = afterStep.match(/\d+/);
119+
if (!stepMatch) return;
120+
const step = parseInt(stepMatch[0]);
121+
metrics.forEach((metric, mi) => {
122+
const key = metricKeys[mi];
123+
let value;
124+
if (metric.mode === 'keyword') {
125+
const idx = line.toLowerCase().indexOf(metric.keyword.toLowerCase());
126+
if (idx !== -1) {
127+
const after = line.substring(idx + metric.keyword.length);
128+
const match = after.match(numberRegex);
129+
if (match) value = parseFloat(match[0]);
130+
}
131+
} else if (metric.regex) {
132+
const reg = new RegExp(metric.regex);
133+
reg.lastIndex = 0;
134+
const m = reg.exec(line);
135+
if (m && m[1]) value = parseFloat(m[1]);
117136
}
118-
}
137+
if (value !== undefined && !isNaN(value)) {
138+
metricsData[key].push({ x: step, y: value });
139+
}
140+
});
119141
});
120-
return results;
121-
};
122-
123-
metrics.forEach(metric => {
124-
let values = [];
125-
if (metric.mode === 'keyword') {
126-
values = extractByKeyword(file.content, metric.keyword);
127-
} else if (metric.regex) {
128-
const reg = new RegExp(metric.regex);
129-
lines.forEach(line => {
130-
reg.lastIndex = 0;
131-
const m = reg.exec(line);
132-
if (m && m[1]) {
133-
const v = parseFloat(m[1]);
134-
if (!isNaN(v)) values.push(v);
142+
} else {
143+
const extractByKeyword = (content, keyword) => {
144+
const results = [];
145+
content.split('\n').forEach(line => {
146+
const idx = line.toLowerCase().indexOf(keyword.toLowerCase());
147+
if (idx !== -1) {
148+
const after = line.substring(idx + keyword.length);
149+
const match = after.match(numberRegex);
150+
if (match) {
151+
const v = parseFloat(match[0]);
152+
if (!isNaN(v)) results.push(v);
153+
}
135154
}
136155
});
137-
}
138-
metricsData[metric.name || metric.keyword] = values.map((v, i) => ({ x: i, y: v }));
139-
});
156+
return results;
157+
};
158+
159+
metrics.forEach((metric, idx) => {
160+
let values = [];
161+
if (metric.mode === 'keyword') {
162+
values = extractByKeyword(file.content, metric.keyword);
163+
} else if (metric.regex) {
164+
const reg = new RegExp(metric.regex);
165+
lines.forEach(line => {
166+
reg.lastIndex = 0;
167+
const m = reg.exec(line);
168+
if (m && m[1]) {
169+
const v = parseFloat(m[1]);
170+
if (!isNaN(v)) values.push(v);
171+
}
172+
});
173+
}
174+
const key = metric.name || metric.keyword || `metric${idx + 1}`;
175+
metricsData[key] = values.map((v, i) => ({ x: i, y: v }));
176+
});
177+
}
140178

141179
const range = file.config?.dataRange;
142180
if (range && (range.start > 0 || range.end !== undefined)) {
@@ -147,15 +185,15 @@ export default function ChartContainer({
147185
const endIndex = Math.min(data.length, end);
148186
return data.slice(start, endIndex);
149187
};
150-
const reindex = data => data.map((p, idx) => ({ x: idx, y: p.y }));
188+
const reindex = alignSteps ? (data => data) : (data => data.map((p, idx) => ({ x: idx, y: p.y })));
151189
Object.keys(metricsData).forEach(k => {
152190
metricsData[k] = reindex(applyRange(metricsData[k]));
153191
});
154192
}
155193

156194
return { ...file, metricsData };
157195
});
158-
}, [files, metrics]);
196+
}, [files, metrics, alignSteps, stepKeyword]);
159197

160198
useEffect(() => {
161199
const maxStep = parsedData.reduce((m, f) => {
@@ -166,15 +204,34 @@ export default function ChartContainer({
166204
}, [parsedData, onMaxStepChange]);
167205

168206
useEffect(() => {
169-
const minSteps = getMinSteps(parsedData);
170-
if (minSteps > 0) {
171-
onXRangeChange(prev => {
172-
const next = { min: 0, max: minSteps - 1 };
173-
if (prev.min === next.min && prev.max === next.max) return prev;
174-
return next;
175-
});
207+
if (alignSteps) {
208+
const enabled = parsedData.filter(f => f.enabled !== false);
209+
if (enabled.length > 0) {
210+
let minStart = Infinity;
211+
let maxEnd = 0;
212+
enabled.forEach(f => {
213+
Object.values(f.metricsData).forEach(d => {
214+
if (d.length > 0) {
215+
minStart = Math.min(minStart, d[0].x);
216+
maxEnd = Math.max(maxEnd, d[d.length - 1].x);
217+
}
218+
});
219+
});
220+
if (minStart !== Infinity) {
221+
onXRangeChange({ min: minStart, max: maxEnd });
222+
}
223+
}
224+
} else {
225+
const minSteps = getMinSteps(parsedData);
226+
if (minSteps > 0) {
227+
onXRangeChange(prev => {
228+
const next = { min: 0, max: minSteps - 1 };
229+
if (prev.min === next.min && prev.max === next.max) return prev;
230+
return next;
231+
});
232+
}
176233
}
177-
}, [parsedData, onXRangeChange]);
234+
}, [parsedData, onXRangeChange, alignSteps]);
178235

179236
const colors = ['#ef4444', '#3b82f6', '#10b981', '#f59e0b', '#8b5cf6', '#f97316'];
180237
const createChartData = dataArray => ({

src/components/__tests__/ChartContainer.test.jsx

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,4 +165,35 @@ describe('ChartContainer', () => {
165165
opts.plugins.zoom.pan.onPanComplete({ chart: { scales: { x: { min: 0, max: 10 } } } });
166166
opts.plugins.zoom.zoom.onZoomComplete({ chart: { scales: { x: { min: 2, max: 4 } } } });
167167
});
168+
169+
it('aligns data points using step keyword when enabled', () => {
170+
const onXRangeChange = vi.fn();
171+
const onMaxStepChange = vi.fn();
172+
const files = [
173+
{ name: 'a.log', enabled: true, content: 'step:1 loss: 1\nstep:2 loss: 2' },
174+
{ name: 'b.log', enabled: true, content: 'step:5 loss: 5\nstep:6 loss: 6' }
175+
];
176+
const prevCount = __lineProps.length;
177+
render(
178+
<ChartContainer
179+
files={files}
180+
metrics={[{ name: 'loss', keyword: 'loss', mode: 'keyword' }]}
181+
compareMode="normal"
182+
alignSteps
183+
stepKeyword="step:"
184+
onXRangeChange={onXRangeChange}
185+
onMaxStepChange={onMaxStepChange}
186+
/>
187+
);
188+
189+
const props = __lineProps[prevCount];
190+
const dsA = props.data.datasets[0].data;
191+
const dsB = props.data.datasets[1].data;
192+
expect(dsA[0].x).toBe(1);
193+
expect(dsA[1].x).toBe(2);
194+
expect(dsB[0].x).toBe(5);
195+
expect(dsB[1].x).toBe(6);
196+
expect(onXRangeChange).toHaveBeenCalledWith({ min: 1, max: 6 });
197+
expect(onMaxStepChange).toHaveBeenCalledWith(6);
198+
});
168199
});

0 commit comments

Comments
 (0)