@cocreate/calculate
Version:
A handy vanilla JavaScript calculator, concatenate multiple elements containing integers & execute calculates. Can be used for creating invoices,making payments & any kind of complex calculate. Easily configured using HTML5 attributes and/or JavaScript A
977 lines (883 loc) • 35.5 kB
JavaScript
import observer from "@cocreate/observer"; // Module for observing DOM mutations.
import { queryElements } from "@cocreate/utils"; // Utility for querying DOM elements.
import "@cocreate/element-prototype"; // Include custom element prototype extensions.
// Initializes the calculation elements within the document.
function init() {
// Select all elements in the document with a "calculate" attribute.
let calculateElements = document.querySelectorAll("[calculate]");
// Initialize each of the selected elements.
initElements(calculateElements);
}
// Initialize multiple elements by invoking initElement for each.
function initElements(elements) {
// Iterate through the collection of elements and initialize each one.
for (let el of elements) initElement(el);
}
// Asynchronously initializes an individual element with setup for calculations.
async function initElement(element) {
// Fetch the calculate string from the element's attribute.
let calculate = element.getAttribute("calculate");
// Return early if the calculate string contains placeholders or template syntax.
if (calculate.includes("{{") || calculate.includes("{[")) return;
// Extract selectors from the calculate attribute value.
let selectors = getSelectors(element.attributes["calculate"].value);
// Iterate through each selector and set up elements impacted by them.
for (let i = 0; i < selectors.length; i++) {
// Find input elements based on the selector criteria.
let inputs = queryElements({
element,
selector: selectors[i],
type: "selector"
});
// Set up events for each found input element.
for (let input of inputs) {
initEvent(element, input);
}
// Initialize an observer to monitor newly added nodes that match the selector.
observer.init({
name: "calculateSelectorInit",
types: ["addedNodes"],
selector: selectors[i],
// Callback function to run when nodes matching the selector are added.
callback(mutation) {
// Initialize events for the new element and update calculation.
initEvent(element, mutation.target);
setCalcationValue(element);
}
});
}
// Set initial calculation value when an element is being initialized.
setCalcationValue(element);
}
/**
* Extracts selector strings starting with '$' from within parentheses in a given string.
* Ensures that the keyword (selector, closest, etc.) is followed by a word boundary.
* Returns an array of unique matching selector strings.
*
* @param {string} string The input string to search.
* @returns {string[]} An array of unique matching selector strings found.
*/
function getSelectors(string) {
if (!string) {
return []; // Return an empty array if input is null, undefined, or an empty string
}
// Regex provided by user: Finds parentheses, allows optional space,
// captures from '$' + keyword + word boundary + rest until ')'
const selectorRegex =
/\(\s*(\$(?:selector|closest|parent|next|previous|document|frame|top)\b[^)]*)\)/g;
const uniqueMatches = new Set();
let match;
// Use regex.exec() in a loop to find all matches
while ((match = selectorRegex.exec(string)) !== null) {
// match[1] contains the captured group (e.g., "$selector .button")
// Add the trimmed match to the Set. Duplicates are automatically ignored.
uniqueMatches.add(match[1].trim());
// Handle potential edge case with zero-length matches to prevent infinite loops
// Although less likely with this specific regex, it's good practice
if (match.index === selectorRegex.lastIndex) {
selectorRegex.lastIndex++;
}
}
return Array.from(uniqueMatches);
}
// Map: Key = InputElement, Value = Array of Elements to update
const initializedInputs = new Map();
/**
* Associates an element to be updated with a specific input element.
* Attaches an 'input' event listener to the input element only once.
* When the input event fires, calls setCalcationValue for all associated elements.
*
* @param {HTMLElement} element The element that needs to be updated.
* @param {HTMLInputElement} input The input element that triggers the update.
*/
function initEvent(element, input) {
const calculteElements = initializedInputs.get(input);
if (calculteElements) {
calculteElements.add(element);
return;
}
initializedInputs.set(input, new Set([element]));
input.addEventListener("input", function () {
const elementsToUpdate = initializedInputs.get(input);
if (elementsToUpdate) {
for (const element of elementsToUpdate) {
setCalcationValue(element);
}
}
});
}
// Asynchronously set the calculated value for the given element.
async function setCalcationValue(element) {
// Get the expression or formula from the element's "calculate" attribute.
let calString = await getValues(element);
// Evaluate the formula and set the calculated value back to the element.
element.setValue(calculate(calString));
}
// Asynchronously retrieve values necessary for computing the calculation attribute of an element.
async function getValues(element) {
// Get the expression that needs to be evaluated from the "calculate" attribute.
let calculate = element.getAttribute("calculate");
// Parse the expression to extract any selectors which values need to contribute to calculation.
let selectors = getSelectors(element.attributes["calculate"].value);
// For each selector, retrieve and calculate the respective value.
for (let i = 0; i < selectors.length; i++) {
let value = 0; // Default value in case no input is found for the selector.
// Query DOM elements based on selector.
let inputs = queryElements({
element,
selector: selectors[i],
type: "selector"
});
// Iterate through inputs/elements matched by the selector.
for (let input of inputs) {
// Initialize event listeners on inputs so that changes can update the calculation.
initEvent(element, input);
let val = null;
// Attempt to get the value from the input element, if it can provide it.
if (input.getValue) {
val = Number(await input.getValue());
}
// Only accumulate valid numeric values.
if (!Number.isNaN(val)) {
value += val;
} else {
console.warn(
`Invalid value for selector "${selectors[i]}". Defaulting to 0.`
);
}
}
// Replace the placeholder in the calculation expression with the accumulated value.
calculate = calculate.replaceAll(`(${selectors[i]})`, value);
}
return calculate; // Return the resolved calculation expression.
}
// Defines mathematical constants available in expressions.
const constants = { PI: Math.PI, E: Math.E };
// Defines allowed mathematical functions and maps them to their respective JavaScript Math counterparts.
const functions = {
abs: Math.abs, // Absolute value
ceil: Math.ceil, // Ceiling function
floor: Math.floor, // Floor function
round: Math.round, // Round to nearest integer
max: Math.max, // Maximum value (assumes arity 2 in RPN)
min: Math.min, // Minimum value (assumes arity 2 in RPN)
pow: Math.pow, // Exponentiation
sqrt: Math.sqrt, // Square root
log: Math.log, // Natural logarithm
sin: Math.sin, // Sine function
cos: Math.cos, // Cosine function
tan: Math.tan // Tangent function
};
/**
* Tokenizer for Core Mathematical Expressions.
* Converts a mathematical expression string (without ternary operators) into an array of tokens.
* Each token is an object with 'type' and 'value'.
* Supported types: 'literal', 'identifier', 'operator', 'function', 'open_paren', 'close_paren', 'comma', 'unknown'.
*
* @param {string} expression - The mathematical expression string to tokenize.
* @returns {Array<object>} An array of token objects.
*/
function tokenizeCore(expression) {
const tokens = [];
// Regular expression to capture recognized patterns:
// 1: Numbers (integer or decimal)
// 2: Identifiers (variable names, function names, constants like PI) - starting with letter or _, followed by letters, numbers, or _
// 3: Multi-character comparison operators (>=, <=, ==, !=)
// 4: Single-character operators, parentheses, comma, or whitespace that might be part of an operator later (like '<' in '<=')
// 5: Whitespace sequences
const regex =
/(\d+(?:\.\d+)?)|([a-zA-Z_][a-zA-Z0-9_]*)|(>=|<=|==|!=)|([\+\-\*\/%^ \(\),<>])|(\s+)/g;
let match;
let lastToken = null; // Keep track of the previous token to help identify unary minus
let expectedIndex = 0; // Track the expected start index of the next token
// Iterate through all matches found by the regex in the expression string
while ((match = regex.exec(expression)) !== null) {
/* ... */ // Assume original complex logic might be here, focusing on the provided snippet
// Check for unrecognized character sequences between valid tokens
if (match.index !== expectedIndex) {
const gap = expression.substring(expectedIndex, match.index);
// Ignore gaps that are only whitespace
if (gap.trim() !== "") {
// Issue a warning for unrecognized characters, but attempt to continue tokenizing
console.warn(
`Invalid character sequence found near index ${expectedIndex}: '${gap}'`
);
// Note: Consider adding an 'error' or 'unknown_sequence' token type if needed for stricter parsing downstream
}
}
let tokenStr = match[0]; // The matched string
let token; // The token object to be created
// Group 5: Whitespace
if (match[5]) {
// Ignore whitespace; simply advance the expected index
expectedIndex = regex.lastIndex;
continue; // Move to the next match
}
// Group 1: Literal (Number)
if (match[1]) {
token = { type: "literal", value: parseFloat(tokenStr) };
}
// Group 2: Identifier (Constant or Function Name)
else if (match[2]) {
if (tokenStr in constants) {
// If it's a known constant, treat it as a literal value
token = { type: "literal", value: constants[tokenStr] };
} else if (tokenStr in functions) {
// If it's a known function name
token = { type: "function", value: tokenStr };
} else {
// If it's not a known constant or function
console.warn(`Unknown identifier: ${tokenStr}`);
// Create an 'unknown' token type. This allows the process to continue,
// but downstream functions (like Shunting-Yard or evaluator) should handle or ignore it.
token = { type: "unknown", value: tokenStr };
}
}
// Group 3: Comparison Operators (>=, <=, ==, !=)
else if (match[3]) {
token = {
type: "operator",
value: tokenStr,
precedence: 1, // Lower precedence than arithmetic operators
associativity: "left"
};
}
// Group 4: Other Operators/Punctuation (+, -, *, /, %, ^, (, ), ,, <, >)
else if (match[4]) {
tokenStr = tokenStr.trim(); // Remove surrounding whitespace if captured by the regex group
// This check should ideally not be needed if regex correctly excludes pure whitespace via group 5, but acts as a safeguard.
if (!tokenStr) {
expectedIndex = regex.lastIndex;
continue;
}
// Distinguish between unary minus and binary subtraction
if (tokenStr === "-") {
const isUnary =
lastToken === null || // Beginning of expression
["operator", "open_paren", "comma"].includes(
lastToken?.type
); // Following an operator, open parenthesis, or comma
token = isUnary
? {
// Unary minus
type: "operator",
value: "unary-", // Special value to distinguish from binary minus
precedence: 4, // Higher precedence than multiplication/division
associativity: "right"
}
: {
// Binary minus (subtraction)
type: "operator",
value: "-",
precedence: 2, // Same precedence as addition
associativity: "left"
};
} else if (tokenStr === "+") {
// Note: Unary plus is often ignored or handled implicitly, but could be tokenized similarly if needed.
token = {
type: "operator",
value: "+",
precedence: 2,
associativity: "left"
};
} else if (tokenStr === "*" || tokenStr === "/") {
token = {
type: "operator",
value: tokenStr,
precedence: 3,
associativity: "left"
};
} else if (tokenStr === "%") {
// Modulo operator
token = {
type: "operator",
value: tokenStr,
precedence: 3,
associativity: "left"
};
} else if (tokenStr === "^") {
// Exponentiation operator
token = {
type: "operator",
value: "^",
precedence: 5,
associativity: "right"
}; // Highest precedence, right-associative
} else if (tokenStr === ">" || tokenStr === "<") {
// Simple comparison operators
token = {
type: "operator",
value: tokenStr,
precedence: 1,
associativity: "left"
}; // Same low precedence as other comparisons
} else if (tokenStr === "(") {
token = { type: "open_paren", value: "(" };
} else if (tokenStr === ")") {
token = { type: "close_paren", value: ")" };
} else if (tokenStr === ",") {
// Comma, typically used as function argument separator
token = { type: "comma", value: "," };
} else {
// If the character is captured by group 4 but isn't handled above
console.warn(
`Unhandled punctuation/operator token: '${tokenStr}'`
);
// Mark as unknown
token = { type: "unknown", value: tokenStr };
}
} else {
// This block should theoretically not be reached if the regex covers all cases correctly.
// It acts as a fallback error indicator.
console.warn(
`Tokenizer internal regex error: No group matched near '${expression.substring(
expectedIndex
)}'`
);
// Optionally create an 'error' token or skip
}
// If a valid (or unknown) token was created, add it to the list
if (token) {
tokens.push(token);
lastToken = token; // Update lastToken for the next iteration's unary minus check
}
// Advance the expected starting position for the next token search
expectedIndex = regex.lastIndex;
}
// After the loop, check if the entire string was consumed by the tokenizer
if (expectedIndex < expression.length) {
const trailing = expression.substring(expectedIndex).trim();
// If there are non-whitespace characters remaining, they were not tokenized
if (trailing) {
console.warn(`Invalid trailing characters ignored: '${trailing}'`);
}
}
return tokens;
}
/**
* Converts an infix token stream (from tokenizeCore) to a Reverse Polish Notation (RPN) queue.
* Implements the Shunting-yard algorithm. Does not handle ternary operators directly.
* Handles operator precedence and associativity, functions, and parentheses.
*
* @param {Array<object>} tokens - The array of token objects from tokenizeCore.
* @returns {Array<object>} An array of token objects arranged in RPN order.
*/
function shuntingYardCore(tokens) {
const outputQueue = []; // Stores the RPN output
const operatorStack = []; // Temporary stack for operators, functions, and parentheses
// Helper function to view the top element of a stack without removing it
const peek = (stack) => (stack.length > 0 ? stack[stack.length - 1] : null);
// Process each token from the input array
for (const token of tokens) {
// If the token is invalid or marked as unknown by the tokenizer, skip it.
if (!token || token.type === "unknown") {
console.warn(
`Shunting-Yard skipping unknown token: ${token?.value}`
);
continue;
}
/* ... */ // Assume original SY logic structure might be here
// Handle token based on its type
switch (token.type) {
case "literal":
// Literals (numbers) are immediately added to the output queue.
outputQueue.push(token);
break;
case "function":
// Functions are pushed onto the operator stack.
operatorStack.push(token);
break;
case "comma":
// Commas indicate separation of arguments in a function call.
// Pop operators from the stack to the output until an opening parenthesis is found.
while (peek(operatorStack)?.type !== "open_paren") {
const topOp = peek(operatorStack);
// If the stack becomes empty before finding '(', it implies mismatched parentheses or comma.
if (topOp === null) {
console.warn(
"Mismatched comma or parentheses detected during comma handling."
);
// Break to prevent infinite loop in error case. Consider throwing an error for stricter handling.
break;
}
outputQueue.push(operatorStack.pop());
}
// The '(' remains on the stack to mark the start of the arguments.
break;
case "operator":
// Handle operators based on precedence and associativity.
const currentOp = token;
let topOp = peek(operatorStack);
// While there's an operator on the stack with higher or equal precedence (considering associativity)...
while (
topOp?.type === "operator" &&
((currentOp.associativity === "left" &&
currentOp.precedence <= topOp.precedence) ||
(currentOp.associativity === "right" &&
currentOp.precedence < topOp.precedence))
) {
// Pop the operator from the stack to the output queue.
outputQueue.push(operatorStack.pop());
topOp = peek(operatorStack); // Check the new top operator
}
// Push the current operator onto the stack.
operatorStack.push(currentOp);
break;
case "open_paren":
// Opening parentheses are always pushed onto the operator stack.
operatorStack.push(token);
break;
case "close_paren":
// Closing parenthesis: process operators until the matching opening parenthesis.
let foundOpenParen = false;
while (peek(operatorStack)?.type !== "open_paren") {
const opToPop = operatorStack.pop();
// If the stack runs out before finding '(', parentheses are mismatched.
if (!opToPop) {
console.warn(
"Mismatched parentheses: Closing parenthesis found without matching open parenthesis."
);
// Break to prevent potential infinite loop if stack is empty.
break;
}
outputQueue.push(opToPop);
}
// If an opening parenthesis was found, pop it from the stack (it's not added to output).
if (peek(operatorStack)?.type === "open_paren") {
operatorStack.pop();
foundOpenParen = true;
} // Mismatch case already warned inside the loop
// If the token preceding the parenthesis pair was a function name, pop it to the output.
// This places the function after its arguments in RPN.
if (peek(operatorStack)?.type === "function") {
outputQueue.push(operatorStack.pop());
}
break;
default:
// Should not happen if tokenizer provides known types, but acts as a safeguard.
console.warn(
`Unknown token type encountered in Shunting-Yard: ${token.type}`
);
}
}
// After processing all tokens, pop any remaining operators/functions from the stack to the output queue.
while (peek(operatorStack) !== null) {
const op = operatorStack.pop();
// If an opening parenthesis is found here, it means parentheses were mismatched.
if (op.type === "open_paren") {
console.warn(
"Mismatched parentheses: Open parenthesis remaining on stack at the end."
);
// Continue processing other operators, but the RPN is likely invalid.
} else {
outputQueue.push(op);
}
}
return outputQueue; // Return the final RPN token queue.
}
/**
* Evaluates a Reverse Polish Notation (RPN) token queue generated by shuntingYardCore.
* Performs the actual calculations based on operators and function calls.
* Handles basic error conditions like stack underflow, division by zero, and unknown tokens.
* Returns the numerical result or null if evaluation fails.
*
* @param {Array<object>} rpnQueue - The array of token objects in RPN order.
* @returns {number | null} The calculated numerical result, or null if an error occurs.
*/
function evaluateRPNCore(rpnQueue) {
if (!rpnQueue || rpnQueue.length === 0) {
return null;
}
const evaluationStack = []; // Stack used to hold operands during RPN evaluation.
for (const token of rpnQueue) {
if (token.type === "literal") {
evaluationStack.push(token.value);
} else if (token.type === "operator") {
if (token.value === "unary-") {
if (evaluationStack.length < 1) {
console.warn(
`Stack underflow error during unary '-' operation.`
);
return null;
}
// Pop the operand, negate it, and push the result back.
evaluationStack.push(-evaluationStack.pop());
}
// Handle binary operators
else {
// Requires two operands on the stack.
if (evaluationStack.length < 2) {
console.warn(
`Stack underflow error during binary '${token.value}' operation.`
);
return null;
}
// Pop the top two operands. Note: the second operand (b) is popped first.
const b = evaluationStack.pop();
const a = evaluationStack.pop();
let result;
// Perform the operation based on the operator value.
switch (token.value) {
case "+":
result = a + b;
break;
case "-":
result = a - b;
break;
case "*":
result = a * b;
break;
case "/":
// Check for division by zero.
if (b === 0) {
console.warn("Division by zero encountered.");
return null;
}
result = a / b;
break;
case "%":
// Check for modulo by zero (JavaScript's % operator returns NaN in this case).
if (b === 0) {
console.warn("Modulo by zero encountered.");
return null; // Return null for consistency with division by zero.
}
result = a % b;
break;
case "^":
result = Math.pow(a, b);
break;
// Comparison operators return 1 for true, 0 for false, consistent with C-like behavior.
case ">":
result = a > b ? 1 : 0;
break;
case "<":
result = a < b ? 1 : 0;
break;
case ">=":
result = a >= b ? 1 : 0;
break;
case "<=":
result = a <= b ? 1 : 0;
break;
case "==":
result = a === b ? 1 : 0;
break; // Use strict equality
case "!=":
result = a !== b ? 1 : 0;
break; // Use strict inequality
default:
console.warn(
`Unknown operator encountered during evaluation: ${token.value}`
);
return null;
}
evaluationStack.push(result);
}
}
// If the token is a function call...
else if (token.type === "function") {
// Look up the function implementation (assuming 'functions' is a globally accessible object/map).
const func = functions[token.value];
if (!func) {
// If the function name is not found in the available functions.
console.warn(
`Unknown function encountered during evaluation: ${token.value}`
);
return null;
}
// Determine the expected number of arguments (arity) for the function.
// Note: Relying solely on func.length can be unreliable for functions with default parameters or rest parameters.
// This example uses a mix of func.length and hardcoded arity for common Math functions.
// A more robust implementation might store arity explicitly alongside the function definition.
let arity = func.length; // Default assumption based on function definition
// Explicitly define arity for functions where .length might be ambiguous or for built-ins.
// (Example adjustments - tailor these to the actual functions defined)
if (["max", "min", "pow"].includes(token.value)) arity = 2;
if (
[
"sqrt",
"abs",
"ceil",
"floor",
"round",
"log",
"sin",
"cos",
"tan"
].includes(token.value)
)
arity = 1;
// Add more overrides as needed for your specific function set.
// Check if there are enough operands on the stack for the function's arity.
if (evaluationStack.length < arity) {
console.warn(
`Stack underflow for function '${token.value}'. Need ${arity} args, found ${evaluationStack.length}.`
);
return null;
}
// Pop the required number of arguments from the stack.
const args = [];
for (let i = 0; i < arity; i++) {
args.push(evaluationStack.pop());
}
try {
// Call the function with the arguments. Since they were popped in reverse,
const functionResult = func(...args.reverse());
evaluationStack.push(functionResult);
} catch (funcError) {
// Catch errors that might occur during the function's execution (e.g., Math.log(-1) -> NaN, invalid inputs).
console.warn(
`Error executing function '${token.value}': ${funcError.message}`
);
return null;
}
} else {
// If a token type other than literal, operator, or function appears in the RPN queue.
// This might indicate an error in the RPN generation (Shunting-Yard).
console.warn(
`Unknown RPN token type encountered: ${token?.type} (Value: ${token?.value})`
);
return null;
}
}
// After processing all tokens, the evaluation stack should contain exactly one value: the final result.
if (evaluationStack.length !== 1) {
// If the stack size is not 1, it usually indicates an invalid expression or a bug in the RPN conversion/evaluation.
console.warn(
`Evaluation finished with invalid stack size: ${
evaluationStack.length
}. Contents: ${JSON.stringify(evaluationStack)}`
);
return null;
}
const finalResult = evaluationStack[0];
// Validate the final result to ensure it's a usable number.
// Allow 0 and 1 specifically, as they are valid results from boolean comparisons.
if (finalResult === 0 || finalResult === 1) {
return finalResult;
}
// Check if the result is a finite number (not NaN, Infinity, or -Infinity).
if (typeof finalResult !== "number" || !Number.isFinite(finalResult)) {
console.warn(
`Final evaluation result is not a valid finite number: ${finalResult}`
);
return null;
}
return finalResult;
}
/**
* Parses a string expression to find the components of a *top-level* ternary expression.
* Looks for the first '?' and its corresponding ':' at the same parenthesis nesting level.
* Returns an object with { condition, trueExpr, falseExpr } if found, otherwise null.
* Respects parentheses to avoid splitting nested ternaries incorrectly.
*
* @param {string} expression - The expression string to parse.
* @returns {{condition: string, trueExpr: string, falseExpr: string} | null} Object with parts or null.
*/
function parseTernary(expression) {
let parenLevel = 0; // Tracks nesting level of parentheses
let qIndex = -1; // Index of the top-level '?'
// First pass: Find the first '?' at parenthesis level 0.
for (let i = 0; i < expression.length; i++) {
const char = expression[i];
if (char === "(") {
parenLevel++;
} else if (char === ")") {
parenLevel--;
} else if (char === "?" && parenLevel === 0) {
// Found the '?' at the top level
qIndex = i;
break; // Stop searching once the first top-level '?' is found
}
// Error check: If parenLevel goes below 0, parentheses are mismatched.
if (parenLevel < 0) {
console.warn(
`Mismatched parentheses detected (too many ')') in ternary structure near index ${i}.`
);
return null; // Indicate parsing failure due to invalid structure
}
}
// If no top-level '?' was found, it's not a simple ternary structure at this level.
if (qIndex === -1) {
return null;
}
// Second pass: Find the corresponding ':' at level 0, starting *after* the '?'.
parenLevel = 0; // Reset parenthesis level counter for the colon search
let cIndex = -1; // Index of the top-level ':'
for (let i = qIndex + 1; i < expression.length; i++) {
const char = expression[i];
if (char === "(") {
parenLevel++;
} else if (char === ")") {
parenLevel--;
} else if (char === ":" && parenLevel === 0) {
// Found the matching ':' at the top level
cIndex = i;
break; // Stop searching
}
// Error check during colon search
if (parenLevel < 0) {
console.warn(
`Mismatched parentheses detected (too many ')') after '?' in ternary structure near index ${i}.`
);
return null; // Indicate parsing failure
}
}
// If no matching top-level ':' was found after the '?', the structure is invalid.
if (cIndex === -1) {
console.warn(
`Invalid ternary structure: No matching top-level ':' found for '?' at index ${qIndex}.`
);
return null;
}
// Extract the three parts of the ternary expression.
const condition = expression.substring(0, qIndex).trim();
const trueExpr = expression.substring(qIndex + 1, cIndex).trim();
const falseExpr = expression.substring(cIndex + 1).trim();
// Validate that none of the parts are empty after trimming.
if (!condition || !trueExpr || !falseExpr) {
console.warn(
`Invalid ternary structure: empty part detected in "${expression}". Condition: "${condition}", True: "${trueExpr}", False: "${falseExpr}".`
);
return null;
}
return { condition, trueExpr, falseExpr };
}
/**
* Main entry point for evaluating a mathematical expression string.
* Handles nested ternary operators (`? :`) recursively with short-circuiting.
* For non-ternary expressions or sub-expressions, it uses the core engine:
* Tokenizer -> Shunting-Yard -> RPN Evaluator.
* Provides graceful handling of common errors, returning null on failure.
*
* @param {string | any} expression - The expression string to evaluate. Non-string inputs are converted.
* @returns {number | null} The final calculated result, or null if evaluation fails or the expression is invalid.
*/
function calculate(expression) {
// Store the original input, converting to string if necessary, for logging context.
const originalExpr =
typeof expression === "string" ? expression : String(expression);
try {
// Ensure we are working with a trimmed string.
let currentExpr =
typeof expression === "string" ? expression.trim() : "";
// Handle empty or whitespace-only expressions immediately.
if (!currentExpr) {
// Warning is optional here, depends if empty input is expected or an error.
// console.warn("Expression is empty or evaluates to empty string.");
return null; // Return null for empty expression.
}
/* --- Optional Step: Remove Fully Wrapping Parentheses ---
* This simplifies parsing by removing redundant outer parentheses, e.g., "((1 + 2))" becomes "1 + 2".
* It iteratively unwraps as long as the outermost characters are '(' and ')'
* and they correctly balance across the entire contained expression.
*/
let unwrapped = false; // Flag to track if any unwrapping occurred (mainly for debugging)
while (currentExpr.startsWith("(") && currentExpr.endsWith(")")) {
let balance = 0;
let canUnwrap = true; // Assume it can be unwrapped unless proven otherwise
// Handle edge case like "()" which cannot be unwrapped to an empty string meaningfully here.
if (currentExpr.length <= 2) {
canUnwrap = false;
break;
}
// Check if the parentheses truly wrap the *entire* internal expression.
for (let i = 0; i < currentExpr.length; i++) {
if (currentExpr[i] === "(") balance++;
else if (currentExpr[i] === ")") balance--;
// If balance returns to 0 *before* the very last character,
// it means the parentheses don't wrap the whole thing, e.g., "(1) + (2)".
if (balance === 0 && i < currentExpr.length - 1) {
canUnwrap = false;
break;
}
// If balance goes negative at any point, parentheses are mismatched.
if (balance < 0) {
canUnwrap = false; // Should ideally be caught later, but good safeguard.
break;
}
}
// The final balance must also be 0 for the wrapping to be valid.
if (balance !== 0) {
canUnwrap = false;
}
// If the checks pass, perform the unwrap.
if (canUnwrap) {
currentExpr = currentExpr
.substring(1, currentExpr.length - 1)
.trim();
unwrapped = true;
} else {
// If cannot unwrap this layer, stop the unwrapping process.
break;
}
}
/* --- End Parenthesis Unwrapping --- */
// 1. Attempt to parse the current (potentially unwrapped) expression as a top-level ternary.
const ternaryParts = parseTernary(currentExpr);
// 2. If it successfully parsed as a ternary structure...
if (ternaryParts) {
// 2a. Recursively evaluate the condition part first.
const condResult = calculate(ternaryParts.condition);
// Handle cases where the condition itself fails to evaluate.
if (condResult === null) {
// Log a warning indicating the condition evaluation failed.
console.warn(
`Failed to evaluate ternary condition: "${ternaryParts.condition}" in context: "${originalExpr}". Defaulting to false branch.`
);
// Proceed as if the condition is false for robustness, evaluating the 'falseExpr'.
// Alternatively, could return null here to propagate the failure.
return calculate(ternaryParts.falseExpr);
}
// 2b. Short-circuiting: Evaluate *only* the required branch based on the condition result.
// The core evaluator returns 1 for true comparisons, 0 for false.
// Any non-zero number is treated as "truthy" here.
if (condResult) {
// Checks for truthiness (non-zero result)
return calculate(ternaryParts.trueExpr); // Evaluate the true branch recursively.
} else {
return calculate(ternaryParts.falseExpr); // Evaluate the false branch recursively.
}
}
// 3. If it's not a top-level ternary (or parseTernary returned null due to errors)...
else {
// Evaluate the expression using the standard core math engine pipeline.
const tokens = tokenizeCore(currentExpr);
const rpnQueue = shuntingYardCore(tokens);
// evaluateRPNCore handles internal errors (like division by zero, unknown tokens) and returns null on failure.
const result = evaluateRPNCore(rpnQueue);
return result; // Return the result (which could be a number or null).
}
} catch (error) {
// Catch any unexpected runtime errors during the calculation process.
const contextExpr =
originalExpr.length > 50
? originalExpr.substring(0, 47) + "..." // Truncate long expressions for logging
: originalExpr;
console.warn(
`Unexpected calculation error: ${error.message} (Expression context: "${contextExpr}")`,
error
);
return null;
}
}
observer.init([
{
name: "CoCreateCalculateChangeValue",
types: ["attributes"],
attributeFilter: ["calculate"],
callback(mutation) {
setCalcationValue(mutation.target);
}
},
{
name: "CoCreateCalculateInit",
types: ["addedNodes"],
selector: "[calculate]",
callback(mutation) {
initElement(mutation.target);
}
}
]);
init(); //
export default { initElements, initElement, calculate };