Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/axis-no-sub-thousand-abbreviation.md
Original file line number Diff line number Diff line change
@@ -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
12 changes: 11 additions & 1 deletion packages/app/src/__tests__/HDXMultiSeriesTimeChart.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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".
Expand Down Expand Up @@ -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',
Expand Down
30 changes: 30 additions & 0 deletions packages/app/src/__tests__/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)', () => {
Expand Down
38 changes: 35 additions & 3 deletions packages/app/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<numbro.Format, 'forceAverage' | 'lowPrecision'> {
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,
Expand Down Expand Up @@ -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,
Expand All @@ -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}` : '')
Expand Down
21 changes: 20 additions & 1 deletion packages/cli/src/__tests__/formatNumber.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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');
Expand Down
38 changes: 35 additions & 3 deletions packages/cli/src/shared/formatNumber.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<numbro.Format, 'forceAverage' | 'lowPrecision'> {
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,
Expand Down Expand Up @@ -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,
Expand All @@ -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}` : '')
Expand Down
Loading