diff --git a/src/arithmetic.ts b/src/arithmetic.ts index a47df92..ae9e9fa 100644 --- a/src/arithmetic.ts +++ b/src/arithmetic.ts @@ -36,46 +36,62 @@ export function sign(value: number, t = PRECISION) { // ----------------------------------------------------------------------------- // String Conversion -const NUM_REGEX = /(\d+)(\d{3})/; -const POWER_SUFFIX = ['', 'k', 'm', 'b', 't', 'q']; - -function addThousandSeparators(x: string) { - let [n, dec] = x.split('.'); - while (NUM_REGEX.test(n)) { - n = n.replace(NUM_REGEX, '$1,$2'); - } - return n + (dec ? `.${dec}` : ''); +/** Get total number of digits (numeric chars) */ +function getRawDigitsCount(of: number) { + const digit = /\d/g; + return of.toString().match(digit)?.length ?? 0; } -function addPowerSuffix(n: number, places = 6) { - if (!places) return `${n}`; - - // Trim short numbers to the appropriate number of decimal places. - const digits = (`${Math.abs(Math.floor(n))}`).length; - const chars = digits + (n < 0 ? 1 : 0); - if (chars <= places) return `${round(n, places - chars)}`; +function getIntegerDigitsCount(of: number) { + return getRawDigitsCount(Math.trunc(of)); +} - // Append a power suffix to longer numbers. - const x = Math.floor(Math.log10(Math.abs(n)) / 3); - const suffix = POWER_SUFFIX[x]; - const decimalPlaces = places - ((digits % 3) || 3) - (suffix ? 1 : 0) - (n < 0 ? 1 : 0); - return round(n / Math.pow(10, 3 * x), decimalPlaces) + suffix; +/** Get total number of zeroes */ +function getZeroesCount(of: number) { + const digit = /0/g; + return of.toString().match(digit)?.length ?? 0; } /** - * Converts a number to a clean string, by rounding, adding power suffixes, and - * adding thousands separators. `places` is the number of digits to show in the - * result. + * Converts a number to a clean string, by rounding, adding abbreviation suffixes, and + * adding grouping separators. `digits` is the number of numeric characters to + * show in the result, for example if `digits` is `3`, then for `n` = `10.12` the result + * will be `"10.1"`. + * Note: leading zeros are not counted towards how many digits to include; this means that + * if `digits` is `3`, then for `n` = `0.0123` the result will be `"0.0123"` + * Note: does not work for numbers > 10^21 or < 10^-6 */ -export function numberFormat(n: number, places = 0, separators = true) { - const str = addPowerSuffix(n, places).replace('-', '–'); - return separators ? addThousandSeparators(str) : str; +export function numberFormat( + n: number, + digits: number | 'auto' = 'auto', + separators: boolean | 'auto' = 'auto', + locale = 'en', + otherFormatterOptions?: Intl.NumberFormatOptions +) { + const formatter = new Intl.NumberFormat(locale, { + useGrouping: separators === 'auto' ? undefined : separators, + maximumSignificantDigits: digits === 'auto' ? undefined : digits, + // If the display digits count is less than the integer digits count then we want to use an abbreviated format. + // For example: given `n = 12343.2` and `digits = 4`, we would like a result of `'12.34K'` rather than `'12,340'`. + notation: digits !== 'auto' && digits < getIntegerDigitsCount(n) ? 'compact' : 'standard', + ...otherFormatterOptions + }); + if (locale === 'en') { + return formatter.format(n).replace('-', '–').toLowerCase(); + } else { + return formatter.format(n).replace('-', '–'); + } } export function scientificFormat(value: number, places = 6) { const abs = Math.abs(value); if (isBetween(abs, Math.pow(10, -places), Math.pow(10, places))) { - return numberFormat(value, places); + if (abs >= 1) { + return numberFormat(value, places); + } else { + const digitsDelta = places - getZeroesCount(value); + return numberFormat(value, digitsDelta > 0 ? digitsDelta : places); + } } // TODO Decide how we want to handle these special cases @@ -88,38 +104,25 @@ export function scientificFormat(value: number, places = 6) { return `${str.slice(0, 5)} × 10^${(isNegative ? '(' : '') + top + (isNegative ? ')' : '')}`; } -// Numbers like 0,123 are decimals, even though they match POINT_DECIMAL. -const SPECIAL_DECIMAL = /^-?0,[0-9]+$/; - -// Points as decimal points, Commas as 1k separators, allow starting . -const POINT_DECIMAL = /^-?([0-9]+(,[0-9]{3})*)?\.?[0-9]*$/; - -// Commas as decimal points, Points as 1k separators, don't allow starting , -const COMMA_DECIMAL = /^-?[0-9]+(\.[0-9]{3})*,?[0-9]*$/; - /** * Converts a number to a string, including . or , decimal points and * thousands separators. * @param {string} str * @returns {number} */ -export function parseNumber(str: string) { - str = str.replace(/^–/, '-').trim(); - if (!str || str.match(/[^0-9.,-]/)) return NaN; - - if (SPECIAL_DECIMAL.test(str)) { - return parseFloat(str.replace(/,/, '.')); - } - - if (POINT_DECIMAL.test(str)) { - return parseFloat(str.replace(/,/g, '')); - } - - if (COMMA_DECIMAL.test(str)) { - return parseFloat(str.replace(/\./g, '').replace(/,/, '.')); - } - - return NaN; +export function parseNumber(str: string, locale = 'en') { + // https://observablehq.com/@mbostock/localized-number-parsing + const parts = (new Intl.NumberFormat(locale)).formatToParts(11111111111.111111); + /* eslint-disable @typescript-eslint/no-non-null-assertion */ + const decimal = parts.find(p => p.type === 'decimal')!.value; + const group = parts.find(p => p.type === 'group')!.value; + /* eslint-enable @typescript-eslint/no-non-null-assertion */ + const neutral = + str + .replace('–', '-') + .replace(new RegExp(`\\${group}`, 'g'), '') + .replace(decimal, '.'); + return +neutral; } /** diff --git a/src/xnumber.ts b/src/xnumber.ts index 73beefb..f07e343 100755 --- a/src/xnumber.ts +++ b/src/xnumber.ts @@ -61,11 +61,14 @@ export class XNumber { } } - toString(precision = 4) { - const separators = !this.den && !this.unit; - let num = numberFormat(this.num, this.den ? 0 : precision, separators); + toString(digits: number | 'auto' = 4, locale = 'en') { + // If this is a fraction or has a unit then we do not want separators; otherwise we go with the locale default + const separators = (this.den || this.unit) ? false : 'auto'; + // If this is a fraction then we do not accept a manually specified value for the length of the numerator + const actualDigits = this.den ? 'auto' : digits; + let num = numberFormat(this.num, actualDigits, separators, locale); let unit = this.unit || ''; - const den = this.den ? `/${numberFormat(this.den, 0, separators)}` : ''; + const den = this.den ? `/${numberFormat(this.den, 'auto', false)}` : ''; if (num === '0') unit = ''; if (unit === 'π' && !this.den && (num === '1' || num === '–1')) num = num.replace('1', ''); return `${num}${den}${unit}`; diff --git a/test/arithmetic-test.ts b/test/arithmetic-test.ts index ed1f75f..aae3167 100644 --- a/test/arithmetic-test.ts +++ b/test/arithmetic-test.ts @@ -9,40 +9,44 @@ import {numberFormat, parseNumber, scientificFormat, toWord} from '../src'; tape('numberFormat', (test) => { - test.equal(numberFormat(1234, 5), '1,234', ':: numberFormat(1234, 5)'); - test.equal(numberFormat(1234, 4), '1,234', ':: numberFormat(1234, 4)'); - test.equal(numberFormat(1234, 3), '1.2k', ':: numberFormat(1234, 3)'); - test.equal(numberFormat(1000, 3), '1k', ':: numberFormat(1000, 3)'); - test.equal(numberFormat(-1234, 6), '–1,234', ':: numberFormat(-1234, 6)'); - test.equal(numberFormat(-1234, 5), '–1,234', ':: numberFormat(-1234, 5)'); - test.equal(numberFormat(-1234, 4), '–1.2k', ':: numberFormat(-1234, 4)'); - test.equal(numberFormat(-1000, 4), '–1k', ':: numberFormat(-1000, 4)'); - - test.equal(numberFormat(10001, 5), '10,001', ':: numberFormat(10001, 5)'); - test.equal(numberFormat(10001, 4), '10k', ':: numberFormat(10001, 4)'); - test.equal(numberFormat(-10001, 6), '–10,001', ':: numberFormat(-10001, 6)'); - test.equal(numberFormat(-10001, 5), '–10k', ':: numberFormat(-10001, 5)'); - test.equal(numberFormat(100001, 6), '100,001', ':: numberFormat(100001, 6)'); - test.equal(numberFormat(100001, 5), '100k', ':: numberFormat(100001, 5)'); - test.equal(numberFormat(-100001, 7), '–100,001', ':: numberFormat(-100001, 7)'); - test.equal(numberFormat(-100001, 6), '–100k', ':: numberFormat(-100001, 6)'); - test.equal(numberFormat(1000001, 7), '1,000,001', ':: numberFormat(1000001, 7)'); - test.equal(numberFormat(1000001, 6), '1m', ':: numberFormat(1000001, 6)'); - test.equal(numberFormat(-1000001, 8), '–1,000,001', ':: numberFormat(-1000001, 8)'); - test.equal(numberFormat(-1000001, 7), '–1m', ':: numberFormat(-1000001, 7)'); - - test.equal(numberFormat(0.1, 2), '0.1', ':: numberFormat(0.1, 2)'); - test.equal(numberFormat(0.1, 1), '0', ':: numberFormat(0.1, 1)'); - test.equal(numberFormat(-0.1, 3), '–0.1', ':: numberFormat(-0.1, 3)'); - test.equal(numberFormat(-0.1, 2), '0', ':: numberFormat(-0.1, 2)'); - test.equal(numberFormat(0.01, 3), '0.01', ':: numberFormat(0.01, 3)'); - test.equal(numberFormat(0.01, 2), '0', ':: numberFormat(0.01, 2)'); - test.equal(numberFormat(-0.01, 4), '–0.01', ':: numberFormat(-0.01, 4)'); - test.equal(numberFormat(-0.01, 3), '0', ':: numberFormat(-0.01, 3)'); - test.equal(numberFormat(0.001, 4), '0.001', ':: numberFormat(0.001, 4)'); - test.equal(numberFormat(0.001, 3), '0', ':: numberFormat(0.001, 3)'); - test.equal(numberFormat(-0.001, 5), '–0.001', ':: numberFormat(-0.001, 5)'); - test.equal(numberFormat(-0.001, 4), '0', ':: numberFormat(-0.001, 4)'); + test.equal(numberFormat(1234, 5, true), '1,234'); + test.equal(numberFormat(1234, 4, true), '1,234'); + test.equal(numberFormat(1234, 3, true), '1.23k'); + test.equal(numberFormat(12345.6, 3, true), '12.3k'); + test.equal(numberFormat(12345.6, 4, true), '12.35k'); + test.equal(numberFormat(-1234, 6, true), '–1,234'); + test.equal(numberFormat(-1234, 4, true), '–1,234'); + test.equal(numberFormat(-1234, 3, true), '–1.23k'); + test.equal(numberFormat(-1000, 3, true), '–1k'); + + test.equal(numberFormat(10001, 5, true), '10,001'); + test.equal(numberFormat(10001, 4, true), '10k'); + test.equal(numberFormat(-10001, 5, true), '–10,001'); + test.equal(numberFormat(-10001, 4, true), '–10k'); + test.equal(numberFormat(100001, 6, true), '100,001'); + test.equal(numberFormat(100001, 5, true), '100k'); + test.equal(numberFormat(-100001, 6, true), '–100,001'); + test.equal(numberFormat(-100001, 5, true), '–100k'); + test.equal(numberFormat(1000001, 7, true), '1,000,001'); + test.equal(numberFormat(1000001, 6, true), '1m'); + test.equal(numberFormat(-1000001, 7, true), '–1,000,001'); + test.equal(numberFormat(-1000001, 6, true), '–1m'); + + test.equal(numberFormat(0.11, 2, true), '0.11', ':: numberFormat(0.11, 2, true)'); + test.equal(numberFormat(0.11, 1, true), '0.1', ':: numberFormat(0.11, 1, true)'); + test.equal(numberFormat(-0.11, 2, true), '–0.11', ':: numberFormat(-0.11, 2, true)'); + test.equal(numberFormat(-0.11, 1, true), '–0.1', ':: numberFormat(-0.11, 1, true)'); + test.equal(numberFormat(0.0111, 3, true), '0.0111', ':: numberFormat(0.0111, 3, true)'); + test.equal(numberFormat(0.011, 2, true), '0.011'), ':: numberFormat(0.011, 2, true)'; + test.equal(numberFormat(-0.011, 2, true), '–0.011', ':: numberFormat(-0.011, 2, true)'); + test.equal(numberFormat(-0.011, 1, true), '–0.01', ':: numberFormat(-0.01, 1, true)'); + test.equal(numberFormat(0.0011, 2, true), '0.0011', ':: numberFormat(0.0011, 2, true)'); + test.equal(numberFormat(0.0011, 1, true), '0.001', ':: numberFormat(0.001, 1, true)'); + test.equal(numberFormat(-0.0011, 2, true), '–0.0011', ':: numberFormat(-0.0011, 2, true)'); + test.equal(numberFormat(-0.0011, 1, true), '–0.001', ':: numberFormat(-0.0011, 1, true)'); + test.equal(numberFormat(1000.11, 8, 'auto', 'de'), '1.000,11', `:: numberFormat(1000.11, 8, true, 'de')`); + test.equal(numberFormat(1000.11, 8, 'auto', 'es'), '1000,11', `:: numberFormat(1000.11, 8, true, 'es')`); + test.equal(numberFormat(10000.11, 8, 'auto', 'es'), '10.000,11', `:: numberFormat(10000.11, 8, true, 'es')`); test.equal(scientificFormat(123123123, 6), '1.231 × 10^8'); test.equal(scientificFormat(123123, 6), '123,123'); @@ -60,29 +64,41 @@ tape('numberFormat', (test) => { tape('parseNumber', (test) => { test.equal(parseNumber('1234'), 1234); - test.equal(parseNumber('1.234,56'), 1234.56); + test.equal(parseNumber('1.234,56'), 1.23456); + test.equal(parseNumber('1,23456', 'de'), 1.23456); + test.equal(parseNumber('1,23456', 'es'), 1.23456); test.equal(parseNumber('1,234.56'), 1234.56); + test.equal(parseNumber('1.234,56', 'de'), 1234.56); + test.equal(parseNumber('1.234,56', 'es'), 1234.56); test.equal(parseNumber('1,234,567'), 1234567); - test.equal(parseNumber('1.234.567'), 1234567); + test.equal(parseNumber('1.234.567', 'de'), 1234567); + test.equal(parseNumber('1.234.567', 'es'), 1234567); test.equal(parseNumber('1,234.567'), 1234.567); - test.equal(parseNumber('1.234,567'), 1234.567); - test.equal(parseNumber('1.234'), 1.234); // ambiguous! - test.equal(parseNumber('1,234'), 1234); // ambiguous! - test.equal(parseNumber('0,123'), 0.123); // ambiguous! - test.equal(parseNumber('1,23'), 1.23); + test.equal(parseNumber('1.234,567', 'de'), 1234.567); + test.equal(parseNumber('1.234,567', 'es'), 1234.567); + test.equal(parseNumber('1.234'), 1.234); + test.equal(parseNumber('1,234', 'de'), 1.234); + test.equal(parseNumber('1,234', 'es'), 1.234); + test.equal(parseNumber('1,234'), 1234); + test.equal(parseNumber('1.234', 'de'), 1234); + test.equal(parseNumber('1.234', 'es'), 1234); + test.equal(parseNumber('0.123'), 0.123); + test.equal(parseNumber('0,123', 'de'), 0.123); + test.equal(parseNumber('0,123', 'es'), 0.123); + test.equal(parseNumber('1.23'), 1.23); + test.equal(parseNumber('1,23'), 123); + test.equal(parseNumber('1,23', 'de'), 1.23); + test.equal(parseNumber('1.23', 'de'), 123); test.equal(parseNumber('1.2345'), 1.2345); test.equal(parseNumber('.123'), 0.123); test.equal(parseNumber('-123'), -123); test.equal(parseNumber('–123'), -123); - test.equal(parseNumber('-123,456'), -123456); // ambiguous! - test.equal(parseNumber('-123.456'), -123.456); // ambiguous! - - test.ok(isNaN(parseNumber('1,2345,678'))); + test.notOk(isNaN(parseNumber('1,2345,678'))); test.ok(isNaN(parseNumber('1.2345.678'))); - test.ok(isNaN(parseNumber('1,2345.678'))); - test.ok(isNaN(parseNumber('1.2345,678'))); + test.notOk(isNaN(parseNumber('1,2345.678'))); + test.notOk(isNaN(parseNumber('1.2345,678'))); test.ok(isNaN(parseNumber('1.234,56A'))); test.ok(isNaN(parseNumber('123A456'))); diff --git a/test/xnumber-test.ts b/test/xnumber-test.ts index 96ef451..3fa5efc 100644 --- a/test/xnumber-test.ts +++ b/test/xnumber-test.ts @@ -134,7 +134,7 @@ tape('Scientific notation', (test) => { test.deepEqual(expr(0.000002, 'scientific'), '2 × 10^(-6)'); test.deepEqual(expr(0.00012, 'scientific'), '1.2 × 10^(-4)'); test.deepEqual(expr(1234.3, 'scientific'), '"1,234"'); - test.deepEqual(expr(12343.2, 'decimal'), '"12.3k"'); + test.deepEqual(expr(12343.2, 'decimal'), '"12.34k"'); test.deepEqual(expr(12343.2, 'scientific'), '1.234 × 10^4'); test.deepEqual(expr(123432.1, 'scientific'), '1.234 × 10^5'); test.end();