Skip to content

Commit 43bdf99

Browse files
authored
fix(report): stabilize record timeline scaling (#2724)
* fix(report): stabilize record timeline scaling * fix(report): address timeline scale review feedback
1 parent 92571ba commit 43bdf99

3 files changed

Lines changed: 181 additions & 34 deletions

File tree

apps/report/src/components/timeline/index.tsx

Lines changed: 16 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ import type { ExecutionTask } from '@midscene/core';
55
import { useTheme } from '@midscene/visualizer';
66
import { useAllCurrentTasks, useExecutionDump } from '../store';
77
import { buildTimelineScreenshots } from './build-timeline-screenshots';
8+
import {
9+
DEFAULT_TIMELINE_MAX_TIME_MS,
10+
createTimelineScale,
11+
formatTimelineTime,
12+
} from './timeline-scale';
813

914
interface TimelineItem {
1015
id: string;
@@ -65,7 +70,7 @@ const TimelineWidget = (props: {
6570
const { isDarkMode } = useTheme();
6671

6772
const allScreenshots = props.screenshots || [];
68-
let maxTime = 500;
73+
let maxTime = DEFAULT_TIMELINE_MAX_TIME_MS;
6974
if (allScreenshots.length >= 2) {
7075
maxTime = Math.max(
7176
allScreenshots[allScreenshots.length - 1].timeOffset,
@@ -125,30 +130,12 @@ const TimelineWidget = (props: {
125130
const { clientWidth } = domRef.current;
126131
const canvasWidth = clientWidth * sizeRatio;
127132
const canvasHeight = BASE_HEIGHT * sizeRatio;
128-
129-
// Grid calculations
130-
let singleGridWidth = 100 * sizeRatio;
131-
let gridCount = Math.floor(canvasWidth / singleGridWidth);
132-
const stepCandidate = [
133-
50, 100, 200, 300, 500, 1000, 2000, 3000, 5000, 6000, 8000, 9000, 10000,
134-
20000, 30000, 40000, 60000, 90000, 12000, 300000,
135-
];
136-
let timeStep = stepCandidate[0];
137-
for (let i = stepCandidate.length - 1; i >= 0; i--) {
138-
if (gridCount * stepCandidate[i] >= maxTime) {
139-
timeStep = stepCandidate[i];
140-
}
141-
}
142-
const gridRatio = maxTime / (gridCount * timeStep);
143-
if (gridRatio <= 0.8) {
144-
singleGridWidth = Math.floor(singleGridWidth * (1 / gridRatio) * 0.9);
145-
gridCount = Math.floor(canvasWidth / singleGridWidth);
146-
}
147-
148-
const leftForTimeOffset = (t: number) =>
149-
Math.floor((singleGridWidth * t) / timeStep);
150-
const timeOffsetForLeft = (l: number) =>
151-
Math.floor((l * timeStep) / singleGridWidth);
133+
const { timeStep, visibleMaxTime, leftForTimeOffset, timeOffsetForLeft } =
134+
createTimelineScale({
135+
canvasWidth,
136+
maxTime,
137+
sizeRatio,
138+
});
152139

153140
// Create canvas
154141
const canvas = document.createElement('canvas');
@@ -162,11 +149,6 @@ const TimelineWidget = (props: {
162149
const screenshotMaxHeight =
163150
canvasHeight - screenshotTop - commonPadding * 1.5;
164151

165-
const formatTime = (num: number) => {
166-
const s = num / 1000;
167-
return s % 1 === 0 ? `${s}s` : `${s.toFixed(1)}s`;
168-
};
169-
170152
// Viewport-aware lazy loading: downsample by pixel position, then load rest
171153
const { imgCache } = stateRef.current;
172154
let isMounted = true;
@@ -266,12 +248,12 @@ const TimelineWidget = (props: {
266248

267249
// Grid lines + time labels
268250
ctx.font = `${timeContentFontSize}px sans-serif`;
269-
for (let i = 1; i <= gridCount; i++) {
270-
const x = leftForTimeOffset(i * timeStep);
251+
for (let tickMs = timeStep; tickMs < visibleMaxTime; tickMs += timeStep) {
252+
const x = leftForTimeOffset(tickMs);
271253
ctx.fillStyle = hexToCSS(gridLineColor);
272254
ctx.fillRect(x, 0, sizeRatio, canvasHeight);
273255

274-
const label = formatTime(i * timeStep);
256+
const label = formatTimelineTime(tickMs);
275257
const tw = ctx.measureText(label).width;
276258
ctx.fillStyle = hexToCSS(gridTextColor);
277259
ctx.fillText(
@@ -354,7 +336,7 @@ const TimelineWidget = (props: {
354336
}
355337

356338
// Time label at cursor
357-
const label = formatTime(timeOffsetForLeft(hoverX));
339+
const label = formatTimelineTime(timeOffsetForLeft(hoverX));
358340
const tw = ctx.measureText(label).width;
359341
ctx.fillStyle = hexToCSS(titleBg);
360342
ctx.fillRect(hoverX + 5, timeTextTop, tw + 10, timeContentFontSize + 4);
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { describe, expect, it } from 'vitest';
2+
import {
3+
createTimelineScale,
4+
formatTimelineTime,
5+
pickNiceStep,
6+
} from './timeline-scale';
7+
8+
describe('formatTimelineTime', () => {
9+
it('uses milliseconds for values below one second', () => {
10+
expect(formatTimelineTime(50)).toBe('50ms');
11+
expect(formatTimelineTime(100)).toBe('100ms');
12+
expect(formatTimelineTime(500)).toBe('500ms');
13+
expect(formatTimelineTime(999)).toBe('999ms');
14+
});
15+
16+
it('uses seconds for values at or above one second', () => {
17+
expect(formatTimelineTime(1000)).toBe('1s');
18+
expect(formatTimelineTime(1500)).toBe('1.5s');
19+
expect(formatTimelineTime(300000)).toBe('300s');
20+
});
21+
});
22+
23+
describe('pickNiceStep', () => {
24+
it('rounds rough steps up to readable values', () => {
25+
expect(pickNiceStep(0.2)).toBe(1);
26+
expect(pickNiceStep(73)).toBe(100);
27+
expect(pickNiceStep(420)).toBe(500);
28+
expect(pickNiceStep(1700)).toBe(2000);
29+
expect(pickNiceStep(12000)).toBe(20000);
30+
expect(pickNiceStep(173000)).toBe(200000);
31+
expect(pickNiceStep(610000)).toBe(1000000);
32+
});
33+
34+
it('falls back for invalid rough steps', () => {
35+
expect(pickNiceStep(0)).toBe(1000);
36+
expect(pickNiceStep(Number.NaN)).toBe(1000);
37+
expect(pickNiceStep(Number.POSITIVE_INFINITY)).toBe(1000);
38+
});
39+
});
40+
41+
describe('createTimelineScale', () => {
42+
it('pads the visible range so the last screenshot stays inside the canvas', () => {
43+
const scale = createTimelineScale({
44+
canvasWidth: 1000,
45+
maxTime: 1_733_653,
46+
sizeRatio: 2,
47+
});
48+
49+
expect(scale.leftForTimeOffset(0)).toBe(0);
50+
expect(scale.timeStep).toBe(500_000);
51+
expect(scale.visibleMaxTime).toBe(2_000_000);
52+
expect(scale.leftForTimeOffset(1_733_653)).toBeLessThan(1000);
53+
expect(scale.leftForTimeOffset(scale.visibleMaxTime)).toBe(1000);
54+
expect(scale.timeOffsetForLeft(1000)).toBe(2_000_000);
55+
});
56+
57+
it('keeps long narrow timelines on a readable step instead of falling back to 50ms', () => {
58+
const scale = createTimelineScale({
59+
canvasWidth: 1000,
60+
maxTime: 1_733_653,
61+
sizeRatio: 2,
62+
});
63+
64+
expect(scale.timeStep).toBe(500_000);
65+
expect(scale.visibleMaxTime).toBe(2_000_000);
66+
});
67+
68+
it('uses the canvas scale for positions independently from the nice tick step', () => {
69+
const scale = createTimelineScale({
70+
canvasWidth: 2000,
71+
maxTime: 1000,
72+
sizeRatio: 2,
73+
});
74+
75+
expect(scale.timeStep).toBe(100);
76+
expect(scale.leftForTimeOffset(250)).toBe(500);
77+
expect(scale.leftForTimeOffset(500)).toBe(1000);
78+
});
79+
80+
it('uses size ratio when choosing the readable tick step', () => {
81+
const lowDensityScale = createTimelineScale({
82+
canvasWidth: 1000,
83+
maxTime: 1000,
84+
sizeRatio: 1,
85+
});
86+
const highDensityScale = createTimelineScale({
87+
canvasWidth: 1000,
88+
maxTime: 1000,
89+
sizeRatio: 2,
90+
});
91+
92+
expect(lowDensityScale.timeStep).toBe(100);
93+
expect(highDensityScale.timeStep).toBe(200);
94+
expect(lowDensityScale.leftForTimeOffset(500)).toBe(500);
95+
expect(highDensityScale.leftForTimeOffset(500)).toBe(500);
96+
});
97+
});
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
const NICE_STEP_FACTORS = [1, 2, 3, 5, 10] as const;
2+
3+
export const DEFAULT_TIMELINE_MAX_TIME_MS = 500;
4+
export const DESIRED_GRID_WIDTH_PX = 100;
5+
export const DEFAULT_TIME_STEP_MS = 1000;
6+
7+
export interface TimelineScale {
8+
pxPerMs: number;
9+
timeStep: number;
10+
visibleMaxTime: number;
11+
leftForTimeOffset: (timeOffset: number) => number;
12+
timeOffsetForLeft: (left: number) => number;
13+
}
14+
15+
export const formatTimelineTime = (timeMs: number): string => {
16+
if (Math.abs(timeMs) < 1000) {
17+
return `${Math.round(timeMs)}ms`;
18+
}
19+
20+
const seconds = timeMs / 1000;
21+
return seconds % 1 === 0 ? `${seconds}s` : `${seconds.toFixed(1)}s`;
22+
};
23+
24+
export const pickNiceStep = (roughStepMs: number): number => {
25+
if (!Number.isFinite(roughStepMs) || roughStepMs <= 0) {
26+
return DEFAULT_TIME_STEP_MS;
27+
}
28+
29+
const magnitude = 10 ** Math.floor(Math.log10(roughStepMs));
30+
const normalized = roughStepMs / magnitude;
31+
const factor =
32+
NICE_STEP_FACTORS.find((candidate) => candidate >= normalized) ?? 10;
33+
34+
// Timeline labels are rendered at integer millisecond precision, so sub-ms
35+
// ticks would collapse into duplicate labels such as "0ms".
36+
return Math.max(1, factor * magnitude);
37+
};
38+
39+
export const createTimelineScale = ({
40+
canvasWidth,
41+
maxTime,
42+
sizeRatio,
43+
}: {
44+
canvasWidth: number;
45+
maxTime: number;
46+
sizeRatio: number;
47+
}): TimelineScale => {
48+
const safeCanvasWidth = Math.max(canvasWidth, 1);
49+
const safeMaxTime = Math.max(maxTime, 1);
50+
51+
const desiredGridPx = DESIRED_GRID_WIDTH_PX * sizeRatio;
52+
const roughPxPerMs = safeCanvasWidth / safeMaxTime;
53+
const roughStepMs = desiredGridPx / roughPxPerMs;
54+
const timeStep = pickNiceStep(roughStepMs);
55+
// Timeline thumbnails use x as the image's left edge. If maxTime maps exactly
56+
// to the canvas right edge, the last thumbnail starts off-canvas. Extend the
57+
// visible range to the next tick so the ending thumbnail still has room.
58+
const visibleMaxTime = Math.ceil(safeMaxTime / timeStep) * timeStep;
59+
const pxPerMs = safeCanvasWidth / visibleMaxTime;
60+
61+
return {
62+
pxPerMs,
63+
timeStep,
64+
visibleMaxTime,
65+
leftForTimeOffset: (timeOffset: number) => Math.floor(timeOffset * pxPerMs),
66+
timeOffsetForLeft: (left: number) => Math.floor(left / pxPerMs),
67+
};
68+
};

0 commit comments

Comments
 (0)