diff --git a/.changeset/duration-axis-tick-width.md b/.changeset/duration-axis-tick-width.md new file mode 100644 index 0000000000..4b3192989f --- /dev/null +++ b/.changeset/duration-axis-tick-width.md @@ -0,0 +1,7 @@ +--- +'@hyperdx/app': patch +--- + +fix: keep duration axis-tick labels within the chart's width budget + +Duration-formatted Y-axis labels (e.g. "13.33min") could render wider than the axis area allows and get clipped. They now use the same compact formatter already used by the heatmap chart's axis. diff --git a/packages/app/src/HDXMultiSeriesTimeChart.tsx b/packages/app/src/HDXMultiSeriesTimeChart.tsx index 1cab711763..2e5c6cb004 100644 --- a/packages/app/src/HDXMultiSeriesTimeChart.tsx +++ b/packages/app/src/HDXMultiSeriesTimeChart.tsx @@ -32,7 +32,12 @@ import { DisplayType } from '@hyperdx/common-utils/dist/types'; import { Popover } from '@mantine/core'; import type { NumberFormat } from '@/types'; -import { COLORS, formatNumber, truncateMiddle } from '@/utils'; +import { + COLORS, + formatDurationMsCompact, + formatNumber, + truncateMiddle, +} from '@/utils'; import { AnnotationHitLayer, @@ -838,6 +843,14 @@ export function formatAxisTick( }).format(value); } + // formatNumber returns early for 'duration', before the mantissa/width + // safety below ever runs, and formatDurationMs has no width budget of its + // own - use the compact formatter instead, as DBHeatmapChart's axis does. + if (axisNumberFormat.output === 'duration') { + const factor = axisNumberFormat.factor ?? 1; + return formatDurationMsCompact(value * factor * 1000); + } + const displayed = axisNumberFormat.output === 'percent' ? value * 100 : value; return formatNumber(value, { diff --git a/packages/app/src/__tests__/HDXMultiSeriesTimeChart.test.ts b/packages/app/src/__tests__/HDXMultiSeriesTimeChart.test.ts index af96261cda..2f8ac5df38 100644 --- a/packages/app/src/__tests__/HDXMultiSeriesTimeChart.test.ts +++ b/packages/app/src/__tests__/HDXMultiSeriesTimeChart.test.ts @@ -134,6 +134,30 @@ describe('formatAxisTick', () => { it('uses compact Intl formatting when no axisNumberFormat is set', () => { expect(formatAxisTick(1234)).toBe('1.2K'); }); + + describe('duration output', () => { + // Regression: 'duration' bypasses this formatter's width safety entirely + // (formatNumber returns early for it), and formatDurationMs has no width + // budget of its own - e.g. "13.33min" is 8 characters. + it('renders minutes with a single-character unit, not "min"', () => { + expect(formatAxisTick(799.8, { output: 'duration' })).toBe('13m'); + }); + + it('renders seconds compactly', () => { + expect(formatAxisTick(39.51, { output: 'duration' })).toBe('39.5s'); + }); + + it('respects a configured factor, matching formatNumber`s own duration math', () => { + expect( + formatAxisTick(442_800, { output: 'duration', factor: 0.001 }), + ).toBe('7.4m'); + }); + + it('renders sub-millisecond and hour-scale values compactly too', () => { + expect(formatAxisTick(0.0000005, { output: 'duration' })).toBe('500ns'); + expect(formatAxisTick(7500, { output: 'duration' })).toBe('2.1h'); + }); + }); }); describe('collectMemoChartGradientHexes', () => {