Skip to content

Commit 7e64719

Browse files
committed
feat: support step keyword positioning
1 parent 58c751b commit 7e64719

3 files changed

Lines changed: 150 additions & 41 deletions

File tree

src/App.jsx

Lines changed: 27 additions & 1 deletion
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 [useStepKeyword, setUseStepKeyword] = useState(false);
44+
const [stepKeyword, setStepKeyword] = useState('step:');
4345

4446
const handleFilesUploaded = useCallback((files) => {
4547
const filesWithDefaults = files.map(file => ({
@@ -340,7 +342,29 @@ 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+
<div className="border-t pt-3">
346+
<h4 className="text-xs font-medium text-gray-700 mb-2">Step 设置</h4>
347+
<label className="inline-flex items-center text-xs text-gray-700">
348+
<input
349+
type="checkbox"
350+
className="mr-2"
351+
checked={useStepKeyword}
352+
onChange={(e) => setUseStepKeyword(e.target.checked)}
353+
/>
354+
使用关键词定位 step
355+
</label>
356+
{useStepKeyword && (
357+
<input
358+
type="text"
359+
value={stepKeyword}
360+
onChange={(e) => setStepKeyword(e.target.value)}
361+
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"
362+
placeholder="step:"
363+
aria-label="step keyword"
364+
/>
365+
)}
366+
</div>
367+
344368
<div className="border-t pt-3">
345369
<h4 className="text-xs font-medium text-gray-700 mb-2">基准线设置</h4>
346370
<div className="space-y-3">
@@ -414,6 +438,8 @@ function App() {
414438
xRange={xRange}
415439
onXRangeChange={setXRange}
416440
onMaxStepChange={setMaxStep}
441+
useStepKeyword={useStepKeyword}
442+
stepKeyword={stepKeyword}
417443
/>
418444
</section>
419445
</main>

src/components/ChartContainer.jsx

Lines changed: 103 additions & 31 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+
useStepKeyword = false,
73+
stepKeyword = 'step:'
7274
}) {
7375
const chartRefs = useRef(new Map());
7476
const registerChart = useCallback((id, inst) => {
@@ -103,17 +105,60 @@ export default function ChartContainer({
103105
const lines = file.content.split('\n');
104106
const metricsData = {};
105107

106-
const extractByKeyword = (content, keyword) => {
108+
const numberRegex = /[+-]?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/;
109+
110+
const extractByKeyword = (keyword) => {
107111
const results = [];
108-
const numberRegex = /[+-]?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/;
109-
content.split('\n').forEach(line => {
110-
const idx = line.toLowerCase().indexOf(keyword.toLowerCase());
112+
const lowerMetric = keyword.toLowerCase();
113+
lines.forEach(line => {
114+
const idx = line.toLowerCase().indexOf(lowerMetric);
111115
if (idx !== -1) {
112116
const after = line.substring(idx + keyword.length);
113117
const match = after.match(numberRegex);
114118
if (match) {
115119
const v = parseFloat(match[0]);
116-
if (!isNaN(v)) results.push(v);
120+
if (!isNaN(v)) {
121+
let x = results.length;
122+
if (useStepKeyword) {
123+
const stepIdx = line.toLowerCase().indexOf(stepKeyword.toLowerCase());
124+
if (stepIdx !== -1) {
125+
const stepAfter = line.substring(stepIdx + stepKeyword.length);
126+
const stepMatch = stepAfter.match(numberRegex);
127+
if (stepMatch) {
128+
const s = parseFloat(stepMatch[0]);
129+
if (!isNaN(s)) x = s;
130+
}
131+
}
132+
}
133+
results.push({ x, y: v });
134+
}
135+
}
136+
}
137+
});
138+
return results;
139+
};
140+
141+
const extractByRegex = (reg) => {
142+
const results = [];
143+
lines.forEach(line => {
144+
reg.lastIndex = 0;
145+
const m = reg.exec(line);
146+
if (m && m[1]) {
147+
const v = parseFloat(m[1]);
148+
if (!isNaN(v)) {
149+
let x = results.length;
150+
if (useStepKeyword) {
151+
const stepIdx = line.toLowerCase().indexOf(stepKeyword.toLowerCase());
152+
if (stepIdx !== -1) {
153+
const stepAfter = line.substring(stepIdx + stepKeyword.length);
154+
const stepMatch = stepAfter.match(numberRegex);
155+
if (stepMatch) {
156+
const s = parseFloat(stepMatch[0]);
157+
if (!isNaN(s)) x = s;
158+
}
159+
}
160+
}
161+
results.push({ x, y: v });
117162
}
118163
}
119164
});
@@ -123,39 +168,38 @@ export default function ChartContainer({
123168
metrics.forEach(metric => {
124169
let values = [];
125170
if (metric.mode === 'keyword') {
126-
values = extractByKeyword(file.content, metric.keyword);
171+
values = extractByKeyword(metric.keyword);
127172
} else if (metric.regex) {
128173
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);
135-
}
136-
});
174+
values = extractByRegex(reg);
137175
}
138-
metricsData[metric.name || metric.keyword] = values.map((v, i) => ({ x: i, y: v }));
176+
metricsData[metric.name || metric.keyword] = values;
139177
});
140178

141179
const range = file.config?.dataRange;
142180
if (range && (range.start > 0 || range.end !== undefined)) {
143181
const applyRange = data => {
144182
if (data.length === 0) return data;
145-
const start = Math.max(0, parseInt(range.start) || 0);
146-
const end = range.end !== undefined ? parseInt(range.end) : data.length;
147-
const endIndex = Math.min(data.length, end);
148-
return data.slice(start, endIndex);
183+
if (useStepKeyword) {
184+
const start = parseInt(range.start) || 0;
185+
const end = range.end !== undefined ? parseInt(range.end) : Infinity;
186+
return data.filter(p => p.x >= start && p.x <= end);
187+
} else {
188+
const start = Math.max(0, parseInt(range.start) || 0);
189+
const end = range.end !== undefined ? parseInt(range.end) : data.length;
190+
const endIndex = Math.min(data.length, end);
191+
const sliced = data.slice(start, endIndex);
192+
return sliced.map((p, idx) => ({ x: idx, y: p.y }));
193+
}
149194
};
150-
const reindex = data => data.map((p, idx) => ({ x: idx, y: p.y }));
151195
Object.keys(metricsData).forEach(k => {
152-
metricsData[k] = reindex(applyRange(metricsData[k]));
196+
metricsData[k] = applyRange(metricsData[k]);
153197
});
154198
}
155199

156200
return { ...file, metricsData };
157201
});
158-
}, [files, metrics]);
202+
}, [files, metrics, useStepKeyword, stepKeyword]);
159203

160204
useEffect(() => {
161205
const maxStep = parsedData.reduce((m, f) => {
@@ -166,15 +210,43 @@ export default function ChartContainer({
166210
}, [parsedData, onMaxStepChange]);
167211

168212
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-
});
213+
if (useStepKeyword) {
214+
const ranges = parsedData.map(f => {
215+
const datasets = Object.values(f.metricsData);
216+
if (datasets.length === 0) return null;
217+
let start = -Infinity;
218+
let end = Infinity;
219+
datasets.forEach(d => {
220+
if (d.length > 0) {
221+
if (d[0].x > start) start = d[0].x;
222+
if (d[d.length - 1].x < end) end = d[d.length - 1].x;
223+
}
224+
});
225+
if (start === -Infinity || end === Infinity) return null;
226+
return { start, end };
227+
}).filter(Boolean);
228+
if (ranges.length > 0) {
229+
const start = Math.max(...ranges.map(r => r.start));
230+
const end = Math.min(...ranges.map(r => r.end));
231+
if (end >= start) {
232+
onXRangeChange(prev => {
233+
const next = { min: start, max: end };
234+
if (prev.min === next.min && prev.max === next.max) return prev;
235+
return next;
236+
});
237+
}
238+
}
239+
} else {
240+
const minSteps = getMinSteps(parsedData);
241+
if (minSteps > 0) {
242+
onXRangeChange(prev => {
243+
const next = { min: 0, max: minSteps - 1 };
244+
if (prev.min === next.min && prev.max === next.max) return prev;
245+
return next;
246+
});
247+
}
176248
}
177-
}, [parsedData, onXRangeChange]);
249+
}, [parsedData, onXRangeChange, useStepKeyword]);
178250

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

src/components/__tests__/ChartContainer.test.jsx

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -63,14 +63,25 @@ describe('ChartContainer', () => {
6363
expect(screen.getByText('🎯 请选择要显示的图表')).toBeInTheDocument();
6464
});
6565

66-
it('renders charts and triggers callbacks', async () => {
67-
const { onXRangeChange, onMaxStepChange } = renderComponent({ files: [sampleFile], metrics: [metric] });
68-
expect(await screen.findByText('📊 loss')).toBeInTheDocument();
69-
await waitFor(() => {
70-
expect(onMaxStepChange).toHaveBeenCalledWith(1);
71-
expect(onXRangeChange).toHaveBeenCalled();
66+
it('renders charts and triggers callbacks', async () => {
67+
const { onXRangeChange, onMaxStepChange } = renderComponent({ files: [sampleFile], metrics: [metric] });
68+
expect(await screen.findByText('📊 loss')).toBeInTheDocument();
69+
await waitFor(() => {
70+
expect(onMaxStepChange).toHaveBeenCalledWith(1);
71+
expect(onXRangeChange).toHaveBeenCalled();
72+
});
73+
const cb = onXRangeChange.mock.calls[0][0];
74+
expect(cb({})).toEqual({ min: 0, max: 1 });
75+
});
76+
77+
it('uses step keyword for x positions when enabled', async () => {
78+
const file = { name: 's.log', id: '2', content: 'step: 2 loss: 1\nstep: 4 loss: 2' };
79+
const { onXRangeChange, onMaxStepChange } = renderComponent({ files: [file], metrics: [metric], useStepKeyword: true, stepKeyword: 'step:' });
80+
await waitFor(() => {
81+
expect(onMaxStepChange).toHaveBeenCalledWith(4);
82+
expect(onXRangeChange).toHaveBeenCalled();
83+
});
84+
const cb = onXRangeChange.mock.calls[0][0];
85+
expect(cb({})).toEqual({ min: 2, max: 4 });
7286
});
73-
const cb = onXRangeChange.mock.calls[0][0];
74-
expect(cb({})).toEqual({ min: 0, max: 1 });
7587
});
76-
});

0 commit comments

Comments
 (0)