diff --git a/.changeset/axis-no-sub-thousand-abbreviation.md b/.changeset/axis-no-sub-thousand-abbreviation.md new file mode 100644 index 0000000000..1812158e5c --- /dev/null +++ b/.changeset/axis-no-sub-thousand-abbreviation.md @@ -0,0 +1,6 @@ +--- +'@hyperdx/app': patch +'@hyperdx/cli': patch +--- + +fix: Stop abbreviating values between 500 and 999 as "0.5k"/"0.8k" on chart axes and in large number format diff --git a/packages/app/src/__tests__/HDXMultiSeriesTimeChart.test.ts b/packages/app/src/__tests__/HDXMultiSeriesTimeChart.test.ts index 2ccea9c3db..9a53c84384 100644 --- a/packages/app/src/__tests__/HDXMultiSeriesTimeChart.test.ts +++ b/packages/app/src/__tests__/HDXMultiSeriesTimeChart.test.ts @@ -86,6 +86,16 @@ describe('formatAxisTick', () => { ); }); + it('does not abbreviate values between 500 and 999', () => { + // Regression: numbro's default lowPrecision rendered 750 as "0.8k" next + // to an unabbreviated "250" on the same axis. + const format = { output: 'number' as const, mantissa: 1 }; + expect( + [0, 250, 500, 750, 1000].map(v => formatAxisTick(v, format)), + ).toEqual(['0', '250', '500', '750', '1k']); + expect(formatAxisTick(800, { output: 'number' })).toBe('800'); + }); + it('always renders exactly 0 as a bare integer', () => { // 0 needs no decimal rescue - it's already unambiguous - and the search // naturally produces this by trimming "0.00"/"0.0 B" down to "0"/"0 B". @@ -320,7 +330,7 @@ describe('getNiceYAxisTicks', () => { const result = getExpandableYAxisTicks(0, 2100, 5, { output: 'number' }); expect(result.ticks.map(t => result.tickFormatter!(t))).toEqual([ '0', - '0.5k', + '500', '1k', '1.5k', '2k', diff --git a/packages/app/src/__tests__/utils.test.ts b/packages/app/src/__tests__/utils.test.ts index 864d5ba1b2..f52c61f7c0 100644 --- a/packages/app/src/__tests__/utils.test.ts +++ b/packages/app/src/__tests__/utils.test.ts @@ -508,6 +508,36 @@ describe('formatNumber', () => { }; expect(formatNumber(1234567, format)).toBe('1m'); }); + + it('only abbreviates from 1000, not from 500', () => { + const format: NumberFormat = { + output: 'number', + average: true, + mantissa: 1, + }; + expect(formatNumber(750, format)).toBe('750.0'); + expect(formatNumber(1500, format)).toBe('1.5k'); + expect(formatNumber(600000, format)).toBe('600.0k'); + }); + + it('moves to the next unit when rounding reaches 1000 of the current one', () => { + const format: NumberFormat = { output: 'number', average: true }; + expect(formatNumber(999700, format)).toBe('1m'); + expect(formatNumber(-999700, format)).toBe('-1m'); + expect(formatNumber(999.6e6, format)).toBe('1b'); + expect(formatNumber(999.6, format)).toBe('1k'); + expect(formatNumber(999960, { ...format, mantissa: 1 })).toBe('1.0m'); + expect(formatNumber(999940, { ...format, mantissa: 1 })).toBe('999.9k'); + expect(formatNumber(9.9999, { output: 'percent', average: true })).toBe( + '1k%', + ); + }); + + it('still formats byte output when average is set', () => { + expect( + formatNumber(268435456, { output: 'byte', average: true, mantissa: 1 }), + ).toBe('256.0 MB'); + }); }); describe('numericUnit with data output (byte)', () => { diff --git a/packages/app/src/utils.ts b/packages/app/src/utils.ts index b8e0e91ea3..a80e9495c3 100644 --- a/packages/app/src/utils.ts +++ b/packages/app/src/utils.ts @@ -1001,6 +1001,32 @@ const formatAutoScaleData = ( return `${scaledValue.toFixed(mantissa)} ${units[i]}${rateSuffix}`; }; +const AVERAGE_UNITS = [ + [1e3, 'thousand'], + [1e6, 'million'], + [1e9, 'billion'], + [1e12, 'trillion'], +] as const; + +// numbro's default lowPrecision abbreviates anything that rounds to the next +// unit (750 -> "0.8k"), while turning it off picks the unit before rounding +// (999,700 -> "1000k"). Pick the unit from the rounded value instead. +function averageUnitOptions( + displayed: number, + mantissa: number, +): Pick { + const abs = Math.abs(displayed); + const round = (v: number) => Math.round(v * 10 ** mantissa) / 10 ** mantissa; + let scaled = abs; + let unit: numbro.Format['forceAverage']; + for (const [power, name] of AVERAGE_UNITS) { + if (round(scaled) < 1000) break; + unit = name; + scaled = abs / power; + } + return unit ? { forceAverage: unit } : { lowPrecision: false }; +} + export const formatNumber = ( value?: string | number, options?: NumberFormat, @@ -1057,11 +1083,20 @@ export const formatNumber = ( return formatDurationMs(ms); } + // Factor is only currently available for the time output + const factor = options.output === 'time' ? (options.factor ?? 1) : 1; + const numbroFormat: numbro.Format = { output: options.output || 'number', mantissa: mantissa, thousandSeparated: options.thousandSeparated || false, average: options.average || false, + ...(options.average && + options.output !== 'byte' && + averageUnitOptions( + value * factor * (options.output === 'percent' ? 100 : 1), + mantissa, + )), ...(options.output === 'byte' && { base: options.decimalBytes ? 'decimal' : 'general', spaceSeparated: true, @@ -1072,9 +1107,6 @@ export const formatNumber = ( }), }; - // Factor is only currently available for the time output - const factor = options.output === 'time' ? (options.factor ?? 1) : 1; - return ( numbro(value * factor).format(numbroFormat) + (options.unit ? ` ${options.unit}` : '') diff --git a/packages/cli/src/__tests__/formatNumber.test.ts b/packages/cli/src/__tests__/formatNumber.test.ts index 2ffc60dfbd..a3a77749f5 100644 --- a/packages/cli/src/__tests__/formatNumber.test.ts +++ b/packages/cli/src/__tests__/formatNumber.test.ts @@ -2,7 +2,16 @@ import { describe, expect, it } from '@jest/globals'; import { NumericUnit } from '@hyperdx/common-utils/dist/types'; -import { axisTickFormatter } from '@/shared/formatNumber'; +import { axisTickFormatter, formatNumber } from '@/shared/formatNumber'; + +describe('formatNumber average', () => { + it('moves to the next unit when rounding reaches 1000 of the current one', () => { + const format = { output: 'number' as const, average: true }; + expect(formatNumber(750, format)).toBe('750'); + expect(formatNumber(999700, format)).toBe('1m'); + expect(formatNumber(999.6e6, format)).toBe('1b'); + }); +}); // Mirrors packages/app/src/__tests__/HDXMultiSeriesTimeChart.test.ts's // formatAxisTick suite - this file is a documented @source port of that @@ -18,6 +27,16 @@ describe('axisTickFormatter', () => { expect(format?.(1234)).toBe('1k'); }); + it('does not abbreviate values between 500 and 999', () => { + const format = axisTickFormatter({ output: 'number', mantissa: 1 }); + expect([250, 500, 750, 1000].map(v => format?.(v))).toEqual([ + '250', + '500', + '750', + '1k', + ]); + }); + it('honors an explicit mantissa instead of always rounding to 0', () => { const format = axisTickFormatter({ output: 'number', mantissa: 2 }); expect(format?.(0.14)).toBe('0.14'); diff --git a/packages/cli/src/shared/formatNumber.ts b/packages/cli/src/shared/formatNumber.ts index 9d51af73a8..d18a71f71f 100644 --- a/packages/cli/src/shared/formatNumber.ts +++ b/packages/cli/src/shared/formatNumber.ts @@ -165,6 +165,32 @@ const formatAutoScaleData = ( return `${scaledValue.toFixed(mantissa)} ${units[i]}${rateSuffix}`; }; +const AVERAGE_UNITS = [ + [1e3, 'thousand'], + [1e6, 'million'], + [1e9, 'billion'], + [1e12, 'trillion'], +] as const; + +// numbro's default lowPrecision abbreviates anything that rounds to the next +// unit (750 -> "0.8k"), while turning it off picks the unit before rounding +// (999,700 -> "1000k"). Pick the unit from the rounded value instead. +function averageUnitOptions( + displayed: number, + mantissa: number, +): Pick { + const abs = Math.abs(displayed); + const round = (v: number) => Math.round(v * 10 ** mantissa) / 10 ** mantissa; + let scaled = abs; + let unit: numbro.Format['forceAverage']; + for (const [power, name] of AVERAGE_UNITS) { + if (round(scaled) < 1000) break; + unit = name; + scaled = abs / power; + } + return unit ? { forceAverage: unit } : { lowPrecision: false }; +} + export const formatNumber = ( value?: string | number, options?: NumberFormat, @@ -221,11 +247,20 @@ export const formatNumber = ( return formatDurationMs(ms); } + // Factor is only currently available for the time output + const factor = options.output === 'time' ? (options.factor ?? 1) : 1; + const numbroFormat: numbro.Format = { output: options.output || 'number', mantissa: mantissa, thousandSeparated: options.thousandSeparated || false, average: options.average || false, + ...(options.average && + options.output !== 'byte' && + averageUnitOptions( + value * factor * (options.output === 'percent' ? 100 : 1), + mantissa, + )), ...(options.output === 'byte' && { base: options.decimalBytes ? 'decimal' : 'general', spaceSeparated: true, @@ -236,9 +271,6 @@ export const formatNumber = ( }), }; - // Factor is only currently available for the time output - const factor = options.output === 'time' ? (options.factor ?? 1) : 1; - return ( numbro(value * factor).format(numbroFormat) + (options.unit ? ` ${options.unit}` : '')