Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Locale-sensitive numberFormat and parseNumber #167

Open
wants to merge 10 commits into
base: master
Choose a base branch
from
Open
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
111 changes: 57 additions & 54 deletions src/arithmetic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
}

/**
Expand Down
11 changes: 7 additions & 4 deletions src/xnumber.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
Expand Down
110 changes: 63 additions & 47 deletions test/arithmetic-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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')));
Expand Down
2 changes: 1 addition & 1 deletion test/xnumber-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading