← 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 }
}