@instructure/quiz-interactions
Version:
A React UI component Library for quiz interaction types.
220 lines (193 loc) • 6.72 kB
JavaScript
import Decimal from 'decimal.js'
import sortBy from 'lodash/sortBy'
import {
formatScientificNotation,
isScientificNotation,
parseScientificNotation,
} from '@instructure/quiz-scientific-notation'
import t from '@instructure/quiz-i18n/format-message'
import setFromArray from '../../../util/setFromArray'
import {mathjsIsLoaded, mathjsLoadingWrapper} from '../common/util'
export function toPrecision(n, precision) {
if (isScientificNotation(n)) {
const [mantissa, exponent] = parseScientificNotation(n)
return formatScientificNotation(Number(mantissa).toFixed(precision), exponent)
}
return isNaN(n) ? n : Number(n).toFixed(precision)
}
function toDecimal(n) {
if (isScientificNotation(n)) {
const [mantissa, exponent] = parseScientificNotation(n)
return new Decimal(`${mantissa}e${exponent}`)
}
return new Decimal(n)
}
function toInput(n) {
return isScientificNotation(n) ? toDecimal(n) : Number(n)
}
const decimalToString = input => {
if (input.minMaxScientific) {
return toDecimal(input.value).toString()
}
return toDecimal(input.value)
}
const toScientificNotation = (n, precision) => {
const [mantissa, exponent] = n
.toExponential(precision)
.match(/^(.+)e\+?(.+)$/)
.slice(1)
return formatScientificNotation(mantissa, exponent)
}
const randomBetween = (min, max, random) => {
return max.minus(min).times(random()).plus(min)
}
const randomInRange = (min, max, precision, random, scientificNotation) => {
if (min.greaterThan(max)) {
return null
}
const value = randomBetween(min, max, random)
return scientificNotation
? toScientificNotation(value, precision)
: fixNegativeZero(value.toFixed(precision))
}
const fixNegativeZero = num => {
const math = mathjsLoadingWrapper.mathjs
if (math.isZero(num) && num[0] === '-') {
return num.substring(1)
}
return num
}
const generateInputs = (variables, random) => {
return variables.map(({name, min, max, precision}) => {
const minMaxScientific = isScientificNotation(min) && isScientificNotation(max)
const input = {
name,
value: randomInRange(
toDecimal(min),
toDecimal(max),
Number(precision),
random,
minMaxScientific,
),
}
if (minMaxScientific) {
input.minMaxScientific = true
}
return input
})
}
const hashifyInputs = (inputs, toInputNumber) => {
const result = {}
inputs.forEach(input => {
result[input.name] =
toInputNumber !== decimalToString ? toInputNumber(input.value) : toInputNumber(input)
})
return result
}
const serializeInputs = inputs => {
return sortBy(inputs, input => input.name)
.map(input => `${input.name}:${input.value}`)
.join('|')
}
const SEARCH_COUNT = 100
export const searchForFormulaSolution = (
variables,
formula,
previousSolutions,
precision,
scientificNotation = false,
random = Math.random,
searchCount = SEARCH_COUNT,
genInputs = generateInputs,
) => {
const usedInputCache = setFromArray(previousSolutions.map(soln => serializeInputs(soln.inputs)))
const precisionValue = Math.min(precision || 0, 16)
for (let i = 0; i < searchCount; i++) {
const inputs = genInputs(variables, random)
if (usedInputCache.has(serializeInputs(inputs))) {
continue // eslint-disable-line no-continue
}
if (!mathjsIsLoaded()) {
throw new Error('attempt to call searchForFormulaSolution before math.js is loaded')
}
try {
const math = mathjsLoadingWrapper.mathjs
// we replace function e() with the constant e
// and function pi() with the constant pi
// this is to match legacy canvas quizzes behavior which accepted both e/pi and e()/pi()
const node = math.parse(formula)
const transformed = node.transform((node, path, parent) => {
if (node.isFunctionNode && node.fn.isSymbolNode && node.fn.name === 'e') {
return new math.expression.node.ConstantNode(Math.E)
} else if (node.isFunctionNode && node.fn.isSymbolNode && node.fn.name === 'pi') {
return new math.expression.node.ConstantNode(Math.PI)
} else {
return node
}
})
const compiled = transformed.compile()
let outputArray
try {
// first try using Decimal for floating point precision
outputArray = compiled.eval(hashifyInputs(inputs, toDecimal))
} catch {
// Since some mathjs methods don't support Decimal,
// catch here and try again without it.
// If this one throws, it will be caught by outer try block
try {
outputArray = compiled.eval(hashifyInputs(inputs, toInput))
} catch {
// If everything else fails, then the inputs are converted to string
// because mathjs couldn't support Decimal in scientific notation
// if this one throws, it will be caugh by outer try block
outputArray = compiled.eval(hashifyInputs(inputs, decimalToString))
}
}
// `mathjs.eval` spits out a number if the formula has one clause, and a ResultSet object
// with an `entries` array field if it has multiple clauses
const output = outputArray.entries
? outputArray.entries[outputArray.entries.length - 1]
: outputArray
if (output !== void 0) {
const decimal = new Decimal(output.toString())
if (!decimal.isNaN() && decimal.isFinite()) {
return {
inputs,
output: scientificNotation
? toScientificNotation(decimal, precisionValue)
: decimal.toFixed(precisionValue),
}
}
}
} catch {
return null
}
}
return null
}
export const buildSolutionsGeneratedMessage = (status, solutionsCount) => {
if (status === STATUS_FAILED && solutionsCount === 0) {
return t('We were not able to find any solutions.')
} else if (status === STATUS_FAILED) {
return t(
`{ number, plural,
one {We were only able to find 1 solution.}
other {We were only able to find # solutions.}
}`,
{number: solutionsCount},
)
} else {
return t(
`{ number, plural,
one {We were able to find 1 solution.}
other {We were able to find # solutions.}
}`,
{number: solutionsCount},
)
}
}
export const STATUS_STOPPED = 'STATUS_STOPPED'
export const STATUS_RUNNING = 'STATUS_RUNNING'
export const STATUS_FAILED = 'STATUS_FAILED'
export const STATUS_FORMULA_SETUP_INVALID = 'STATUS_FORMULA_SETUP_INVALID'
export const STATUS_CANCELED = 'STATUS_CANCELED'