Skip to content

Commit 5253b86

Browse files
fix: stop abbreviating 500-999 as 0.5k/0.8k on chart axes (#3434)
## Summary With "Large number format" off, the time chart's Y-axis mixed abbreviated and plain labels (`0, 250, 0.5k, 0.8k, 1k`). Some labels were also wrong: 750 rendered as `0.8k`. The axis always formats ticks in compact form (deliberate since #171 and #3030, to fit the gutter). numbro's `lowPrecision` option, on by default, adds the `k` suffix to any value that *rounds* to 1000, which is everything ≥ 500. This PR changes `formatNumber` so that, whenever `average` is applied, abbreviation starts at 1000. | value | before (Decimals 1) | after | |---|---|---| | 400 | `400` | `400` | | 500 | `0.5k` | `500` | | 750 | `0.8k` | `750` | | 1000 | `1k` | `1k` | Because the fix is in `formatNumber`, it also covers heatmap axis labels, plus tooltips, number tiles, and tables when "Large number format" is on (750 used to show as `0.8k` or `1k` there). The CLI's mirrored `formatNumber` gets the same change. Byte output is excluded: it forces `average: false`, and numbro rejects `lowPrecision` without `average`. Turning `lowPrecision` off on its own makes numbro pick the unit before rounding (999,700 → `1000k`). So the unit is now chosen from the value rounded at the configured decimals and passed to numbro as `forceAverage`: 999,700 → `1m`, 999.6 → `1k`. ### Screenshots or video Screenshots to be added by the author. | Before | After | | :----- | :---- | | <img width="1457" height="766" alt="Screenshot 2026-10-21 at 2 47 49 PM" src="https://github.com/user-attachments/assets/7f95b81c-e15d-4c6b-aadb-cede5a61948f" /> | <img width="1465" height="808" alt="Screenshot 2026-10-21 at 2 47 33 PM" src="https://github.com/user-attachments/assets/018ee738-3d38-456b-aca9-31c88d5023cb" /> | ### How to reproduce **Starting point:** `yarn app:dev:local` connected to the demo server, or the Vercel preview. **On `main`:** 1. Open **Chart Explorer**, pick the demo **Logs** source, and choose **Time Series**. 2. Set the aggregation to **Average** and the value to `500 + rand() % 400`. 3. Open display settings and set Output format **Number**, Decimals **1**, with **Large number format** off. Click **Apply**. 4. The Y-axis shows ticks like `0, 250, 0.5k, 0.8k`. **On this branch:** 1. Repeat the steps above. The Y-axis shows `0, 250, 500, 750`, and `k` appears only from 1000. ### How to test on Vercel preview **Preview routes:** /chart **Steps:** 1. Select the "Logs" source and the "Time Series" chart type. 2. Set the aggregation to "Average" and the value expression to `500 + rand() % 400`, then run the query. 3. Open display settings, set Output format to "Number" and Decimals to 1, leave "Large number format" unchecked, and click "Apply". 4. Verify that no Y-axis tick label below 1000 ends in "k" (for example, "500" and "750", not "0.5k" and "0.8k"). ### References - Linear Issue: HDX-5781 - Related PRs: #3030, #3162, #3172
1 parent bc47e48 commit 5253b86

6 files changed

Lines changed: 137 additions & 8 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@hyperdx/app': patch
3+
'@hyperdx/cli': patch
4+
---
5+
6+
fix: Stop abbreviating values between 500 and 999 as "0.5k"/"0.8k" on chart axes and in large number format

‎packages/app/src/__tests__/HDXMultiSeriesTimeChart.test.ts‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,16 @@ describe('formatAxisTick', () => {
8686
);
8787
});
8888

