Gets the name of a number, even if it's stupidly big. Supersedes TheoryOfNekomata/number-name.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

265 lines
8.0 KiB

  1. /**
  2. * Valid values that can be converted to exponential notation.
  3. */
  4. export type ValidValue = string | number | bigint;
  5. interface BaseOptions {
  6. /**
  7. * The decimal point character to use. Defaults to ".".
  8. */
  9. decimalPoint?: string;
  10. /**
  11. * The grouping symbol to use. Defaults to ",".
  12. */
  13. groupingSymbol?: string;
  14. /**
  15. * The exponent character to use. Defaults to "e".
  16. */
  17. exponentDelimiter?: string;
  18. }
  19. /**
  20. * Options to use when converting a number to exponential notation.
  21. */
  22. export type NumberToExponentialOptions = BaseOptions;
  23. /**
  24. * Options to use when converting exponential notation to a number string.
  25. */
  26. export interface ExponentialToNumberStringOptions extends BaseOptions {
  27. /**
  28. * The maximum length to represent the return value else the input will be preserved.
  29. */
  30. maxLength?: number;
  31. }
  32. /**
  33. * Default decimal point character.
  34. */
  35. const DEFAULT_DECIMAL_POINT = '.' as const;
  36. /**
  37. * Default grouping symbol.
  38. */
  39. const DEFAULT_GROUPING_SYMBOL = ',' as const;
  40. /**
  41. * Default exponent character.
  42. */
  43. const DEFAULT_EXPONENT_DELIMITER = 'e' as const;
  44. /**
  45. * Default positive symbol.
  46. */
  47. const DEFAULT_POSITIVE_SYMBOL = '+' as const;
  48. /**
  49. * Default negative symbol.
  50. */
  51. const DEFAULT_NEGATIVE_SYMBOL = '-' as const;
  52. /**
  53. * Forces a value to have a decimal point.
  54. * @param value - The value to force a decimal point on.
  55. * @param decimalPoint - The decimal point character to use.
  56. * @returns string The value with a decimal point.
  57. */
  58. const forceDecimalPoint = (value: string, decimalPoint: string) => (
  59. value.includes(decimalPoint)
  60. ? value
  61. : `${value}${decimalPoint}0`
  62. );
  63. /**
  64. * Error thrown when a value is not in exponential notation.
  65. */
  66. export class InvalidFormatError extends TypeError {
  67. constructor(value: string) {
  68. super(`Value must be in exponential notation. Received: ${value}`);
  69. }
  70. }
  71. /**
  72. * Error thrown when a value is not a string, number, or bigint.
  73. */
  74. export class InvalidValueTypeError extends TypeError {
  75. constructor(value: unknown) {
  76. super(`Value must be a string, number, or bigint. Received: ${typeof value}`);
  77. }
  78. }
  79. /**
  80. * Forces a number to have a sign.
  81. * @param value - The value to force a sign on.
  82. * @returns string The value with a sign.
  83. */
  84. const forceNumberSign = (value: bigint) => {
  85. const isExponentNegative = value < 0;
  86. const exponentValueAbs = isExponentNegative ? -value : value;
  87. const exponentSign = isExponentNegative ? DEFAULT_NEGATIVE_SYMBOL : DEFAULT_POSITIVE_SYMBOL;
  88. return `${exponentSign}${exponentValueAbs}`;
  89. };
  90. interface ExponentialComponents {
  91. integer: string;
  92. fractional: string;
  93. exponent: string;
  94. }
  95. /**
  96. * Extracts the integer, fractional, and exponent components of a string in exponential notation.
  97. * @param value - The string value to extract components from.
  98. * @param options - Options to use when extracting components.
  99. * @returns ExponentialComponents The extracted components.
  100. */
  101. export const extractExponentialComponents = (
  102. value: string,
  103. options = {} as BaseOptions,
  104. ): ExponentialComponents => {
  105. const {
  106. decimalPoint = DEFAULT_DECIMAL_POINT,
  107. groupingSymbol = DEFAULT_GROUPING_SYMBOL,
  108. exponentDelimiter = DEFAULT_EXPONENT_DELIMITER,
  109. } = options;
  110. const valueWithoutGroupingSymbols = value.replace(new RegExp(`${groupingSymbol}`, 'g'), '');
  111. const exponentDelimiterIndex = valueWithoutGroupingSymbols.indexOf(exponentDelimiter);
  112. if (exponentDelimiterIndex < 0) {
  113. // We force the value to have decimal point so that we can extract the integer and fractional
  114. // components.
  115. const stringValueWithDecimal = forceDecimalPoint(valueWithoutGroupingSymbols, decimalPoint);
  116. const [integerRaw, fractionalRaw] = stringValueWithDecimal.split(decimalPoint);
  117. const integer = integerRaw.replace(/^0+/g, '');
  118. const fractional = fractionalRaw.replace(/0+$/g, '');
  119. const exponentValue = BigInt(integer.length - 1);
  120. return {
  121. integer,
  122. exponent: forceNumberSign(exponentValue),
  123. fractional,
  124. };
  125. }
  126. if (exponentDelimiterIndex !== valueWithoutGroupingSymbols.lastIndexOf(exponentDelimiter)) {
  127. throw new InvalidFormatError(value);
  128. }
  129. const [base, exponentRaw] = valueWithoutGroupingSymbols.split(exponentDelimiter);
  130. const [integerRaw, fractionalRaw = ''] = base.split(decimalPoint);
  131. const integerWithoutZeroes = integerRaw.replace(/^0+/g, '');
  132. const integer = integerWithoutZeroes[0] ?? '0';
  133. const extraIntegerDigits = integerWithoutZeroes.slice(1);
  134. const fractional = `${extraIntegerDigits}${fractionalRaw.replace(/0+$/g, '')}`;
  135. const exponentValue = BigInt(exponentRaw) + BigInt(extraIntegerDigits.length);
  136. return {
  137. integer,
  138. exponent: forceNumberSign(exponentValue),
  139. fractional,
  140. };
  141. };
  142. /**
  143. * Converts a numeric value to a string in exponential notation. Supports numbers of all types.
  144. * @param value - The value to convert.
  145. * @param options - Options to use when extracting components.
  146. * @returns string The value in exponential notation.
  147. */
  148. export const numberToExponential = (
  149. value: ValidValue,
  150. options = {} as NumberToExponentialOptions,
  151. ): string => {
  152. const valueRaw = value as unknown;
  153. if (typeof valueRaw === 'bigint' || typeof valueRaw === 'number') {
  154. return numberToExponential(valueRaw.toString(), options);
  155. }
  156. if (typeof valueRaw !== 'string') {
  157. throw new InvalidValueTypeError(valueRaw);
  158. }
  159. if (valueRaw.startsWith(DEFAULT_NEGATIVE_SYMBOL)) {
  160. return `${DEFAULT_NEGATIVE_SYMBOL}${numberToExponential(valueRaw.slice(DEFAULT_NEGATIVE_SYMBOL.length), options)}}`;
  161. }
  162. const {
  163. decimalPoint = DEFAULT_DECIMAL_POINT,
  164. groupingSymbol = DEFAULT_GROUPING_SYMBOL,
  165. exponentDelimiter = DEFAULT_EXPONENT_DELIMITER,
  166. } = options;
  167. // Remove invalid characters.
  168. // We also remove the grouping symbols in case they come from human input.
  169. const stringValue = valueRaw
  170. .replace(new RegExp(`${groupingSymbol}`, 'g'), '')
  171. .toLowerCase()
  172. .replace(/\s/g, '');
  173. const {
  174. integer,
  175. fractional,
  176. exponent,
  177. } = extractExponentialComponents(stringValue, options);
  178. const significantDigits = `${integer}${fractional}`;
  179. if (significantDigits.length === 0) {
  180. // We copy the behavior from `Number.prototype.toExponential` here.
  181. return `0${exponentDelimiter}${DEFAULT_POSITIVE_SYMBOL}0`;
  182. }
  183. const significandInteger = significantDigits[0];
  184. const significandFractional = significantDigits.slice(1).replace(/0+$/g, '');
  185. if (significandFractional.length === 0) {
  186. return `${significandInteger}${exponentDelimiter}${exponent}`;
  187. }
  188. return `${significandInteger}${decimalPoint}${significandFractional}${exponentDelimiter}${exponent}`;
  189. };
  190. export const exponentialToNumberString = (
  191. exp: string,
  192. options = {} as ExponentialToNumberStringOptions,
  193. ) => {
  194. const {
  195. decimalPoint = DEFAULT_DECIMAL_POINT,
  196. maxLength = Number.MAX_SAFE_INTEGER,
  197. } = options;
  198. const { integer, fractional, exponent } = extractExponentialComponents(exp, options);
  199. const currentDecimalPointIndex = integer.length;
  200. const newDecimalPointIndex = currentDecimalPointIndex + Number(exponent);
  201. if (newDecimalPointIndex > maxLength) {
  202. // Integer part overflow
  203. //
  204. // Important to bail out as early as possible.
  205. throw new RangeError('Could not represent number in string form.');
  206. }
  207. const fractionalDigits = fractional.length > 0 ? fractional : '0';
  208. const significantDigits = `${integer}${fractionalDigits}`;
  209. if (significantDigits.length > maxLength) {
  210. // Digits overflow
  211. //
  212. // Should we choose to truncate instead of throw an exception?
  213. // Quite tricky when only the fractional part overflows.
  214. throw new RangeError('Could not represent number in string form.');
  215. }
  216. let newInteger: string;
  217. try {
  218. newInteger = significantDigits.slice(0, newDecimalPointIndex)
  219. .padEnd(newDecimalPointIndex, '0');
  220. } catch {
  221. // Error in padEnd(), shadow it.
  222. throw new RangeError('Could not represent number in string form.');
  223. }
  224. const newFractional = significantDigits.slice(newDecimalPointIndex).replace(/0+$/g, '');
  225. if (newFractional.length === 0) {
  226. return newInteger;
  227. }
  228. if (newInteger.length + decimalPoint.length + newFractional.length > maxLength) {
  229. // Fractional part overflow
  230. //
  231. // Final length check.
  232. throw new RangeError('Could not represent number in string form.');
  233. }
  234. return `${newInteger}${decimalPoint}${newFractional}`;
  235. };