Skip to content

Commit ae5daba

Browse files
authored
fix(traces): add precision to waterfall ticks to prevent duplicate labels (#2652)
# Summary On the trace minimap and waterfall viewer, short traces (or traces zoomed in far enough) produced duplicate tick values for values that rounded to the same ms value. Now, we ensure that adjacent tick labels differ by adding precision when adjacent values would round to the same number. ## What changed - Trace viewer and trace minimap tick labels now show just enough precision to ensure that adjacent values are different - The trace viewer cursor / tooltip displays precision matching the tick labels ### Before https://github.com/user-attachments/assets/dd4cbcf2-495c-4b72-b4fc-b5476e78fae9 <img width="1079" height="439" alt="Screenshot 2026-07-16 at 8 45 27 AM" src="https://github.com/user-attachments/assets/8ba7bb1d-acd8-44f0-9a29-5b363ffe1dc8" /> ### After https://github.com/user-attachments/assets/a4a514eb-9593-46f7-99ab-69d37f682545 <img width="1057" height="360" alt="Screenshot 2026-07-16 at 8 45 19 AM" src="https://github.com/user-attachments/assets/4f9838b4-d2ab-434c-8d5e-b1a459681640" /> ## How to test in Preview Pick a relatively short trace and zoom way in. Observe the trace viewer ticks and cursor/tooltip. ## References Closes HDX-4803 Closes #2650
1 parent 1f3c3ca commit ae5daba

6 files changed

Lines changed: 182 additions & 23 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@hyperdx/app": patch
3+
---
4+
5+
fix(traces): Prevent duplicate ticks in waterfall/minimap

‎packages/app/src/components/TimelineChart/TimelineMinimap.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ export const TimelineMinimap = memo(function ({
113113
const val = i * interval;
114114
const frac = val / maxVal;
115115
if (frac > 1) break;
116-
result.push({ frac, label: renderMs(val) });
116+
result.push({ frac, label: renderMs(val, interval) });
117117
}
118118
return result;
119119
}, [maxVal]);
@@ -277,7 +277,7 @@ export const TimelineMinimap = memo(function ({
277277
>
278278
{ticks.map(({ frac, label }) => (
279279
<div
280-
key={label}
280+
key={frac}
281281
className={styles.tick}
282282
style={{ left: `${frac * 100}%`, height: MINIMAP_HEIGHT }}
283283
>

‎packages/app/src/components/TimelineChart/TimelineMouseCursor.tsx‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88

99
import { useStableCallback } from '@/hooks/useStableCallback';
1010

11-
import { renderMs } from './utils';
11+
import { renderMs, tickIntervalForWidth } from './utils';
1212

1313
import styles from './TimelineChart.module.scss';
1414

@@ -107,7 +107,13 @@ export function TimelineMouseCursor({
107107
const cursorPx = x - labelW; // = xPerc * (clientWidth - labelW)
108108
cursor.style.transform = `translateX(${cursorPx.toFixed(2)}px)`;
109109
overlay.style.transform = `translateX(${xPerc < 0.5 ? 12 : -150}px)`;
110-
label.textContent = renderMs(Math.max(time, 0));
110+
// Format with the same tick interval the X-axis derives from this width, so
111+
// the readout carries the same precision (and unit) as the tick labels.
112+
const interval = tickIntervalForWidth(
113+
maxValRef.current,
114+
eventsContentWidth,
115+
);
116+
label.textContent = renderMs(Math.max(time, 0), interval);
111117
line.style.height = `${heightRef.current ?? 0}px`;
112118
});
113119

‎packages/app/src/components/TimelineChart/TimelineXAxis.tsx‎

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,14 @@ import {
77
useRef,
88
} from 'react';
99

10-
import { calculateInterval, renderMs } from './utils';
10+
import { renderMs, tickIntervalForWidth } from './utils';
1111

1212
import styles from './TimelineChart.module.scss';
1313

1414
export type TimelineXAxisHandle = {
1515
recompute: () => void;
1616
};
1717

18-
// Minimum horizontal space reserved per tick label. The widest label we render
19-
// (e.g. "1.234s") is ~45px; the extra headroom keeps adjacent labels from
20-
// touching. Tune up for a sparser axis, down for a denser one.
21-
const MIN_TICK_PX = 56;
22-
2318
export function TimelineXAxis({
2419
maxVal,
2520
labelWidth,
@@ -64,8 +59,7 @@ export function TimelineXAxis({
6459
// recompute after the layout/scale has changed. Both containers span the
6560
// same width, so measure one and drive both from the same spacing.
6661
const ticksWidthPx = gridContainer.getBoundingClientRect().width;
67-
const maxTicks = Math.max(1, Math.floor(ticksWidthPx / MIN_TICK_PX));
68-
const interval = calculateInterval(max, maxTicks);
62+
const interval = tickIntervalForWidth(max, ticksWidthPx);
6963
const numTicks = Math.floor(max / interval);
7064
const percSpacing = (interval / max) * 100;
7165
const marginLeft = (i: number) =>
@@ -111,7 +105,7 @@ export function TimelineXAxis({
111105
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
112106
const label = tick.firstElementChild as HTMLDivElement;
113107
tick.style.marginLeft = marginLeft(i);
114-
label.textContent = renderMs(i * interval);
108+
label.textContent = renderMs(i * interval, interval);
115109
}
116110
}, [maxValRef, heightRef]);
117111

‎packages/app/src/components/TimelineChart/__tests__/utils.test.ts‎

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import {
22
calculateInterval,
33
getMaxEventValue,
4+
MIN_TICK_PX,
45
renderMs,
6+
tickIntervalForWidth,
57
} from '@/components/TimelineChart/utils';
68

79
describe('renderMs', () => {
@@ -45,6 +47,85 @@ describe('renderMs', () => {
4547
// 0.9995ms rounds to 1000µs, so it should render as 1ms instead
4648
expect(renderMs(0.9995)).toBe('1ms');
4749
});
50+
51+
describe('with a tick interval (step)', () => {
52+
it('adds ms precision so adjacent ticks stay distinct', () => {
53+
expect(renderMs(1, 0.5)).toBe('1.0ms');
54+
expect(renderMs(1.5, 0.5)).toBe('1.5ms');
55+
expect(renderMs(2, 0.5)).toBe('2.0ms');
56+
expect(renderMs(2.5, 0.5)).toBe('2.5ms');
57+
});
58+
59+
it('produces no duplicate labels across a 0.5ms-spaced axis', () => {
60+
const interval = 0.5;
61+
const labels = [];
62+
for (let i = 0; i * interval <= 4; i++) {
63+
labels.push(renderMs(i * interval, interval));
64+
}
65+
expect(new Set(labels).size).toBe(labels.length);
66+
});
67+
68+
it('uses two decimals when the interval requires it', () => {
69+
expect(renderMs(1, 0.05)).toBe('1.00ms');
70+
expect(renderMs(1.05, 0.05)).toBe('1.05ms');
71+
expect(renderMs(1.1, 0.05)).toBe('1.10ms');
72+
});
73+
74+
it('keeps integer labels when the interval is a whole number', () => {
75+
expect(renderMs(2, 2)).toBe('2ms');
76+
expect(renderMs(50, 50)).toBe('50ms');
77+
});
78+
79+
it('adds precision to sub-millisecond (µs) ticks', () => {
80+
expect(renderMs(0.5, 0.5)).toBe('500µs');
81+
expect(renderMs(0.0005, 0.0005)).toBe('0.5µs');
82+
expect(renderMs(0.001, 0.0005)).toBe('1.0µs');
83+
expect(renderMs(0.0015, 0.0005)).toBe('1.5µs');
84+
});
85+
86+
it('falls through to ms (with ms precision) when the µs form rounds to 1000', () => {
87+
expect(renderMs(0.99996, 0.0005)).toBe('1.0000ms');
88+
expect(renderMs(0.9999, 0.05)).toBe('1.00ms');
89+
expect(renderMs(0.9996, 0.5)).toBe('1.0ms');
90+
});
91+
92+
it('adds precision to second-scale ticks', () => {
93+
expect(renderMs(1000, 500)).toBe('1.0s');
94+
expect(renderMs(1500, 500)).toBe('1.5s');
95+
expect(renderMs(2000, 500)).toBe('2.0s');
96+
});
97+
98+
describe('edge cases', () => {
99+
it('treats a null step like an omitted step (no added precision)', () => {
100+
// @ts-expect-error null is not assignable to step, but is guarded at runtime
101+
expect(renderMs(1, null)).toBe('1ms');
102+
// @ts-expect-error null is not assignable to step, but is guarded at runtime
103+
expect(renderMs(0.5, null)).toBe('500µs');
104+
// @ts-expect-error null is not assignable to step, but is guarded at runtime
105+
expect(renderMs(1500, null)).toBe('1.500s');
106+
// @ts-expect-error null is not assignable to step, but is guarded at runtime
107+
expect(renderMs(2000, null)).toBe('2s');
108+
});
109+
110+
it('adds no precision for a 0 step', () => {
111+
expect(renderMs(1, 0)).toBe('1ms');
112+
expect(renderMs(0.5, 0)).toBe('500µs');
113+
expect(renderMs(1000, 0)).toBe('1s');
114+
expect(renderMs(1500, 0)).toBe('2s');
115+
});
116+
117+
it('adds no precision for a negative step', () => {
118+
expect(renderMs(1, -0.5)).toBe('1ms');
119+
expect(renderMs(0.5, -0.0005)).toBe('500µs');
120+
expect(renderMs(1000, -500)).toBe('1s');
121+
});
122+
123+
it('adds no precision for a non-finite step', () => {
124+
expect(renderMs(1, Infinity)).toBe('1ms');
125+
expect(renderMs(1, NaN)).toBe('1ms');
126+
});
127+
});
128+
});
48129
});
49130

50131
describe('calculateInterval', () => {
@@ -111,6 +192,39 @@ describe('calculateInterval', () => {
111192
});
112193
});
113194

195+
describe('tickIntervalForWidth', () => {
196+
it('budgets ticks by MIN_TICK_PX, matching calculateInterval', () => {
197+
// 560px fits 10 ticks (560 / 56), so a 100ms range steps by 10ms.
198+
expect(tickIntervalForWidth(100, 10 * MIN_TICK_PX)).toBe(
199+
calculateInterval(100, 10),
200+
);
201+
});
202+
203+
it('yields finer intervals as the width grows', () => {
204+
// A narrow axis packs fewer ticks (coarser interval); a wide one packs more
205+
// (finer interval). This is what lets the cursor readout gain precision when
206+
// the timeline is zoomed in.
207+
const narrow = tickIntervalForWidth(100, 3 * MIN_TICK_PX);
208+
const wide = tickIntervalForWidth(100, 20 * MIN_TICK_PX);
209+
expect(wide).toBeLessThan(narrow);
210+
});
211+
212+
it('formats a cursor time with the same precision as the tick labels', () => {
213+
// A zoomed-in 5ms range across a wide axis steps by a sub-ms interval, so
214+
// the readout keeps a decimal instead of rounding to a whole millisecond.
215+
const interval = tickIntervalForWidth(5, 20 * MIN_TICK_PX);
216+
expect(renderMs(2.3, interval)).toBe('2.3ms');
217+
expect(renderMs(2.3)).toBe('2ms');
218+
});
219+
220+
it('reserves at least one tick for tiny widths', () => {
221+
expect(tickIntervalForWidth(100, 0)).toBe(calculateInterval(100, 1));
222+
expect(tickIntervalForWidth(100, MIN_TICK_PX / 2)).toBe(
223+
calculateInterval(100, 1),
224+
);
225+
});
226+
});
227+
114228
describe('getMaxEventValue', () => {
115229
it('returns the max event end plus 10% padding', () => {
116230
const rows = [

‎packages/app/src/components/TimelineChart/utils.ts‎

Lines changed: 50 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,24 +13,52 @@ export function getMaxEventValue(
1313
return max * 1.1;
1414
}
1515

16-
export function renderMs(ms: number) {
16+
// Number of decimal places needed to render values spaced `step` apart without
17+
// two adjacent ticks collapsing to the same rounded label. `step` is the tick
18+
// interval expressed in the same unit the label is rendered in (µs, ms, or s).
19+
// Intervals are always 1/2/5 × 10ⁿ buckets, so this is simply the number of
20+
// fractional digits the interval requires (0.5 → 1, 0.05 → 2, 2 → 0, …).
21+
function decimalsForStep(step: number): number {
22+
if (!Number.isFinite(step) || step <= 0) {
23+
return 0;
24+
}
25+
// A small epsilon guards against float noise where e.g. log10(0.001) lands
26+
// just below an integer (−3.0000000000000004) and would floor one too far.
27+
return Math.max(0, -Math.floor(Math.log10(step) + 1e-9));
28+
}
29+
30+
/**
31+
* Formats the given millisecond duration in an appropriate unit (µs, ms, or s).
32+
*
33+
* When `step` is provided, precision is added so that (ms - step) and (ms + step)
34+
* are formatted as different strings. When `step` is not provided, the value is
35+
* is rounded to the nearest integer and formatted without decimals.
36+
*
37+
* @param ms The millisecond value to format.
38+
* @param step Optional tick interval, representing the number of milliseconds between adjacent values.
39+
* @returns the formatted string representation.
40+
*/
41+
export function renderMs(ms: number, step?: number) {
1742
if (ms < 1) {
18-
const µsRounded = Math.round(ms * 1000);
43+
const µs = ms * 1000;
44+
const decimals = step != null ? decimalsForStep(step * 1000) : 0;
45+
const factor = Math.pow(10, decimals);
1946

20-
if (µsRounded !== 1000) {
21-
return `${µsRounded}µs`;
47+
// Only render as µs when it doesn't round up to a full millisecond.
48+
if (Math.round(µs * factor) / factor !== 1000) {
49+
return `${µs.toFixed(decimals)}µs`;
2250
}
2351
}
2452

2553
if (ms < 1000) {
26-
return `${Math.round(ms)}ms`;
27-
}
28-
29-
if (ms % 1000 === 0) {
30-
return `${Math.floor(ms / 1000)}s`;
54+
const decimals = step != null ? decimalsForStep(step) : 0;
55+
return `${ms.toFixed(decimals)}ms`;
3156
}
3257

33-
return `${(ms / 1000).toFixed(3)}s`;
58+
const s = ms / 1000;
59+
const decimals =
60+
step != null ? decimalsForStep(step / 1000) : ms % 1000 === 0 ? 0 : 3;
61+
return `${s.toFixed(decimals)}s`;
3462
}
3563

3664
// Returns the smallest "human-friendly" interval (a 1/2/5 × 10ⁿ bucket) that
@@ -58,3 +86,15 @@ export function calculateInterval(value: number, maxTicks = 10) {
5886
}
5987
return 10 * magnitude;
6088
}
89+
90+
// Minimum horizontal space reserved per tick label. The widest label we render
91+
// (e.g. "1.234s") is ~45px; the extra headroom keeps adjacent labels from
92+
// touching. Tune up for a sparser axis, down for a denser one.
93+
export const MIN_TICK_PX = 56;
94+
95+
// Derives the tick interval for a timeline of range `maxVal` rendered across
96+
// `ticksWidthPx` pixels, budgeting ticks by MIN_TICK_PX so labels never overlap.
97+
export function tickIntervalForWidth(maxVal: number, ticksWidthPx: number) {
98+
const maxTicks = Math.max(1, Math.floor(ticksWidthPx / MIN_TICK_PX));
99+
return calculateInterval(maxVal, maxTicks);
100+
}

0 commit comments

Comments
 (0)