89+
it('does not abbreviate values between 500 and 999', () => {
90+
// Regression: numbro's default lowPrecision rendered 750 as "0.8k" next
91+
// to an unabbreviated "250" on the same axis.
92+
const format = { output: 'number' as const, mantissa: 1 };
93+
expect(
94+
[0, 250, 500, 750, 1000].map(v => formatAxisTick(v, format)),
95+
).toEqual(['0', '250', '500', '750', '1k']);
96+
expect(formatAxisTick(800, { output: 'number' })).toBe('800');
97+
});
98+
8999
it('always renders exactly 0 as a bare integer', () => {
90100
// 0 needs no decimal rescue - it's already unambiguous - and the search
91101
// naturally produces this by trimming "0.00"/"0.0 B" down to "0"/"0 B".
@@ -320,7 +330,7 @@ describe('getNiceYAxisTicks', () => {
320330
const result = getExpandableYAxisTicks(0, 2100, 5, { output: 'number' });
321331
expect(result.ticks.map(t => result.tickFormatter!(t))).toEqual([
322332
'0',
323-
'0.5k',
333+
'500',
324334
'1k',
325335
'1.5k',
326336
'2k',

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

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -508,6 +508,36 @@ describe('formatNumber', () => {
508508
};
509509
expect(formatNumber(1234567, format)).toBe('1m');
510510
});
511+
512+
it('only abbreviates from 1000, not from 500', () => {
513+
const format: NumberFormat = {
514+
output: 'number',
515+
average: true,
516+
mantissa: 1,
517+
};
518+
expect(formatNumber(750, format)).toBe('750.0');
519+
expect(formatNumber(1500, format)).toBe('1.5k');
520+
expect(formatNumber(600000, format)).toBe('600.0k');
521+
});
522+
523+
it('moves to the next unit when rounding reaches 1000 of the current one', () => {
524+
const format: NumberFormat = { output: 'number', average: true };
525+
expect(formatNumber(999700, format)).toBe('1m');
526+
expect(formatNumber(-999700, format)).toBe('-1m');
527+
expect(formatNumber(999.6e6, format)).toBe('1b');
528+
expect(formatNumber(999.6, format)).toBe('1k');
529+
expect(formatNumber(999960, { ...format, mantissa: 1 })).toBe('1.0m');
530+
expect(formatNumber(999940, { ...format, mantissa: 1 })).toBe('999.9k');
531+
expect(formatNumber(9.9999, { output: 'percent', average: true })).toBe(
532+
'1k%',
533+
);
534+
});
535+
536+
it('still formats byte output when average is set', () => {
537+
expect(
538+
formatNumber(268435456, { output: 'byte', average: true, mantissa: 1 }),
539+
).toBe('256.0 MB');
540+
});
511541
});
512542

513543
describe('numericUnit with data output (byte)', () => {

‎packages/app/src/utils.ts‎

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1001,6 +1001,32 @@ const formatAutoScaleData = (
10011001
return `${scaledValue.toFixed(mantissa)} ${units[i]}${rateSuffix}`;
10021002
};
10031003

1004+
const AVERAGE_UNITS = [
1005+
[1e3, 'thousand'],
1006+
[1e6, 'million'],
1007+
[1e9, 'billion'],
1008+
[1e12, 'trillion'],
1009+
] as const;
1010+
1011+
// numbro's default lowPrecision abbreviates anything that rounds to the next
1012+
// unit (750 -> "0.8k"), while turning it off picks the unit before rounding
1013+
// (999,700 -> "1000k"). Pick the unit from the rounded value instead.
1014+
function averageUnitOptions(
1015+
displayed: number,
1016+
mantissa: number,
1017+
): Pick<numbro.Format, 'forceAverage' | 'lowPrecision'> {
1018+
const abs = Math.abs(displayed);
1019+
const round = (v: number) => Math.round(v * 10 ** mantissa) / 10 ** mantissa;
1020+
let scaled = abs;
1021+
let unit: numbro.Format['forceAverage'];
1022+
for (const [power, name] of AVERAGE_UNITS) {
1023+
if (round(scaled) < 1000) break;
1024+
unit = name;
1025+
scaled = abs / power;
1026+
}
1027+
return unit ? { forceAverage: unit } : { lowPrecision: false };
1028+
}
1029+
10041030
export const formatNumber = (
10051031
value?: string | number,
10061032
options?: NumberFormat,
@@ -1057,11 +1083,20 @@ export const formatNumber = (
10571083
return formatDurationMs(ms);
10581084
}
10591085

1086+
// Factor is only currently available for the time output
1087+
const factor = options.output === 'time' ? (options.factor ?? 1) : 1;
1088+
10601089
const numbroFormat: numbro.Format = {
10611090
output: options.output || 'number',
10621091
mantissa: mantissa,
10631092
thousandSeparated: options.thousandSeparated || false,
10641093
average: options.average || false,
1094+
...(options.average &&
1095+
options.output !== 'byte' &&
1096+
averageUnitOptions(
1097+
value * factor * (options.output === 'percent' ? 100 : 1),
1098+
mantissa,
1099+
)),
10651100
...(options.output === 'byte' && {
10661101
base: options.decimalBytes ? 'decimal' : 'general',
10671102
spaceSeparated: true,
@@ -1072,9 +1107,6 @@ export const formatNumber = (
10721107
}),
10731108
};
10741109

1075-
// Factor is only currently available for the time output
1076-
const factor = options.output === 'time' ? (options.factor ?? 1) : 1;
1077-
10781110
return (
10791111
numbro(value * factor).format(numbroFormat) +
10801112
(options.unit ? ` ${options.unit}` : '')

‎packages/cli/src/__tests__/formatNumber.test.ts‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,16 @@ import { describe, expect, it } from '@jest/globals';
22

33
import { NumericUnit } from '@hyperdx/common-utils/dist/types';
44

5-
import { axisTickFormatter } from '@/shared/formatNumber';
5+
import { axisTickFormatter, formatNumber } from '@/shared/formatNumber';
6+
7+
describe('formatNumber average', () => {
8+
it('moves to the next unit when rounding reaches 1000 of the current one', () => {
9+
const format = { output: 'number' as const, average: true };
10+
expect(formatNumber(750, format)).toBe('750');
11+
expect(formatNumber(999700, format)).toBe('1m');
12+
expect(formatNumber(999.6e6, format)).toBe('1b');
13+
});
14+
});
615

716
// Mirrors packages/app/src/__tests__/HDXMultiSeriesTimeChart.test.ts's
817
// formatAxisTick suite - this file is a documented @source port of that
@@ -18,6 +27,16 @@ describe('axisTickFormatter', () => {
1827
expect(format?.(1234)).toBe('1k');
1928
});
2029

30+
it('does not abbreviate values between 500 and 999', () => {
31+
const format = axisTickFormatter({ output: 'number', mantissa: 1 });
32+
expect([250, 500, 750, 1000].map(v => format?.(v))).toEqual([
33+
'250',
34+
'500',
35+
'750',
36+
'1k',
37+
]);
38+
});
39+
2140
it('honors an explicit mantissa instead of always rounding to 0', () => {
2241
const format = axisTickFormatter({ output: 'number', mantissa: 2 });
2342
expect(format?.(0.14)).toBe('0.14');

‎packages/cli/src/shared/formatNumber.ts‎

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,32 @@ const formatAutoScaleData = (
165165
return `${scaledValue.toFixed(mantissa)} ${units[i]}${rateSuffix}`;
166166
};
167167

168+
const AVERAGE_UNITS = [
169+
[1e3, 'thousand'],
170+
[1e6, 'million'],
171+
[1e9, 'billion'],
172+
[1e12, 'trillion'],
173+
] as const;
174+
175+
// numbro's default lowPrecision abbreviates anything that rounds to the next
176+
// unit (750 -> "0.8k"), while turning it off picks the unit before rounding
177+
// (999,700 -> "1000k"). Pick the unit from the rounded value instead.
178+
function averageUnitOptions(
179+
displayed: number,
180+
mantissa: number,
181+
): Pick<numbro.Format, 'forceAverage' | 'lowPrecision'> {
182+
const abs = Math.abs(displayed);
183+
const round = (v: number) => Math.round(v * 10 ** mantissa) / 10 ** mantissa;
184+
let scaled = abs;
185+
let unit: numbro.Format['forceAverage'];
186+
for (const [power, name] of AVERAGE_UNITS) {
187+
if (round(scaled) < 1000) break;
188+
unit = name;
189+
scaled = abs / power;
190+
}
191+
return unit ? { forceAverage: unit } : { lowPrecision: false };
192+
}
193+
168194
export const formatNumber = (
169195
value?: string | number,
170196
options?: NumberFormat,
@@ -221,11 +247,20 @@ export const formatNumber = (
221247
return formatDurationMs(ms);
222248
}
223249

250+
// Factor is only currently available for the time output
251+
const factor = options.output === 'time' ? (options.factor ?? 1) : 1;
252+
224253
const numbroFormat: numbro.Format = {
225254
output: options.output || 'number',
226255
mantissa: mantissa,
227256
thousandSeparated: options.thousandSeparated || false,
228257
average: options.average || false,
258+
...(options.average &&
259+
options.output !== 'byte' &&
260+
averageUnitOptions(
261+
value * factor * (options.output === 'percent' ? 100 : 1),
262+
mantissa,
263+
)),
229264
...(options.output === 'byte' && {
230265
base: options.decimalBytes ? 'decimal' : 'general',
231266
spaceSeparated: true,
@@ -236,9 +271,6 @@ export const formatNumber = (
236271
}),
237272
};
238273

239-
// Factor is only currently available for the time output
240-
const factor = options.output === 'time' ? (options.factor ?? 1) : 1;
241-
242274
return (
243275
numbro(value * factor).format(numbroFormat) +
244276
(options.unit ? ` ${options.unit}` : '')

0 commit comments

Comments
 (0)