|
| 1 | +import { |
| 2 | + getNumberFormatPreference, |
| 3 | + INumberFormat, |
| 4 | +} from '../models/formatting-preferences' |
| 5 | +import { round } from '../ui/lib/round' |
| 6 | +import { enableFormattingPreferences } from './feature-flag' |
| 7 | + |
| 8 | +/** |
| 9 | + * Format a number using the given separator configuration. |
| 10 | + * |
| 11 | + * This is a simple formatter that handles integer and decimal parts with |
| 12 | + * configurable separators. It does not use Intl.NumberFormat. |
| 13 | + * |
| 14 | + * @param value - The number to format |
| 15 | + * @param fmt - The number format configuration with thousands and decimal |
| 16 | + * separators, defaults to the user's preferred format. |
| 17 | + */ |
| 18 | +export function formatNumber(value: number, fmt?: INumberFormat): string { |
| 19 | + if (!fmt && !enableFormattingPreferences()) { |
| 20 | + return value.toString() |
| 21 | + } |
| 22 | + |
| 23 | + fmt ??= getNumberFormatPreference() |
| 24 | + |
| 25 | + if (!Number.isFinite(value)) { |
| 26 | + return String(value) |
| 27 | + } |
| 28 | + |
| 29 | + const isNegative = value < 0 |
| 30 | + const abs = Math.abs(value) |
| 31 | + const [intPart, decPart] = abs.toString().split('.') |
| 32 | + |
| 33 | + // Insert a placeholder character for thousands groupings, then replace with |
| 34 | + // the configured separator. The regex matches positions that are followed by |
| 35 | + // groups of exactly 3 digits. |
| 36 | + const grouped = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, '\x00') |
| 37 | + const formattedInt = grouped.replace(/\x00/g, fmt.thousandsSeparator) |
| 38 | + |
| 39 | + const result = |
| 40 | + decPart !== undefined |
| 41 | + ? `${formattedInt}${fmt.decimalSeparator}${decPart}` |
| 42 | + : formattedInt |
| 43 | + |
| 44 | + return isNegative ? `-${result}` : result |
| 45 | +} |
| 46 | + |
| 47 | +interface ICompactFormatOptions { |
| 48 | + /** Number of decimal places to display */ |
| 49 | + readonly decimals?: number |
| 50 | + /** |
| 51 | + * The base to use for unit scaling. |
| 52 | + * - 1000: SI/decimal units (k, m, b, t or KB, MB, GB) |
| 53 | + * - 1024: IEC/binary units (KiB, MiB, GiB) |
| 54 | + */ |
| 55 | + readonly base?: 1000 | 1024 |
| 56 | + /** |
| 57 | + * Custom unit suffixes to use. If not provided, defaults to: |
| 58 | + * - For base 1000: ['', 'k', 'm', 'b', 't'] |
| 59 | + * - For base 1024: no default (must be provided) |
| 60 | + */ |
| 61 | + readonly units?: ReadonlyArray<string> |
| 62 | + /** |
| 63 | + * Whether to add a space between the number and the unit suffix. |
| 64 | + * Defaults to false for the shorthand k/m/b/t units. |
| 65 | + */ |
| 66 | + readonly unitSeparator?: string |
| 67 | + |
| 68 | + readonly numberFormat?: INumberFormat |
| 69 | +} |
| 70 | + |
| 71 | +const defaultDecimalUnits = ['', 'k', 'm', 'b', 't'] |
| 72 | + |
| 73 | +export function formatCompactNumber( |
| 74 | + value: number, |
| 75 | + fmt?: ICompactFormatOptions |
| 76 | +): string { |
| 77 | + if (!fmt && !enableFormattingPreferences()) { |
| 78 | + return `${value}` |
| 79 | + } |
| 80 | + |
| 81 | + if (!Number.isFinite(value)) { |
| 82 | + return `${value}` |
| 83 | + } |
| 84 | + |
| 85 | + const abs = Math.abs(value) |
| 86 | + const base = fmt?.base ?? 1000 |
| 87 | + const units = fmt?.units ?? defaultDecimalUnits |
| 88 | + const unitSeparator = fmt?.unitSeparator ?? '' |
| 89 | + |
| 90 | + if (abs < base) { |
| 91 | + const result = formatNumber(value, fmt?.numberFormat) |
| 92 | + // For byte formatting, always show units even for small values |
| 93 | + return units[0] ? `${result}${unitSeparator}${units[0]}` : result |
| 94 | + } |
| 95 | + |
| 96 | + const unitIx = Math.min( |
| 97 | + units.length - 1, |
| 98 | + Math.floor(Math.log(abs) / Math.log(base)) |
| 99 | + ) |
| 100 | + |
| 101 | + const scaled = value / Math.pow(base, unitIx) |
| 102 | + |
| 103 | + // If the user didn't provide an explicit number of decimals to use, we'll |
| 104 | + // default to 1 decimal for numbers less than 10 and no decimals for numbers |
| 105 | + // 10 or greater. This is a common convention for compact number formatting |
| 106 | + // that balances precision with brevity. |
| 107 | + const decimals = fmt?.decimals ?? (Math.abs(scaled) < 10 ? 1 : 0) |
| 108 | + |
| 109 | + const result = round(scaled, decimals) |
| 110 | + return `${formatNumber(result, fmt?.numberFormat)}${unitSeparator}${ |
| 111 | + units[unitIx] |
| 112 | + }` |
| 113 | +} |
0 commit comments