Skip to content

Commit a37b1ac

Browse files
committed
refactor: improve speedtest chart accuracy and responsiveness
- Time-proportional x-axis with dashed leader line from t=0 - Uniform tick labels with smart unit selection (s/m/h) that scale from seconds to hours - Dynamic y-axis padding based on measured label width - Binary search hit-test with crosshair-snap for dense data - ResizeObserver debounced via requestAnimationFrame - Tooltip clamped to container bounds - General cleanup: named constants, single-pass data prep, responsive layout, safer error handling, input validation, missing dependency
1 parent 459df88 commit a37b1ac

File tree

8 files changed

+208
-89
lines changed

8 files changed

+208
-89
lines changed

packages/speedtest/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"typecheck": "tsc --noEmit"
1111
},
1212
"dependencies": {
13+
"@repo/shared": "workspace:*",
1314
"@repo/webview-shared": "workspace:*"
1415
},
1516
"devDependencies": {

packages/speedtest/src/chart.ts

Lines changed: 84 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,47 @@ export interface ChartPoint {
1010
}
1111

1212
export interface ChartData {
13-
labels: string[];
13+
/** Time positions for each data point (used for proportional x-spacing) */
14+
xValues: number[];
1415
values: number[];
1516
pointLabels: string[];
1617
}
1718

19+
/** Points above this count are drawn as a line only (no dots). */
20+
export const DOT_THRESHOLD = 20;
21+
22+
const DOT_RADIUS = 4;
23+
const MIN_TICK_SPACING = 48;
24+
const LEADER_OPACITY = 0.4;
25+
const Y_GRID_LINES = 5;
26+
const Y_HEADROOM = 1.1;
27+
28+
/** Pick a "nice" step size that is >= the raw step. */
29+
const NICE_STEPS = [
30+
1, 2, 5, 10, 15, 20, 30, 60, 120, 300, 600, 900, 1800, 3600,
31+
];
32+
33+
function niceStep(raw: number): number {
34+
return NICE_STEPS.find((s) => s >= raw) ?? Math.ceil(raw / 3600) * 3600;
35+
}
36+
37+
/** Build a tick formatter that uses a single unit for the entire axis. */
38+
function tickFormatter(step: number): (t: number) => string {
39+
if (step >= 3600) {
40+
return (t) => {
41+
const h = t / 3600;
42+
return `${Number.isInteger(h) ? h : h.toFixed(1)}h`;
43+
};
44+
}
45+
if (step >= 60) {
46+
return (t) => {
47+
const m = t / 60;
48+
return `${Number.isInteger(m) ? m : m.toFixed(1)}m`;
49+
};
50+
}
51+
return (t) => `${t}s`;
52+
}
53+
1854
/**
1955
* Draw a line chart on the given canvas and return hit-test positions.
2056
*/
@@ -36,17 +72,11 @@ export function renderLineChart(
3672
}
3773
ctx.scale(dpr, dpr);
3874

39-
const pad = { top: 24, right: 24, bottom: 52, left: 72 };
40-
const plotW = width - pad.left - pad.right;
41-
const plotH = height - pad.top - pad.bottom;
42-
const maxVal = Math.max(...data.values, 1) * 1.1;
4375
const n = data.values.length;
76+
const maxVal = Math.max(...data.values, 1) * Y_HEADROOM;
77+
const maxX = n > 0 ? data.xValues[n - 1] : 1;
78+
const xRange = maxX || 1;
4479

45-
// Coordinate helpers
46-
const xAt = (i: number) => pad.left + (i / Math.max(n - 1, 1)) * plotW;
47-
const yAt = (v: number) => pad.top + plotH - (v / maxVal) * plotH;
48-
49-
// Read VS Code theme
5080
const s = getComputedStyle(document.documentElement);
5181
const css = (prop: string) => s.getPropertyValue(prop).trim();
5282
const fg =
@@ -60,55 +90,55 @@ export function renderLineChart(
6090
const grid = css("--vscode-editorWidget-border") || "rgba(128,128,128,0.15)";
6191
const family = css("--vscode-font-family") || "sans-serif";
6292

93+
ctx.font = `1em ${family}`;
94+
const yLabelWidth = ctx.measureText(maxVal.toFixed(0)).width;
95+
const pad = {
96+
top: 24,
97+
right: 24,
98+
bottom: 52,
99+
left: Math.max(48, yLabelWidth + 24),
100+
};
101+
const plotW = width - pad.left - pad.right;
102+
const plotH = height - pad.top - pad.bottom;
103+
104+
const tAt = (t: number) => pad.left + (t / xRange) * plotW;
105+
const xAt = (i: number) => tAt(data.xValues[i]);
106+
const yAt = (v: number) => pad.top + plotH - (v / maxVal) * plotH;
107+
63108
// ── Axes ──
64109

65-
// Y-axis grid lines and labels
66110
ctx.strokeStyle = grid;
67111
ctx.lineWidth = 1;
68112
ctx.fillStyle = fg;
69-
ctx.font = `1em ${family}`;
70113
ctx.textAlign = "right";
71-
for (let i = 0; i <= 5; i++) {
72-
const y = yAt((i / 5) * maxVal);
114+
for (let i = 0; i <= Y_GRID_LINES; i++) {
115+
const y = yAt((i / Y_GRID_LINES) * maxVal);
73116
ctx.beginPath();
74117
ctx.moveTo(pad.left, y);
75118
ctx.lineTo(pad.left + plotW, y);
76119
ctx.stroke();
77-
ctx.fillText(((i / 5) * maxVal).toFixed(0), pad.left - 12, y + 5);
120+
ctx.fillText(
121+
((i / Y_GRID_LINES) * maxVal).toFixed(0),
122+
pad.left - 12,
123+
y + 5,
124+
);
78125
}
79126

80-
// Bottom axis line
81127
ctx.strokeStyle = fg;
82128
ctx.beginPath();
83129
ctx.moveTo(pad.left, pad.top + plotH);
84130
ctx.lineTo(pad.left + plotW, pad.top + plotH);
85131
ctx.stroke();
86132

87-
// X-axis labels (auto-thinned, deduped)
88133
ctx.textAlign = "center";
89134
ctx.fillStyle = fg;
90-
const maxLabels = Math.floor(plotW / 60);
91-
const step = Math.max(1, Math.ceil(n / maxLabels));
92-
let lastDrawnLabel = "";
93-
let lastDrawnX = -Infinity;
94-
for (let i = 0; i < n; i += step) {
95-
if (data.labels[i] !== lastDrawnLabel) {
96-
ctx.fillText(data.labels[i], xAt(i), height - pad.bottom + 24);
97-
lastDrawnLabel = data.labels[i];
98-
lastDrawnX = xAt(i);
99-
}
100-
}
101-
const last = n - 1;
102-
if (
103-
last > 0 &&
104-
last % step !== 0 &&
105-
data.labels[last] !== lastDrawnLabel &&
106-
xAt(last) - lastDrawnX > 50
107-
) {
108-
ctx.fillText(data.labels[last], xAt(last), height - pad.bottom + 24);
135+
const maxTicks = Math.max(1, Math.floor(plotW / MIN_TICK_SPACING));
136+
const tickStep = niceStep(xRange / maxTicks);
137+
const formatTick = tickFormatter(tickStep);
138+
for (let t = 0; t <= maxX; t += tickStep) {
139+
ctx.fillText(formatTick(t), tAt(t), height - pad.bottom + 24);
109140
}
110141

111-
// Axis titles
112142
ctx.font = `0.95em ${family}`;
113143
ctx.fillText("Time", pad.left + plotW / 2, height - 4);
114144
ctx.save();
@@ -124,10 +154,23 @@ export function renderLineChart(
124154
// ── Series ──
125155

126156
const baseline = pad.top + plotH;
157+
const firstPx = xAt(0);
158+
159+
if (data.xValues[0] > 0) {
160+
ctx.beginPath();
161+
ctx.moveTo(tAt(0), baseline);
162+
ctx.lineTo(firstPx, yAt(data.values[0]));
163+
ctx.setLineDash([4, 4]);
164+
ctx.strokeStyle = accent;
165+
ctx.lineWidth = 1;
166+
ctx.globalAlpha = LEADER_OPACITY;
167+
ctx.stroke();
168+
ctx.setLineDash([]);
169+
ctx.globalAlpha = 1;
170+
}
127171

128-
// Fill area
129172
ctx.beginPath();
130-
ctx.moveTo(xAt(0), baseline);
173+
ctx.moveTo(firstPx, baseline);
131174
for (let i = 0; i < n; i++) {
132175
ctx.lineTo(xAt(i), yAt(data.values[i]));
133176
}
@@ -139,7 +182,6 @@ export function renderLineChart(
139182
ctx.fillStyle = gradient;
140183
ctx.fill();
141184

142-
// Line
143185
ctx.beginPath();
144186
ctx.moveTo(xAt(0), yAt(data.values[0]));
145187
for (let i = 1; i < n; i++) {
@@ -149,15 +191,14 @@ export function renderLineChart(
149191
ctx.lineWidth = 2;
150192
ctx.stroke();
151193

152-
// Dots and hit-test positions
153-
const showDots = n <= 50;
194+
const showDots = n <= DOT_THRESHOLD;
154195
const points: ChartPoint[] = [];
155196
for (let i = 0; i < n; i++) {
156197
const x = xAt(i);
157198
const y = yAt(data.values[i]);
158199
if (showDots) {
159200
ctx.beginPath();
160-
ctx.arc(x, y, 4, 0, Math.PI * 2);
201+
ctx.arc(x, y, DOT_RADIUS, 0, Math.PI * 2);
161202
ctx.fillStyle = accent;
162203
ctx.fill();
163204
}

packages/speedtest/src/index.css

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
1+
*,
2+
*::before,
3+
*::after {
4+
box-sizing: border-box;
5+
}
6+
17
body {
28
margin: 0;
39
padding: 24px;
10+
min-width: 360px;
411
background: var(--vscode-editor-background);
512
color: var(--vscode-editor-foreground);
613
font-family: var(--vscode-font-family);
@@ -9,12 +16,11 @@ body {
916

1017
.summary {
1118
display: flex;
19+
flex-wrap: wrap;
1220
justify-content: center;
13-
gap: 48px;
21+
gap: 16px 48px;
1422
margin-bottom: 24px;
1523
text-align: center;
16-
/* Offset to align with the chart plot area (matches canvas left padding) */
17-
padding-left: 48px;
1824
}
1925

2026
.stat-label {
@@ -39,7 +45,6 @@ body {
3945

4046
.chart-container {
4147
position: relative;
42-
min-width: 400px;
4348
height: 320px;
4449
margin-bottom: 20px;
4550
}
@@ -52,7 +57,6 @@ body {
5257
.actions {
5358
display: flex;
5459
justify-content: center;
55-
padding-left: 48px;
5660
}
5761

5862
button {
@@ -79,7 +83,6 @@ button:hover {
7983
font-size: 0.9em;
8084
white-space: nowrap;
8185
pointer-events: none;
82-
transform: translateX(-50%);
8386
opacity: 0;
8487
transition: opacity 0.1s;
8588
}

0 commit comments

Comments
 (0)