← back to Boomer Calculator
lib/calculations.ts
91 lines
export function finite(value: number): number {
if (!Number.isFinite(value)) throw new Error('Result is too large or undefined.')
return value
}
export function readNumber(value: string, label: string): number {
if (!value.trim()) throw new Error(`Enter ${label}.`)
const number = Number(value)
if (!Number.isFinite(number)) throw new Error(`Enter a valid ${label}.`)
return number
}
export function evaluateExpression(input: string): number {
if (!input.trim() || input.length > 500) throw new Error('Enter an expression up to 500 characters.')
const source = input.replace(/\s+/g, '')
let position = 0
function expression(): number {
let value = term()
while (source[position] === '+' || source[position] === '-') {
const operator = source[position++]
const right = term()
value = finite(operator === '+' ? value + right : value - right)
}
return value
}
function term(): number {
let value = factor()
while (source[position] === '*' || source[position] === '/') {
const operator = source[position++]
const right = factor()
if (operator === '/' && right === 0) throw new Error('Cannot divide by zero.')
value = finite(operator === '*' ? value * right : value / right)
}
return value
}
function factor(): number {
if (source[position] === '+') { position++; return factor() }
if (source[position] === '-') { position++; return -factor() }
if (source.startsWith('sin(', position)) {
position += 4
const degrees = expression()
if (source[position++] !== ')') throw new Error('Close every parenthesis.')
const sine = Math.sin((degrees % 360) * Math.PI / 180)
return Math.abs(sine) < 1e-15 ? 0 : sine
}
if (source[position] === '(') {
position++
const value = expression()
if (source[position++] !== ')') throw new Error('Close every parenthesis.')
return value
}
const match = source.slice(position).match(/^(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?/)
if (!match) throw new Error('Check the expression and parentheses.')
position += match[0].length
return finite(Number(match[0]))
}
const value = expression()
if (position !== source.length) throw new Error('Check the expression and parentheses.')
return finite(value)
}
export function monthlyPayment(price: number, down: number, years: number, annualRate: number): number {
;[price, down, years, annualRate].forEach(finite)
if (price <= 0 || down < 0 || down > price) throw new Error('Enter a positive home price and a down payment from zero to the home price.')
if (years <= 0 || !Number.isInteger(years * 12) || annualRate < 0) throw new Error('Enter a positive loan term and a rate of zero or more.')
const principal = price - down
const months = years * 12
const rate = annualRate / 1200
if (!principal) return 0
return finite(rate === 0 ? principal / months : principal * rate / -Math.expm1(-months * Math.log1p(rate)))
}
export function bodyMassIndex(weight: number, height: number, unit: 'metric' | 'imperial'): number {
;[weight, height].forEach(finite)
if (weight <= 0 || height <= 0) throw new Error('Enter a weight and height greater than zero.')
const kg = unit === 'imperial' ? weight * 0.45359237 : weight
const metres = unit === 'imperial' ? height * 0.0254 : height / 100
return finite(kg / (metres * metres))
}
export function tipAmounts(bill: number, percent: number, people: number) {
;[bill, percent, people].forEach(finite)
if (bill < 0 || percent < 0) throw new Error('Bill and tip percentage must be zero or more.')
if (!Number.isSafeInteger(people) || people < 1) throw new Error('Enter a whole number of people, at least one.')
const billCents = Math.round(bill * 100)
const tipCents = Math.round(billCents * percent / 100)
const totalCents = billCents + tipCents
if (!Number.isSafeInteger(totalCents)) throw new Error('Bill or tip is too large.')
return { tip: tipCents / 100, total: totalCents / 100, perPerson: Math.round(totalCents / people) / 100 }
}