-
Notifications
You must be signed in to change notification settings - Fork 77
/
Copy pathnumber.ts
265 lines (224 loc) · 9.44 KB
/
number.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
// @ts-strict-ignore
import { numberKeys } from "./key";
import { NumberStringFormat } from "./locale";
const unnecessaryDecimal = new RegExp(`\\${"."}(0+)?$`);
const trailingZeros = new RegExp("0+$");
// adopted from https://stackoverflow.com/a/66939244
export class BigDecimal {
value: bigint;
// BigInt("-0").toString() === "0" which removes the minus sign when typing numbers like -0.1
isNegative: boolean;
// Configuration: constants
static DECIMALS = 100; // number of decimals on all instances
static ROUNDED = true; // numbers are truncated (false) or rounded (true)
static SHIFT = BigInt("1" + "0".repeat(this.DECIMALS)); // derived constant
constructor(input: string | BigDecimal) {
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) === "-";
}
static _divRound = (dividend: bigint, divisor: bigint): BigDecimal =>
BigDecimal.fromBigInt(
dividend / divisor + (BigDecimal.ROUNDED ? ((dividend * BigInt(2)) / divisor) % BigInt(2) : BigInt(0)),
);
static fromBigInt = (bigint: bigint): BigDecimal =>
Object.assign(Object.create(BigDecimal.prototype), { value: bigint, isNegative: bigint < BigInt(0) });
getIntegersAndDecimals(): { integers: string; decimals: string } {
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(): string {
const { integers, decimals } = this.getIntegersAndDecimals();
return `${this.isNegative ? "-" : ""}${integers}${decimals.length ? "." + decimals : ""}`;
}
formatToParts(formatter: NumberStringFormat): Intl.NumberFormatPart[] {
const { integers, decimals } = this.getIntegersAndDecimals();
const parts = formatter.numberFormatter.formatToParts(BigInt(integers));
if (this.isNegative) {
parts.unshift({ type: "minusSign", value: formatter.minusSign });
}
if (decimals.length) {
parts.push({ type: "decimal", value: formatter.decimal });
decimals.split("").forEach((char: string) => parts.push({ type: "fraction", value: char }));
}
return parts;
}
format(formatter: NumberStringFormat): string {
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: string) => formatter.numberFormatter.format(Number(char)))
.join("")}`
: "";
return `${integersFormatted}${decimalsFormatted}`;
}
add(n: string): BigDecimal {
return BigDecimal.fromBigInt(this.value + new BigDecimal(n).value);
}
subtract(n: string): BigDecimal {
return BigDecimal.fromBigInt(this.value - new BigDecimal(n).value);
}
multiply(n: string): BigDecimal {
return BigDecimal._divRound(this.value * new BigDecimal(n).value, BigDecimal.SHIFT);
}
divide(n: string): BigDecimal {
return BigDecimal._divRound(this.value * BigDecimal.SHIFT, new BigDecimal(n).value);
}
}
export function isValidNumber(numberString: string): boolean {
return !(!numberString || isNaN(Number(numberString)));
}
export function parseNumberString(numberString?: string): string {
if (!numberString || !stringContainsNumbers(numberString)) {
return "";
}
return sanitizeExponentialNumberString(numberString, (nonExpoNumString: string): string => {
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);
})
.join("");
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*$/;
const hasTrailingDecimalZeros = /0*$/;
export const sanitizeNumberString = (numberString: string): string =>
sanitizeExponentialNumberString(numberString, (nonExpoNumString) => {
const sanitizedValue = nonExpoNumString
.replace(allHyphensExceptTheStart, "")
.replace(decimalOnlyAtEndOfString, "")
.replace(allLeadingZerosOptionallyNegative, "$1");
return isValidNumber(sanitizedValue)
? isNegativeDecimalOnlyZeros.test(sanitizedValue)
? sanitizedValue
: getBigDecimalAsString(sanitizedValue)
: nonExpoNumString;
});
export function getBigDecimalAsString(sanitizedValue: string): string {
const sanitizedValueDecimals = sanitizedValue.split(".")[1];
const value = new BigDecimal(sanitizedValue).toString();
const [bigDecimalValueInteger, bigDecimalValueDecimals] = value.split(".");
return sanitizedValueDecimals && bigDecimalValueDecimals !== sanitizedValueDecimals
? `${bigDecimalValueInteger}.${sanitizedValueDecimals}`
: value;
}
export function sanitizeExponentialNumberString(numberString: string, func: (s: string) => string): string {
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: string): string {
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: string, magnitude: number): string => {
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: string, magnitude: number): string => {
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: string): boolean {
return numberKeys.some((number) => string.includes(number));
}
/**
* Adds localized trailing decimals zero values to the number string.
* BigInt conversion to string removes the trailing decimal zero values (Ex: 1.000 is returned as 1). This method helps adding them back.
*
* @param {string} localizedValue - localized number string value
* @param {string} value - current value in the input field
* @param {NumberStringFormat} formatter - numberStringFormatter instance to localize the number value
* @returns {string} localized number string value
*/
export function addLocalizedTrailingDecimalZeros(
localizedValue: string,
value: string,
formatter: NumberStringFormat,
): string {
const decimals = value.split(".")[1];
if (decimals) {
const trailingDecimalZeros = decimals.match(hasTrailingDecimalZeros)[0];
if (
trailingDecimalZeros &&
formatter.delocalize(localizedValue).length !== value.length &&
decimals.indexOf("e") === -1
) {
const decimalSeparator = formatter.decimal;
localizedValue = !localizedValue.includes(decimalSeparator)
? `${localizedValue}${decimalSeparator}`
: localizedValue;
return localizedValue.padEnd(localizedValue.length + trailingDecimalZeros.length, formatter.localize("0"));
}
}
return localizedValue;
}