Ensure exponents are normalized properly.master
@@ -60,7 +60,7 @@ export interface StringifySystem { | |||
* @param place - The group place. | |||
* @param options - Options to use when creating the group. | |||
*/ | |||
makeGroups: (groups: Group[], options?: Record<string, unknown>) => string[]; | |||
makeGroups: <T extends object>(groups: Group[], options?: T) => string[]; | |||
/** | |||
* Groups a string. | |||
* @param value - The string to group. | |||
@@ -1,5 +1,6 @@ | |||
import { enUS } from './systems'; | |||
import { StringifySystem } from './common'; | |||
import { exponentialToNumberString } from './exponent'; | |||
/** | |||
* Negative symbol. | |||
@@ -28,7 +29,7 @@ type ParseResult = typeof ALLOWED_PARSE_RESULT_TYPES[number]; | |||
/** | |||
* Options to use when converting a value to a string. | |||
*/ | |||
export interface StringifyOptions { | |||
export interface StringifyOptions<TMakeGroupOptions extends object = object> { | |||
/** | |||
* The system to use when converting a value to a string. | |||
* | |||
@@ -38,7 +39,7 @@ export interface StringifyOptions { | |||
/** | |||
* Options to use when making a group. This is used to override the default options for a group. | |||
*/ | |||
makeGroupOptions?: Record<string, unknown>; | |||
makeGroupOptions?: TMakeGroupOptions; | |||
} | |||
/** | |||
@@ -66,7 +67,10 @@ export const stringify = ( | |||
} | |||
const groups = system.group(valueStr); | |||
const groupNames = system.makeGroups(groups, makeGroupOptions); | |||
const groupNames = system.makeGroups( | |||
groups, | |||
makeGroupOptions, | |||
); | |||
return system.finalize(groupNames); | |||
}; | |||
@@ -104,7 +108,7 @@ export const parse = (value: string, options = {} as ParseOptions) => { | |||
// Precision might be lost here. Use bigint when not using fractional parts. | |||
return Number(stringValue); | |||
case 'bigint': | |||
return BigInt(stringValue); | |||
return BigInt(exponentialToNumberString(stringValue)); | |||
default: | |||
break; | |||
} | |||
@@ -100,7 +100,7 @@ interface ExponentialComponents { | |||
* @param options - Options to use when extracting components. | |||
* @returns ExponentialComponents The extracted components. | |||
*/ | |||
export const extractExponentialComponents = ( | |||
const extractExponentialComponents = ( | |||
value: string, | |||
options = {} as NumberToExponentialOptions, | |||
): ExponentialComponents => { | |||
@@ -203,3 +203,21 @@ export const numberToExponential = ( | |||
return `${significandInteger}${decimalPoint}${significandFractional}${exponentDelimiter}${exponent}`; | |||
}; | |||
export const exponentialToNumberString = ( | |||
exp: string, | |||
options = {} as NumberToExponentialOptions, | |||
) => { | |||
const { decimalPoint = DEFAULT_DECIMAL_POINT } = options; | |||
const { integer, fractional, exponent } = extractExponentialComponents(exp, options); | |||
const currentDecimalPointIndex = integer.length; | |||
const newDecimalPointIndex = currentDecimalPointIndex + Number(exponent); | |||
const fractionalDigits = fractional.length > 0 ? fractional : '0'; | |||
const significantDigits = `${integer}${fractionalDigits}`; | |||
const newInteger = significantDigits.slice(0, newDecimalPointIndex).padEnd(newDecimalPointIndex, '0'); | |||
const newFractional = significantDigits.slice(newDecimalPointIndex).replace(/0+$/g, ''); | |||
if (newFractional.length === 0) { | |||
return newInteger; | |||
} | |||
return `${newInteger}${decimalPoint}${newFractional}`; | |||
}; |
@@ -8,8 +8,7 @@ export const NEGATIVE = 'negative' as const; | |||
export const NEGATIVE_SYMBOL = '-' as const; | |||
// replace with hyphen with option | |||
export const TENS_ONES_SEPARATOR = ' ' as const; | |||
export const TENS_ONES_SEPARATOR = '-' as const; | |||
export const POSITIVE_SYMBOL = '+' as const; | |||
@@ -36,9 +36,10 @@ import { | |||
* Builds a name for numbers in tens and ones. | |||
* @param tens - Tens digit. | |||
* @param ones - Ones digit. | |||
* @param addTensDashes - Whether to add dashes between the tens and ones. | |||
* @returns string The name for the number. | |||
*/ | |||
const makeTensName = (tens: number, ones: number) => { | |||
const makeTensName = (tens: number, ones: number, addTensDashes = false) => { | |||
if (tens === 0) { | |||
return ONES[ones]; | |||
} | |||
@@ -51,7 +52,7 @@ const makeTensName = (tens: number, ones: number) => { | |||
return TENS[tens]; | |||
} | |||
return `${TENS[tens] as Exclude<TensName, 'zero' | 'ten'>}${TENS_ONES_SEPARATOR}${ONES[ones] as Exclude<OnesName, 'zero'>}` as const; | |||
return `${TENS[tens] as Exclude<TensName, 'zero' | 'ten'>}${addTensDashes ? TENS_ONES_SEPARATOR : ' '}${ONES[ones] as Exclude<OnesName, 'zero'>}` as const; | |||
}; | |||
/** | |||
@@ -59,18 +60,19 @@ const makeTensName = (tens: number, ones: number) => { | |||
* @param hundreds - Hundreds digit. | |||
* @param tens - Tens digit. | |||
* @param ones - Ones digit. | |||
* @param addTensDashes - Whether to add dashes between the tens and ones. | |||
* @returns string The name for the number. | |||
*/ | |||
const makeHundredsName = (hundreds: number, tens: number, ones: number) => { | |||
const makeHundredsName = (hundreds: number, tens: number, ones: number, addTensDashes = false) => { | |||
if (hundreds === 0) { | |||
return makeTensName(tens, ones); | |||
return makeTensName(tens, ones, addTensDashes); | |||
} | |||
if (tens === 0 && ones === 0) { | |||
return `${ONES[hundreds]} ${HUNDRED}` as const; | |||
} | |||
return `${ONES[hundreds]} ${HUNDRED} ${makeTensName(tens, ones)}` as const; | |||
return `${ONES[hundreds]} ${HUNDRED} ${makeTensName(tens, ones, addTensDashes)}` as const; | |||
}; | |||
/** | |||
@@ -205,7 +207,12 @@ const getGroupName = (place: GroupPlace, shortenMillia: boolean) => { | |||
return `${groupGroups}${ILLION_SUFFIX}` as const; | |||
}; | |||
export const makeGroups = (groups: Group[], options?: Record<string, unknown>): string[] => { | |||
export interface MakeGroupsOptions { | |||
addTensDashes?: boolean; | |||
shortenMillia?: boolean; | |||
} | |||
export const makeGroups = (groups: Group[], options?: MakeGroupsOptions): string[] => { | |||
const filteredGroups = groups.filter(([digits, place]) => ( | |||
place === BigInt(0) || digits !== EMPTY_GROUP_DIGITS | |||
)); | |||
@@ -217,8 +224,11 @@ export const makeGroups = (groups: Group[], options?: Record<string, unknown>): | |||
.split('') | |||
.map((s) => Number(s)) as [number, number, number]; | |||
const groupDigitsName = makeHundredsName(...makeHundredsArgs); | |||
const groupName = getGroupName(place, options?.shortenMillia as boolean ?? false); | |||
const groupDigitsName = makeHundredsName( | |||
...makeHundredsArgs, | |||
options?.addTensDashes ?? false, | |||
); | |||
const groupName = getGroupName(place, options?.shortenMillia ?? false); | |||
if (groupName.length > 0) { | |||
return `${groupDigitsName} ${groupName}`; | |||
} | |||
@@ -1,6 +1,6 @@ | |||
import { describe, it, expect } from 'vitest'; | |||
import { numberToExponential } from '../src/exponent'; | |||
import { exponentialToNumberString, numberToExponential } from '../src/exponent'; | |||
describe('numberToExponential', () => { | |||
it('converts 0 to 0e+0', () => { | |||
@@ -55,3 +55,17 @@ describe('numberToExponential', () => { | |||
expect(numberToExponential('1e+100')).toBe('1e+100'); | |||
}); | |||
}); | |||
describe.only('exponentialToNumberString', () => { | |||
it('converts 1e+0 to 1', () => { | |||
expect(exponentialToNumberString('1e+0')).toBe('1'); | |||
}); | |||
it('converts 1e+1 to 10', () => { | |||
expect(exponentialToNumberString('1e+1')).toBe('10'); | |||
}); | |||
it('converts 1.23e+1 to 12.3', () => { | |||
expect(exponentialToNumberString('1.23e+1')).toBe('12.3'); | |||
}); | |||
}); |
@@ -4,7 +4,7 @@ import { numberToExponential } from '../../src/exponent'; | |||
const options = { system: systems.enUS }; | |||
describe('numerica', () => { | |||
describe.skip('numerica', () => { | |||
describe('group names', () => { | |||
describe('0-9', () => { | |||
it.each` | |||
@@ -15,7 +15,29 @@ | |||
align-items: stretch; | |||
} | |||
.main-form > textarea { | |||
.main-form > fieldset { | |||
display: contents; | |||
} | |||
.main-form > fieldset > div { | |||
padding: 1rem; | |||
box-sizing: border-box; | |||
display: flex; | |||
gap: 1rem; | |||
} | |||
.main-form > fieldset > legend { | |||
position: absolute; | |||
left: -999999px; | |||
} | |||
.main-form > div { | |||
flex-direction: column; | |||
display: flex; | |||
flex: auto; | |||
} | |||
.main-form > div > textarea { | |||
flex: auto; | |||
width: 100%; | |||
height: 0; | |||
@@ -27,12 +49,34 @@ | |||
box-sizing: border-box; | |||
} | |||
.checkbox > input { | |||
position: absolute; | |||
left: -999999px; | |||
} | |||
.checkbox > input + span { | |||
display: inline-flex; | |||
align-items: center; | |||
justify-content: center; | |||
height: 3rem; | |||
padding: 0 1rem; | |||
border: 1px solid #ccc; | |||
border-radius: 0.25rem; | |||
cursor: pointer; | |||
} | |||
.checkbox > input:checked + span { | |||
border-color: #000; | |||
background-color: #000; | |||
color: #fff; | |||
} | |||
@media (min-width: 1080px) { | |||
.main-form { | |||
.main-form > div { | |||
flex-direction: row; | |||
} | |||
.main-form > textarea { | |||
.main-form > div > textarea { | |||
width: 0; | |||
height: 100%; | |||
} | |||
@@ -40,9 +84,25 @@ | |||
</style> | |||
</head> | |||
<body> | |||
<form id="mainForm"> | |||
<textarea aria-label="Number" name="number" placeholder="123"></textarea> | |||
<textarea aria-label="Name" name="name" placeholder="one hundred twenty three"></textarea> | |||
<form id="mainForm" class="main-form"> | |||
<fieldset> | |||
<legend> | |||
Options | |||
</legend> | |||
<div> | |||
<label class="checkbox"> | |||
<input type="checkbox" name="shortenMillia"> | |||
<span>Shorten millia</span> | |||
</label><label class="checkbox"> | |||
<input type="checkbox" name="addTensDashes" checked> | |||
<span>Add dashes to tens</span> | |||
</label> | |||
</div> | |||
</fieldset> | |||
<div> | |||
<textarea aria-label="Number" name="number" placeholder="123"></textarea> | |||
<textarea aria-label="Name" name="name" placeholder="one hundred twenty three"></textarea> | |||
</div> | |||
</form> | |||
<script type="module"> | |||
import { stringify, parse } from './numerica.js'; | |||
@@ -50,26 +110,47 @@ | |||
function MainForm(el) { | |||
const numberInput = el.querySelector('[name="number"]'); | |||
const nameInput = el.querySelector('[name="name"]'); | |||
const addTensDashesCheckbox = el.querySelector('[name="addTensDashes"]'); | |||
const shortenMilliaCheckbox = el.querySelector('[name="shortenMillia"]'); | |||
const options = { | |||
stringify: { | |||
makeGroupOptions: { | |||
shortenMillia: false, | |||
addTensDashes: true, | |||
}, | |||
}, | |||
parse: { | |||
type: 'bigint', | |||
} | |||
}; | |||
numberInput.addEventListener('input', (e) => { | |||
nameInput.value = ''; | |||
if (e.currentTarget.value === '') { | |||
if (e.currentTarget.value.trim() === '') { | |||
return; | |||
} | |||
nameInput.value = stringify(e.currentTarget.value); | |||
nameInput.value = stringify(e.currentTarget.value, options.stringify); | |||
}); | |||
nameInput.addEventListener('input', (e) => { | |||
numberInput.value = ''; | |||
if (e.currentTarget.value === '') { | |||
if (e.currentTarget.value.trim() === '') { | |||
return; | |||
} | |||
numberInput.value = Number(parse(e.currentTarget.value)).toString(); | |||
numberInput.value = parse(e.currentTarget.value, options.parse).toString(); | |||
}); | |||
el.classList.add('main-form'); | |||
} | |||
addTensDashesCheckbox.addEventListener('change', (e) => { | |||
options.stringify.makeGroupOptions.addTensDashes = e.currentTarget.checked; | |||
numberInput.dispatchEvent(new Event('input')); | |||
}); | |||
shortenMilliaCheckbox.addEventListener('change', (e) => { | |||
options.stringify.makeGroupOptions.shortenMillia = e.currentTarget.checked; | |||
numberInput.dispatchEvent(new Event('input')); | |||
}); | |||
} | |||
const mainForm = window.document.getElementById('mainForm'); | |||
new MainForm(mainForm); | |||
</script> | |||