@esri/calcite-components
Version:
Web Components for Esri's Calcite Design System.
174 lines (173 loc) • 7.34 kB
JavaScript
/*!
* All material copyright ESRI, All Rights Reserved, unless otherwise specified.
* See https://github.com/Esri/calcite-components/blob/master/LICENSE.md for details.
* v1.5.0-next.4
*/
import { numberKeys } from "./key";
const unnecessaryDecimal = new RegExp(`\\${"."}(0+)?$`);
const trailingZeros = new RegExp("0+$");
// adopted from https://stackoverflow.com/a/66939244
export class BigDecimal {
constructor(input) {
if (input instanceof BigDecimal) {
return input;
}
const [integers, decimals] = expandExponentialNumberString(input).split(".").concat("");
this.value =
BigInt(integers + decimals.padEnd(BigDecimal.DECIMALS, "0").slice(0, BigDecimal.DECIMALS)) +
BigInt(BigDecimal.ROUNDED && decimals[BigDecimal.DECIMALS] >= "5");
this.isNegative = input.charAt(0) === "-";
}
getIntegersAndDecimals() {
const s = this.value
.toString()
.replace("-", "")
.padStart(BigDecimal.DECIMALS + 1, "0");
const integers = s.slice(0, -BigDecimal.DECIMALS);
const decimals = s.slice(-BigDecimal.DECIMALS).replace(trailingZeros, "");
return { integers, decimals };
}
toString() {
const { integers, decimals } = this.getIntegersAndDecimals();
return `${this.isNegative ? "-" : ""}${integers}${decimals.length ? "." + decimals : ""}`;
}
formatToParts(formatter) {
const { integers, decimals } = this.getIntegersAndDecimals();
const parts = formatter.numberFormatter.formatToParts(BigInt(integers));
this.isNegative && parts.unshift({ type: "minusSign", value: formatter.minusSign });
if (decimals.length) {
parts.push({ type: "decimal", value: formatter.decimal });
decimals.split("").forEach((char) => parts.push({ type: "fraction", value: char }));
}
return parts;
}
format(formatter) {
const { integers, decimals } = this.getIntegersAndDecimals();
const integersFormatted = `${this.isNegative ? formatter.minusSign : ""}${formatter.numberFormatter.format(BigInt(integers))}`;
const decimalsFormatted = decimals.length
? `${formatter.decimal}${decimals
.split("")
.map((char) => formatter.numberFormatter.format(Number(char)))
.join("")}`
: "";
return `${integersFormatted}${decimalsFormatted}`;
}
add(n) {
return BigDecimal.fromBigInt(this.value + new BigDecimal(n).value);
}
subtract(n) {
return BigDecimal.fromBigInt(this.value - new BigDecimal(n).value);
}
multiply(n) {
return BigDecimal._divRound(this.value * new BigDecimal(n).value, BigDecimal.SHIFT);
}
divide(n) {
return BigDecimal._divRound(this.value * BigDecimal.SHIFT, new BigDecimal(n).value);
}
}
// Configuration: constants
BigDecimal.DECIMALS = 100; // number of decimals on all instances
BigDecimal.ROUNDED = true; // numbers are truncated (false) or rounded (true)
BigDecimal.SHIFT = BigInt("1" + "0".repeat(BigDecimal.DECIMALS)); // derived constant
BigDecimal._divRound = (dividend, divisor) => BigDecimal.fromBigInt(dividend / divisor + (BigDecimal.ROUNDED ? ((dividend * BigInt(2)) / divisor) % BigInt(2) : BigInt(0)));
BigDecimal.fromBigInt = (bigint) => Object.assign(Object.create(BigDecimal.prototype), { value: bigint, isNegative: bigint < BigInt(0) });
export function isValidNumber(numberString) {
return !(!numberString || isNaN(Number(numberString)));
}
export function parseNumberString(numberString) {
if (!numberString || !stringContainsNumbers(numberString)) {
return "";
}
return sanitizeExponentialNumberString(numberString, (nonExpoNumString) => {
let containsDecimal = false;
const result = nonExpoNumString
.split("")
.filter((value, i) => {
if (value.match(/\./g) && !containsDecimal) {
containsDecimal = true;
return true;
}
if (value.match(/\-/g) && i === 0) {
return true;
}
return numberKeys.includes(value);
})
.reduce((string, part) => string + part);
return isValidNumber(result) ? new BigDecimal(result).toString() : "";
});
}
// regex for number sanitization
const allLeadingZerosOptionallyNegative = /^([-0])0+(?=\d)/;
const decimalOnlyAtEndOfString = /(?!^\.)\.$/;
const allHyphensExceptTheStart = /(?!^-)-/g;
const isNegativeDecimalOnlyZeros = /^-\b0\b\.?0*$/;
export const sanitizeNumberString = (numberString) => sanitizeExponentialNumberString(numberString, (nonExpoNumString) => {
const sanitizedValue = nonExpoNumString
.replace(allHyphensExceptTheStart, "")
.replace(decimalOnlyAtEndOfString, "")
.replace(allLeadingZerosOptionallyNegative, "$1");
return isValidNumber(sanitizedValue)
? isNegativeDecimalOnlyZeros.test(sanitizedValue)
? sanitizedValue
: new BigDecimal(sanitizedValue).toString()
: nonExpoNumString;
});
export function sanitizeExponentialNumberString(numberString, func) {
if (!numberString) {
return numberString;
}
const firstE = numberString.toLowerCase().indexOf("e") + 1;
if (!firstE) {
return func(numberString);
}
return numberString
.replace(/[eE]*$/g, "")
.substring(0, firstE)
.concat(numberString.slice(firstE).replace(/[eE]/g, ""))
.split(/[eE]/)
.map((section, i) => (i === 1 ? func(section.replace(/\./g, "")) : func(section)))
.join("e")
.replace(/^e/, "1e");
}
/**
* Converts an exponential notation numberString into decimal notation.
* BigInt doesn't support exponential notation, so this is required to maintain precision
*
* @param {string} numberString - pre-validated exponential or decimal number
* @returns {string} numberString in decimal notation
*/
export function expandExponentialNumberString(numberString) {
const exponentialParts = numberString.split(/[eE]/);
if (exponentialParts.length === 1) {
return numberString;
}
const number = +numberString;
if (Number.isSafeInteger(number)) {
return `${number}`;
}
const isNegative = numberString.charAt(0) === "-";
const magnitude = +exponentialParts[1];
const decimalParts = exponentialParts[0].split(".");
const integers = (isNegative ? decimalParts[0].substring(1) : decimalParts[0]) || "";
const decimals = decimalParts[1] || "";
const shiftDecimalLeft = (integers, magnitude) => {
const magnitudeDelta = Math.abs(magnitude) - integers.length;
const leftPaddedZeros = magnitudeDelta > 0 ? `${"0".repeat(magnitudeDelta)}${integers}` : integers;
const shiftedDecimal = `${leftPaddedZeros.slice(0, magnitude)}${"."}${leftPaddedZeros.slice(magnitude)}`;
return shiftedDecimal;
};
const shiftDecimalRight = (decimals, magnitude) => {
const rightPaddedZeros = magnitude > decimals.length ? `${decimals}${"0".repeat(magnitude - decimals.length)}` : decimals;
const shiftedDecimal = `${rightPaddedZeros.slice(0, magnitude)}${"."}${rightPaddedZeros.slice(magnitude)}`;
return shiftedDecimal;
};
const expandedNumberString = magnitude > 0
? `${integers}${shiftDecimalRight(decimals, magnitude)}`
: `${shiftDecimalLeft(integers, magnitude)}${decimals}`;
return `${isNegative ? "-" : ""}${expandedNumberString.charAt(0) === "." ? "0" : ""}${expandedNumberString
.replace(unnecessaryDecimal, "")
.replace(allLeadingZerosOptionallyNegative, "")}`;
}
function stringContainsNumbers(string) {
return numberKeys.some((number) => string.includes(number));
}