Gets the name of a number, even if it's stupidly big. Supersedes TheoryOfNekomata/number-name.
 
 

713 lines
16 KiB

  1. // noinspection SpellCheckingInspection
  2. import { Group, InvalidTokenError } from '../common';
  3. import { numberToExponential } from '../exponent';
  4. const DECIMAL_POINT = '.' as const;
  5. const GROUPING_SYMBOL = ',' as const;
  6. const NEGATIVE = 'negative' as const;
  7. const NEGATIVE_SYMBOL = '-' as const;
  8. const POSITIVE_SYMBOL = '+' as const;
  9. const SHORT_MILLIA_DELIMITER = '^' as const;
  10. const EXPONENT_DELIMITER = 'e' as const;
  11. const EMPTY_GROUP_DIGITS = '000' as const;
  12. const EMPTY_PLACE: Group = [EMPTY_GROUP_DIGITS, 0];
  13. /**
  14. * Ones number names.
  15. */
  16. const ONES = [
  17. 'zero',
  18. 'one',
  19. 'two',
  20. 'three',
  21. 'four',
  22. 'five',
  23. 'six',
  24. 'seven',
  25. 'eight',
  26. 'nine',
  27. ] as const;
  28. type OnesName = typeof ONES[number];
  29. /**
  30. * Ten plus ones number names.
  31. */
  32. const TEN_PLUS_ONES = [
  33. 'ten',
  34. 'eleven',
  35. 'twelve',
  36. 'thirteen',
  37. 'fourteen',
  38. 'fifteen',
  39. 'sixteen',
  40. 'seventeen',
  41. 'eighteen',
  42. 'nineteen',
  43. ] as const;
  44. type TenPlusOnesName = typeof TEN_PLUS_ONES[number];
  45. /**
  46. * Tens number names.
  47. */
  48. const TENS = [
  49. 'zero',
  50. TEN_PLUS_ONES[0],
  51. 'twenty',
  52. 'thirty',
  53. 'forty',
  54. 'fifty',
  55. 'sixty',
  56. 'seventy',
  57. 'eighty',
  58. 'ninety',
  59. ] as const;
  60. type TensName = typeof TENS[number];
  61. /**
  62. * Hundreds name.
  63. */
  64. const HUNDRED = 'hundred' as const;
  65. /**
  66. * Thousands name.
  67. */
  68. const THOUSAND = 'thousand' as const;
  69. // const ILLION_ORDINAL_SUFFIX = 'illionth' as const;
  70. // const THOUSAND_ORDINAL = 'thousandth' as const;
  71. /**
  72. * Special millions name.
  73. */
  74. const MILLIONS_SPECIAL_PREFIXES = [
  75. '',
  76. 'm',
  77. 'b',
  78. 'tr',
  79. 'quadr',
  80. 'quint',
  81. 'sext',
  82. 'sept',
  83. 'oct',
  84. 'non',
  85. ] as const;
  86. type MillionsSpecialPrefix = Exclude<typeof MILLIONS_SPECIAL_PREFIXES[number], ''>;
  87. /**
  88. * Millions name.
  89. */
  90. const MILLIONS_PREFIXES = [
  91. '',
  92. 'un',
  93. 'duo',
  94. 'tre',
  95. 'quattuor',
  96. 'quin',
  97. 'sex',
  98. 'septen',
  99. 'octo',
  100. 'novem',
  101. ] as const;
  102. type MillionsPrefix = Exclude<typeof MILLIONS_PREFIXES[number], ''>;
  103. /**
  104. * Decillions name.
  105. */
  106. const DECILLIONS_PREFIXES = [
  107. '',
  108. 'dec',
  109. 'vigin',
  110. 'trigin',
  111. 'quadragin',
  112. 'quinquagin',
  113. 'sexagin',
  114. 'septuagin',
  115. 'octogin',
  116. 'nonagin',
  117. ] as const;
  118. type DecillionsPrefix = Exclude<typeof DECILLIONS_PREFIXES[number], ''>;
  119. /**
  120. * Centillions name.
  121. */
  122. const CENTILLIONS_PREFIXES = [
  123. '',
  124. 'cen',
  125. 'duocen',
  126. 'trecen',
  127. 'quadringen',
  128. 'quingen',
  129. 'sescen',
  130. 'septingen',
  131. 'octingen',
  132. 'nongen',
  133. ] as const;
  134. type CentillionsPrefix = Exclude<typeof CENTILLIONS_PREFIXES[number], ''>;
  135. /**
  136. * Prefix for millia- number names.
  137. */
  138. const MILLIA_PREFIX = 'millia' as const;
  139. /**
  140. * Suffix for -illion number names.
  141. */
  142. const ILLION_SUFFIX = 'illion' as const;
  143. /**
  144. * Builds a name for numbers in tens and ones.
  145. * @param tens - Tens digit.
  146. * @param ones - Ones digit.
  147. * @returns The name for the number.
  148. */
  149. const makeTensName = (tens: number, ones: number) => {
  150. if (tens === 0) {
  151. return ONES[ones];
  152. }
  153. if (tens === 1) {
  154. return TEN_PLUS_ONES[ones] as unknown as TenPlusOnesName;
  155. }
  156. if (ones === 0) {
  157. return TENS[tens];
  158. }
  159. return `${TENS[tens] as Exclude<TensName, 'zero' | 'ten'>} ${ONES[ones] as Exclude<OnesName, 'zero'>}` as const;
  160. };
  161. /**
  162. * Builds a name for numbers in hundreds, tens, and ones.
  163. * @param hundreds - Hundreds digit.
  164. * @param tens - Tens digit.
  165. * @param ones - Ones digit.
  166. * @returns The name for the number.
  167. */
  168. const makeHundredsName = (hundreds: number, tens: number, ones: number) => {
  169. if (hundreds === 0) {
  170. return makeTensName(tens, ones);
  171. }
  172. if (tens === 0 && ones === 0) {
  173. return `${ONES[hundreds]} ${HUNDRED}` as const;
  174. }
  175. return `${ONES[hundreds]} ${HUNDRED} ${makeTensName(tens, ones)}` as const;
  176. };
  177. /**
  178. * Builds a name for numbers in the millions.
  179. * @param millions - Millions digit.
  180. * @param milliaCount - Number of millia- groups.
  181. * @returns The millions prefix.
  182. */
  183. const makeMillionsPrefix = (millions: number, milliaCount: number) => {
  184. if (milliaCount > 0) {
  185. return MILLIONS_PREFIXES[millions] as MillionsPrefix;
  186. }
  187. return MILLIONS_SPECIAL_PREFIXES[millions] as MillionsSpecialPrefix;
  188. };
  189. /**
  190. * Builds a name for numbers in the decillions.
  191. * @param decillions - Decillions digit.
  192. * @param millions - Millions digit.
  193. * @param milliaCount - Number of millia- groups.
  194. * @returns The decillions prefix.
  195. */
  196. const makeDecillionsPrefix = (decillions: number, millions: number, milliaCount: number) => {
  197. if (decillions === 0) {
  198. return makeMillionsPrefix(millions, milliaCount);
  199. }
  200. const onesPrefix = MILLIONS_PREFIXES[millions] as MillionsPrefix;
  201. const tensName = DECILLIONS_PREFIXES[decillions] as DecillionsPrefix;
  202. return `${onesPrefix}${tensName}` as const;
  203. };
  204. /**
  205. * Builds a name for numbers in the centillions.
  206. * @param centillions - Centillions digit.
  207. * @param decillions - Decillions digit.
  208. * @param millions - Millions digit.
  209. * @param milliaCount - Number of millia- groups.
  210. * @returns The centillions prefix.
  211. */
  212. const makeCentillionsPrefix = (
  213. centillions: number,
  214. decillions: number,
  215. millions: number,
  216. milliaCount: number,
  217. ) => {
  218. if (centillions === 0) {
  219. return makeDecillionsPrefix(decillions, millions, milliaCount);
  220. }
  221. const onesPrefix = MILLIONS_PREFIXES[millions] as MillionsPrefix;
  222. const tensName = DECILLIONS_PREFIXES[decillions] as DecillionsPrefix;
  223. const hundredsName = CENTILLIONS_PREFIXES[centillions] as CentillionsPrefix;
  224. return `${hundredsName}${onesPrefix}${decillions > 0 ? tensName : ''}` as const;
  225. };
  226. const getGroupName = (place: number, shortenMillia: boolean) => {
  227. if (place === 0) {
  228. return '' as const;
  229. }
  230. if (place === 1) {
  231. return THOUSAND;
  232. }
  233. const bigGroupPlace = place - 1;
  234. const groupGroups = bigGroupPlace
  235. .toString()
  236. .split('')
  237. .reduceRight<Group[]>(
  238. (acc, c, i, cc) => {
  239. const firstGroup = acc.at(0);
  240. const currentPlace = Math.floor((cc.length - i - 1) / 3);
  241. if (typeof firstGroup === 'undefined') {
  242. return [[c, currentPlace]];
  243. }
  244. if (firstGroup[0].length > 2) {
  245. return [[c, currentPlace], ...acc];
  246. }
  247. return [
  248. [c + firstGroup[0], currentPlace],
  249. ...acc.slice(1),
  250. ];
  251. },
  252. [],
  253. )
  254. .map(([groupDigits, groupPlace]) => [groupDigits.padStart(3, '0'), groupPlace] as const)
  255. .filter(([groupDigits]) => groupDigits !== EMPTY_GROUP_DIGITS)
  256. .map(([groupDigits, groupPlace]) => {
  257. const [hundreds, tens, ones] = groupDigits.split('').map(Number);
  258. if (groupPlace < 1) {
  259. return makeCentillionsPrefix(hundreds, tens, ones, groupPlace);
  260. }
  261. const milliaSuffix = (
  262. shortenMillia && groupPlace > 1
  263. ? `${MILLIA_PREFIX}${SHORT_MILLIA_DELIMITER}${groupPlace}`
  264. : MILLIA_PREFIX.repeat(groupPlace)
  265. );
  266. if (groupDigits === '001') {
  267. return milliaSuffix;
  268. }
  269. return makeCentillionsPrefix(hundreds, tens, ones, groupPlace) + milliaSuffix;
  270. })
  271. .join('');
  272. if (groupGroups.endsWith(DECILLIONS_PREFIXES[1])) {
  273. return `${groupGroups}${ILLION_SUFFIX}` as const;
  274. }
  275. if (bigGroupPlace > 10) {
  276. return `${groupGroups}t${ILLION_SUFFIX}` as const;
  277. }
  278. return `${groupGroups}${ILLION_SUFFIX}` as const;
  279. };
  280. export const makeGroup = (
  281. group: string,
  282. place: number,
  283. options?: Record<string, unknown>,
  284. ): string => {
  285. const makeHundredsArgs = group
  286. .padStart(3, '0')
  287. .split('')
  288. .map((s) => Number(s)) as [number, number, number];
  289. const groupDigitsName = makeHundredsName(...makeHundredsArgs);
  290. const groupName = getGroupName(place, options?.shortenMillia as boolean ?? false);
  291. if (groupName.length > 0) {
  292. return `${groupDigitsName} ${groupName}` as const;
  293. }
  294. return groupDigitsName;
  295. };
  296. /**
  297. * Group a number string into groups of three digits, starting from the decimal point.
  298. * @param value - The number string to group.
  299. */
  300. export const group = (value: string): Group[] => {
  301. const [significand, exponentString] = numberToExponential(
  302. value,
  303. {
  304. decimalPoint: DECIMAL_POINT,
  305. groupingSymbol: GROUPING_SYMBOL,
  306. exponentDelimiter: EXPONENT_DELIMITER,
  307. },
  308. )
  309. .split(EXPONENT_DELIMITER);
  310. const exponent = Number(exponentString);
  311. const significantDigits = significand.replace(DECIMAL_POINT, '');
  312. return significantDigits.split('').reduce<Group[]>(
  313. (acc, c, i) => {
  314. const currentPlace = Math.floor((exponent - i) / 3);
  315. const lastGroup = acc.at(-1) ?? [EMPTY_GROUP_DIGITS, currentPlace];
  316. const currentPlaceInGroup = 2 - ((exponent - i) % 3);
  317. if (lastGroup[1] === currentPlace) {
  318. const lastGroupDigits = lastGroup[0].split('');
  319. lastGroupDigits[currentPlaceInGroup] = c;
  320. return [...acc.slice(0, -1) ?? [], [
  321. lastGroupDigits.join(''),
  322. currentPlace,
  323. ]];
  324. }
  325. return [...acc, [c.padEnd(3, '0'), currentPlace]];
  326. },
  327. [],
  328. );
  329. };
  330. /**
  331. * Formats the final tokenized string.
  332. * @param tokens - The tokens to finalize.
  333. */
  334. export const finalize = (tokens: string[]) => (
  335. tokens
  336. .map((t) => t.trim())
  337. .join(' ')
  338. .trim()
  339. );
  340. /**
  341. * Makes a negative string.
  342. * @param s - The string to make negative.
  343. */
  344. export const makeNegative = (s: string) => (
  345. `${NEGATIVE} ${s}`
  346. );
  347. export const tokenize = (stringValue: string) => (
  348. stringValue.split(' ').filter((maybeToken) => maybeToken.length > 0)
  349. );
  350. const FINAL_TOKEN = '' as const;
  351. interface DoParseState {
  352. groupNameCurrent: string;
  353. millias: number[];
  354. milliaIndex: number;
  355. done: boolean;
  356. }
  357. const doParseGroupName = (result: DoParseState): DoParseState => {
  358. if (result.groupNameCurrent.length < 1) {
  359. return {
  360. ...result,
  361. done: true,
  362. };
  363. }
  364. if (result.groupNameCurrent === 't') {
  365. // If the current group name is "t", then we're done.
  366. // We use the -t- affix to attach the group prefix to the -illion suffix, except for decillion.
  367. return {
  368. ...result,
  369. done: true,
  370. };
  371. }
  372. const centillions = CENTILLIONS_PREFIXES.findIndex((p) => (
  373. p.length > 0 && result.groupNameCurrent.startsWith(p)
  374. ));
  375. if (centillions > -1) {
  376. return {
  377. milliaIndex: 0,
  378. millias: result.millias.map((m, i) => (
  379. i === 0
  380. ? m + (centillions * 100)
  381. : m
  382. )),
  383. groupNameCurrent: result.groupNameCurrent.slice(
  384. CENTILLIONS_PREFIXES[centillions].length,
  385. ),
  386. done: false,
  387. };
  388. }
  389. const decillions = DECILLIONS_PREFIXES.findIndex((p) => (
  390. p.length > 0 && result.groupNameCurrent.startsWith(p)
  391. ));
  392. if (decillions > -1) {
  393. return {
  394. milliaIndex: 0,
  395. millias: result.millias.map((m, i) => (
  396. i === 0
  397. ? m + (decillions * 10)
  398. : m
  399. )),
  400. groupNameCurrent: result.groupNameCurrent.slice(
  401. DECILLIONS_PREFIXES[decillions].length,
  402. ),
  403. done: false,
  404. };
  405. }
  406. const millions = MILLIONS_PREFIXES.findIndex((p) => (
  407. p.length > 0 && result.groupNameCurrent.startsWith(p)
  408. ));
  409. if (millions > -1) {
  410. return {
  411. milliaIndex: 0,
  412. millias: result.millias.map((m, i) => (
  413. i === 0
  414. ? m + millions
  415. : m
  416. )),
  417. groupNameCurrent: result.groupNameCurrent.slice(
  418. MILLIONS_PREFIXES[millions].length,
  419. ),
  420. done: false,
  421. };
  422. }
  423. if (result.groupNameCurrent.startsWith(`${MILLIA_PREFIX}${SHORT_MILLIA_DELIMITER}`)) {
  424. // short millia
  425. const matchedMilliaArray = result.groupNameCurrent.match(/^\d+/);
  426. if (!matchedMilliaArray) {
  427. throw new InvalidTokenError(result.groupNameCurrent);
  428. }
  429. const matchedMillia = matchedMilliaArray[0];
  430. const newMillia = Number(matchedMillia);
  431. const oldMillia = result.milliaIndex;
  432. const newMillias = [...result.millias];
  433. newMillias[newMillia] = newMillias[oldMillia] || 1;
  434. newMillias[oldMillia] = 0;
  435. return {
  436. milliaIndex: newMillia,
  437. millias: newMillias,
  438. groupNameCurrent: result.groupNameCurrent.slice(MILLIA_PREFIX.length),
  439. done: false,
  440. };
  441. }
  442. if (result.groupNameCurrent.startsWith(MILLIA_PREFIX)) {
  443. const newMillia = result.milliaIndex + 1;
  444. const oldMillia = result.milliaIndex;
  445. const newMillias = [...result.millias];
  446. newMillias[newMillia] = newMillias[oldMillia] || 1;
  447. newMillias[oldMillia] = 0;
  448. return {
  449. milliaIndex: newMillia,
  450. millias: newMillias,
  451. groupNameCurrent: result.groupNameCurrent.slice(MILLIA_PREFIX.length),
  452. done: false,
  453. };
  454. }
  455. throw new InvalidTokenError(result.groupNameCurrent);
  456. };
  457. const getGroupPlaceFromGroupName = (groupName: string) => {
  458. if (groupName === THOUSAND) {
  459. return 1;
  460. }
  461. const groupNameBase = groupName.replace(ILLION_SUFFIX, '');
  462. const specialMillions = MILLIONS_SPECIAL_PREFIXES.findIndex((p) => groupNameBase === p);
  463. if (specialMillions > -1) {
  464. return specialMillions + 1;
  465. }
  466. let result: DoParseState = {
  467. groupNameCurrent: groupNameBase,
  468. millias: [0],
  469. milliaIndex: 0,
  470. done: false,
  471. };
  472. do {
  473. result = doParseGroupName(result);
  474. } while (!result.done);
  475. const bigGroupPlace = Number(
  476. result.millias
  477. .map((s) => s.toString().padStart(3, '0'))
  478. .reverse()
  479. .join(''),
  480. );
  481. return bigGroupPlace + 1;
  482. };
  483. enum ParseGroupsMode {
  484. INITIAL = 'unknown',
  485. ONES_MODE = 'ones',
  486. TENS_MODE = 'tens',
  487. TEN_PLUS_ONES_MODE = 'tenPlusOnes',
  488. HUNDRED_MODE = 'hundred',
  489. THOUSAND_MODE = 'thousand',
  490. DONE = 'done',
  491. }
  492. interface ParserState {
  493. lastToken?: string;
  494. groups: Group[];
  495. mode: ParseGroupsMode;
  496. }
  497. export const parseGroups = (tokens: string[]) => {
  498. const { groups } = [...tokens, FINAL_TOKEN].reduce<ParserState>(
  499. (acc, token) => {
  500. const lastGroup = acc.groups.at(-1) ?? [...EMPTY_PLACE];
  501. if (token === THOUSAND || token.endsWith(ILLION_SUFFIX)) {
  502. if (acc.mode === ParseGroupsMode.ONES_MODE) {
  503. const ones = ONES.findIndex((o) => o === acc.lastToken);
  504. if (ones > -1) {
  505. lastGroup[0] = `${lastGroup[0].slice(0, 2)}${ones}`;
  506. }
  507. }
  508. lastGroup[1] = getGroupPlaceFromGroupName(token);
  509. return {
  510. ...acc,
  511. groups: [...acc.groups.slice(0, -1), lastGroup],
  512. lastToken: token,
  513. mode: ParseGroupsMode.THOUSAND_MODE,
  514. };
  515. }
  516. if (token === HUNDRED) {
  517. if (acc.mode === ParseGroupsMode.ONES_MODE) {
  518. const hundreds = ONES.findIndex((o) => o === acc.lastToken);
  519. lastGroup[0] = `${hundreds}${lastGroup[0].slice(1)}`;
  520. return {
  521. ...acc,
  522. groups: [...acc.groups.slice(0, -1), lastGroup],
  523. mode: ParseGroupsMode.HUNDRED_MODE,
  524. };
  525. }
  526. }
  527. if (token === FINAL_TOKEN) {
  528. if (acc.mode === ParseGroupsMode.ONES_MODE) {
  529. const ones = ONES.findIndex((o) => o === acc.lastToken);
  530. lastGroup[0] = `${lastGroup[0].slice(0, 2)}${ones}`;
  531. lastGroup[1] = 0;
  532. return {
  533. ...acc,
  534. groups: [...acc.groups.slice(0, -1), lastGroup],
  535. mode: ParseGroupsMode.DONE,
  536. };
  537. }
  538. }
  539. if (ONES.includes(token as OnesName)) {
  540. if (acc.mode === ParseGroupsMode.THOUSAND_MODE) {
  541. return {
  542. ...acc,
  543. lastToken: token,
  544. mode: ParseGroupsMode.ONES_MODE,
  545. groups: [...acc.groups, [...EMPTY_PLACE]],
  546. };
  547. }
  548. return {
  549. ...acc,
  550. lastToken: token,
  551. mode: ParseGroupsMode.ONES_MODE,
  552. };
  553. }
  554. const tenPlusOnes = TEN_PLUS_ONES.findIndex((t) => t === token);
  555. if (tenPlusOnes > -1) {
  556. if (acc.mode === ParseGroupsMode.THOUSAND_MODE) {
  557. return {
  558. ...acc,
  559. lastToken: token,
  560. mode: ParseGroupsMode.ONES_MODE,
  561. groups: [...acc.groups, [`01${tenPlusOnes}`, lastGroup[1] - 1]],
  562. };
  563. }
  564. lastGroup[0] = `${lastGroup[0].slice(0, 1)}1${tenPlusOnes}`;
  565. return {
  566. ...acc,
  567. lastToken: token,
  568. mode: ParseGroupsMode.TEN_PLUS_ONES_MODE,
  569. groups: [...acc.groups.slice(0, -1), lastGroup],
  570. };
  571. }
  572. const tens = TENS.findIndex((t) => t === token);
  573. if (tens > -1) {
  574. lastGroup[0] = `${lastGroup[0].slice(0, 1)}${tens}${lastGroup[0].slice(2)}`;
  575. return {
  576. ...acc,
  577. lastToken: token,
  578. mode: ParseGroupsMode.TENS_MODE,
  579. groups: [...acc.groups.slice(0, -1), lastGroup],
  580. };
  581. }
  582. return {
  583. ...acc,
  584. lastToken: token,
  585. };
  586. },
  587. {
  588. lastToken: undefined,
  589. groups: [],
  590. mode: ParseGroupsMode.INITIAL,
  591. },
  592. );
  593. return groups;
  594. };
  595. export const combineGroups = (groups: Group[]) => {
  596. const groupsSorted = groups.sort((a, b) => b[1] - a[1]); // sort by place
  597. const firstGroup = groupsSorted[0];
  598. const firstGroupPlace = firstGroup[1];
  599. const digits = groupsSorted.reduce(
  600. (previousDigits, thisGroup) => {
  601. const [groupDigits] = thisGroup;
  602. return `${previousDigits}${groupDigits}`;
  603. },
  604. '',
  605. ).replace(/^0+/, '') || '0';
  606. const firstGroupDigits = firstGroup[0];
  607. const firstGroupDigitsWithoutZeroes = firstGroupDigits.replace(/^0+/, '');
  608. const exponentExtra = firstGroupDigits.length - firstGroupDigitsWithoutZeroes.length;
  609. const exponentValue = BigInt((firstGroupPlace * 3) + (2 - exponentExtra));
  610. const isExponentNegative = exponentValue < 0;
  611. const exponentValueAbs = isExponentNegative ? -exponentValue : exponentValue;
  612. const exponentSign = isExponentNegative ? NEGATIVE_SYMBOL : POSITIVE_SYMBOL;
  613. const exponent = `${exponentSign}${exponentValueAbs}`;
  614. const significandInteger = digits.slice(0, 1);
  615. const significandFraction = digits.slice(1).replace(/0+$/, '');
  616. if (significandFraction.length > 0) {
  617. return `${significandInteger}${DECIMAL_POINT}${significandFraction}${EXPONENT_DELIMITER}${exponent}`;
  618. }
  619. return `${significandInteger}${EXPONENT_DELIMITER}${exponent}`;
  620. };