@cocreate/calculate 1.16.5 → 1.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/demo/index.html +97 -23
- package/package.json +1 -1
- package/src/index.js +159 -919
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
# [1.17.0](https://github.com/CoCreate-app/CoCreate-calculate/compare/v1.16.5...v1.17.0) (2026-03-01)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* Enhance element initialization and calculation logic ([ce7a6a0](https://github.com/CoCreate-app/CoCreate-calculate/commit/ce7a6a0cf42a63ee092fba38626179cbf573d5cb))
|
|
7
|
+
|
|
1
8
|
## [1.16.5](https://github.com/CoCreate-app/CoCreate-calculate/compare/v1.16.4...v1.16.5) (2026-02-09)
|
|
2
9
|
|
|
3
10
|
|
package/demo/index.html
CHANGED
|
@@ -1,25 +1,99 @@
|
|
|
1
1
|
<!DOCTYPE html>
|
|
2
2
|
<html lang="en">
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
3
|
+
<head>
|
|
4
|
+
<title>Calculate | CoCreateJS</title>
|
|
5
|
+
<!-- CoCreate Favicon -->
|
|
6
|
+
<link
|
|
7
|
+
rel="icon"
|
|
8
|
+
type="image/png"
|
|
9
|
+
sizes="32x32"
|
|
10
|
+
href="../assets/favicon.ico" />
|
|
11
|
+
|
|
12
|
+
<script>
|
|
13
|
+
// Prevent ServiceWorker InvalidStateError in sandboxed preview environments
|
|
14
|
+
// by safely hiding the serviceWorker API from feature detection before external scripts load.
|
|
15
|
+
try {
|
|
16
|
+
Object.defineProperty(navigator, 'serviceWorker', {
|
|
17
|
+
value: undefined,
|
|
18
|
+
configurable: true
|
|
19
|
+
});
|
|
20
|
+
} catch (e) {}
|
|
21
|
+
</script>
|
|
22
|
+
|
|
23
|
+
<style>
|
|
24
|
+
body { font-family: sans-serif; padding: 20px; max-width: 800px; margin: auto; }
|
|
25
|
+
.section { background: #f9f9f9; padding: 15px; margin-bottom: 20px; border-radius: 8px; border: 1px solid #ddd; }
|
|
26
|
+
input { margin-bottom: 10px; padding: 5px; }
|
|
27
|
+
label { display: inline-block; width: 150px; font-weight: bold; }
|
|
28
|
+
</style>
|
|
29
|
+
</head>
|
|
30
|
+
<body>
|
|
31
|
+
<h1>Safe Parser Calculate Examples</h1>
|
|
32
|
+
|
|
33
|
+
<div class="section">
|
|
34
|
+
<h3>Original DOM Selectors</h3>
|
|
35
|
+
<label>Self Value:</label>
|
|
36
|
+
<input value="12" calculate="$value * 100" /><br>
|
|
37
|
+
|
|
38
|
+
<label>Input 1 (id1):</label>
|
|
39
|
+
<input class="class1" key="total" id="id1" value="12" /><br>
|
|
40
|
+
|
|
41
|
+
<label>Input 2 (id2):</label>
|
|
42
|
+
<input class="class1" key="total" id="id2" value="13" /><br>
|
|
43
|
+
|
|
44
|
+
<label>Sum id1 + id2:</label>
|
|
45
|
+
<input id="te" calculate="($document #id1) + ($document #id2)" /><br>
|
|
46
|
+
|
|
47
|
+
<label>Key Total + 1:</label>
|
|
48
|
+
<input calculate="($document [key='total']) + 1" />
|
|
49
|
+
|
|
50
|
+
<h3 calculate="($document [key='total']) + ($document .class1)">sum</h3>
|
|
51
|
+
</div>
|
|
52
|
+
|
|
53
|
+
<div class="section">
|
|
54
|
+
<h3>Math Functions & Constants</h3>
|
|
55
|
+
<label>Base Number:</label>
|
|
56
|
+
<input id="baseNum" value="25" /><br>
|
|
57
|
+
|
|
58
|
+
<!-- Using Math.sqrt() -->
|
|
59
|
+
<label>Square Root:</label>
|
|
60
|
+
<input calculate="sqrt(($document #baseNum))" /><br>
|
|
61
|
+
|
|
62
|
+
<!-- Using Math.pow() -->
|
|
63
|
+
<label>Power (base^2):</label>
|
|
64
|
+
<input calculate="pow(($document #baseNum), 2)" /><br>
|
|
65
|
+
|
|
66
|
+
<!-- Using Math.max() with selectors -->
|
|
67
|
+
<label>Max Value:</label>
|
|
68
|
+
<input calculate="max(($document #id1), ($document #id2), 20)" /><br>
|
|
69
|
+
|
|
70
|
+
<!-- Using Constants -->
|
|
71
|
+
<label>Num * PI:</label>
|
|
72
|
+
<input calculate="round(($document #baseNum) * PI)" /><br>
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
<div class="section">
|
|
76
|
+
<h3>Ternary Conditionals (? :)</h3>
|
|
77
|
+
<label>User Age:</label>
|
|
78
|
+
<input id="age" value="20" /><br>
|
|
79
|
+
|
|
80
|
+
<!-- Basic Ternary String Output -->
|
|
81
|
+
<label>Status:</label>
|
|
82
|
+
<input calculate="($document #age) >= 18 ? 'Adult' : 'Minor'" readonly style="background: #eee;" /><br>
|
|
83
|
+
|
|
84
|
+
<!-- Nested Ternary for Tiers -->
|
|
85
|
+
<label>Score:</label>
|
|
86
|
+
<input id="score" value="85" /><br>
|
|
87
|
+
|
|
88
|
+
<label>Grade:</label>
|
|
89
|
+
<input calculate="($document #score) >= 90 ? 'A' : ($document #score) >= 80 ? 'B' : 'C'" readonly style="background: #eee;" /><br>
|
|
90
|
+
|
|
91
|
+
<!-- Math inside Ternary condition -->
|
|
92
|
+
<label>Combined Goal:</label>
|
|
93
|
+
<input calculate="(($document #id1) + ($document #id2)) > 20 ? 'Goal Reached!' : 'Keep Going...'" readonly style="background: #eee; width: 200px;" />
|
|
94
|
+
</div>
|
|
95
|
+
|
|
96
|
+
<!--<script src="../dist/CoCreate-calculate.js"></script>-->
|
|
97
|
+
<script src="https://geneomes.com/dist/CoCreate.js"></script>
|
|
98
|
+
</body>
|
|
99
|
+
</html>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cocreate/calculate",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.17.0",
|
|
4
4
|
"description": "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 API.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"calculate",
|
package/src/index.js
CHANGED
|
@@ -1,60 +1,60 @@
|
|
|
1
1
|
import observer from "@cocreate/observer"; // Module for observing DOM mutations.
|
|
2
|
-
import { queryElements } from "@cocreate/utils"; // Utility for querying DOM elements.
|
|
2
|
+
import { queryElements, safeParse } from "@cocreate/utils"; // Utility for querying DOM elements and safe parsing.
|
|
3
3
|
import "@cocreate/element-prototype"; // Include custom element prototype extensions.
|
|
4
4
|
|
|
5
5
|
// Initializes the calculation elements within the document.
|
|
6
6
|
function init() {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
// Select all elements in the document with a "calculate" attribute.
|
|
8
|
+
let calculateElements = document.querySelectorAll("[calculate]");
|
|
9
|
+
// Initialize each of the selected elements.
|
|
10
|
+
initElements(calculateElements);
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
// Initialize multiple elements by invoking initElement for each.
|
|
14
14
|
function initElements(elements) {
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
// Iterate through the collection of elements and initialize each one.
|
|
16
|
+
for (let el of elements) initElement(el);
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
// Asynchronously initializes an individual element with setup for calculations.
|
|
20
20
|
async function initElement(element) {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
21
|
+
// Fetch the calculate string from the element's attribute.
|
|
22
|
+
let calculate = element.getAttribute("calculate");
|
|
23
|
+
// Return early if the calculate string contains placeholders or template syntax.
|
|
24
|
+
if (calculate.includes("{{") || calculate.includes("{[")) return;
|
|
25
|
+
|
|
26
|
+
// Extract selectors from the calculate attribute value.
|
|
27
|
+
let selectors = getSelectors(element.attributes["calculate"].value);
|
|
28
|
+
|
|
29
|
+
// Iterate through each selector and set up elements impacted by them.
|
|
30
|
+
for (let i = 0; i < selectors.length; i++) {
|
|
31
|
+
// Find input elements based on the selector criteria.
|
|
32
|
+
let inputs = queryElements({
|
|
33
|
+
element,
|
|
34
|
+
selector: selectors[i],
|
|
35
|
+
type: "selector"
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// Set up events for each found input element.
|
|
39
|
+
for (let input of inputs) {
|
|
40
|
+
initEvent(element, input);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Initialize an observer to monitor newly added nodes that match the selector.
|
|
44
|
+
observer.init({
|
|
45
|
+
name: "calculateSelectorInit",
|
|
46
|
+
types: ["addedNodes"],
|
|
47
|
+
selector: selectors[i],
|
|
48
|
+
// Callback function to run when nodes matching the selector are added.
|
|
49
|
+
callback(mutation) {
|
|
50
|
+
// Initialize events for the new element and update calculation.
|
|
51
|
+
initEvent(element, mutation.target);
|
|
52
|
+
setCalcationValue(element);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
// Set initial calculation value when an element is being initialized.
|
|
57
|
+
setCalcationValue(element);
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
/**
|
|
@@ -66,32 +66,32 @@ async function initElement(element) {
|
|
|
66
66
|
* @returns {string[]} An array of unique matching selector strings found.
|
|
67
67
|
*/
|
|
68
68
|
function getSelectors(string) {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
69
|
+
if (!string) {
|
|
70
|
+
return []; // Return an empty array if input is null, undefined, or an empty string
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Regex provided by user: Finds parentheses, allows optional space,
|
|
74
|
+
// captures from '$' + keyword + word boundary + rest until ')'
|
|
75
|
+
const selectorRegex =
|
|
76
|
+
/\(\s*(\$(?:selector|closest|parent|next|previous|document|frame|top)\b[^)]*)\)/g;
|
|
77
|
+
|
|
78
|
+
const uniqueMatches = new Set();
|
|
79
|
+
let match;
|
|
80
|
+
|
|
81
|
+
// Use regex.exec() in a loop to find all matches
|
|
82
|
+
while ((match = selectorRegex.exec(string)) !== null) {
|
|
83
|
+
// match[1] contains the captured group (e.g., "$selector .button")
|
|
84
|
+
// Add the trimmed match to the Set. Duplicates are automatically ignored.
|
|
85
|
+
uniqueMatches.add(match[1].trim());
|
|
86
|
+
|
|
87
|
+
// Handle potential edge case with zero-length matches to prevent infinite loops
|
|
88
|
+
// Although less likely with this specific regex, it's good practice
|
|
89
|
+
if (match.index === selectorRegex.lastIndex) {
|
|
90
|
+
selectorRegex.lastIndex++;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return Array.from(uniqueMatches);
|
|
95
95
|
}
|
|
96
96
|
|
|
97
97
|
// Map: Key = InputElement, Value = Array of Elements to update
|
|
@@ -106,871 +106,111 @@ const initializedInputs = new Map();
|
|
|
106
106
|
* @param {HTMLInputElement} input The input element that triggers the update.
|
|
107
107
|
*/
|
|
108
108
|
function initEvent(element, input) {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
109
|
+
const calculteElements = initializedInputs.get(input);
|
|
110
|
+
if (calculteElements) {
|
|
111
|
+
calculteElements.add(element);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
initializedInputs.set(input, new Set([element]));
|
|
116
|
+
|
|
117
|
+
input.addEventListener("input", function () {
|
|
118
|
+
const elementsToUpdate = initializedInputs.get(input);
|
|
119
|
+
|
|
120
|
+
if (elementsToUpdate) {
|
|
121
|
+
for (const element of elementsToUpdate) {
|
|
122
|
+
setCalcationValue(element);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
126
|
}
|
|
127
127
|
|
|
128
128
|
// Asynchronously set the calculated value for the given element.
|
|
129
129
|
async function setCalcationValue(element) {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
130
|
+
// Get the expression or formula from the element's "calculate" attribute.
|
|
131
|
+
let calString = await getValues(element);
|
|
132
|
+
// Evaluate the formula and set the calculated value back to the element.
|
|
133
|
+
element.setValue(calculate(calString));
|
|
134
134
|
}
|
|
135
135
|
|
|
136
136
|
// Asynchronously retrieve values necessary for computing the calculation attribute of an element.
|
|
137
137
|
async function getValues(element) {
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
138
|
+
// Get the expression that needs to be evaluated from the "calculate" attribute.
|
|
139
|
+
let calculateAttr = element.getAttribute("calculate");
|
|
140
|
+
|
|
141
|
+
// Parse the expression to extract any selectors which values need to contribute to calculation.
|
|
142
|
+
let selectors = getSelectors(element.attributes["calculate"].value);
|
|
143
|
+
|
|
144
|
+
// For each selector, retrieve and calculate the respective value.
|
|
145
|
+
for (let i = 0; i < selectors.length; i++) {
|
|
146
|
+
let value = 0; // Default value in case no input is found for the selector.
|
|
147
|
+
|
|
148
|
+
// Query DOM elements based on selector.
|
|
149
|
+
let inputs = queryElements({
|
|
150
|
+
element,
|
|
151
|
+
selector: selectors[i],
|
|
152
|
+
type: "selector"
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// Iterate through inputs/elements matched by the selector.
|
|
156
|
+
for (let input of inputs) {
|
|
157
|
+
// Initialize event listeners on inputs so that changes can update the calculation.
|
|
158
|
+
initEvent(element, input);
|
|
159
|
+
let val = null;
|
|
160
|
+
|
|
161
|
+
// Attempt to get the value from the input element, if it can provide it.
|
|
162
|
+
if (input.getValue) {
|
|
163
|
+
val = Number(await input.getValue());
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Only accumulate valid numeric values.
|
|
167
|
+
if (!Number.isNaN(val)) {
|
|
168
|
+
value += val;
|
|
169
|
+
} else {
|
|
170
|
+
console.warn(
|
|
171
|
+
`Invalid value for selector "${selectors[i]}". Defaulting to 0.`
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Replace the placeholder in the calculation expression with the accumulated value.
|
|
177
|
+
calculateAttr = calculateAttr.replaceAll(`(${selectors[i]})`, value);
|
|
178
|
+
}
|
|
179
|
+
return calculateAttr; // Return the resolved calculation expression.
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
-
// Defines mathematical constants available in expressions.
|
|
183
|
-
const constants = { PI: Math.PI, E: Math.E };
|
|
184
|
-
|
|
185
|
-
// Defines allowed mathematical functions and maps them to their respective JavaScript Math counterparts.
|
|
186
|
-
const functions = {
|
|
187
|
-
abs: Math.abs, // Absolute value
|
|
188
|
-
ceil: Math.ceil, // Ceiling function
|
|
189
|
-
floor: Math.floor, // Floor function
|
|
190
|
-
round: Math.round, // Round to nearest integer
|
|
191
|
-
max: Math.max, // Maximum value (assumes arity 2 in RPN)
|
|
192
|
-
min: Math.min, // Minimum value (assumes arity 2 in RPN)
|
|
193
|
-
pow: Math.pow, // Exponentiation
|
|
194
|
-
sqrt: Math.sqrt, // Square root
|
|
195
|
-
log: Math.log, // Natural logarithm
|
|
196
|
-
sin: Math.sin, // Sine function
|
|
197
|
-
cos: Math.cos, // Cosine function
|
|
198
|
-
tan: Math.tan // Tangent function
|
|
199
|
-
};
|
|
200
|
-
|
|
201
182
|
/**
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
* Each token is an object with 'type' and 'value'.
|
|
205
|
-
* Supported types: 'literal', 'identifier', 'operator', 'function', 'open_paren', 'close_paren', 'comma', 'unknown'.
|
|
183
|
+
* Main entry point for evaluating an expression string securely.
|
|
184
|
+
* Passes the string off to the 0-dependency Recursive Descent Parser.
|
|
206
185
|
*
|
|
207
|
-
* @param {string} expression - The
|
|
208
|
-
* @
|
|
186
|
+
* @param {string | any} expression - The expression string to evaluate.
|
|
187
|
+
* @param {object} context - Optional JSON context for dot notation variables.
|
|
188
|
+
* @returns {any} The evaluated result, or null if an error occurs.
|
|
209
189
|
*/
|
|
210
|
-
function
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
// 1: Numbers (integer or decimal)
|
|
214
|
-
// 2: Identifiers (variable names, function names, constants like PI) - starting with letter or _, followed by letters, numbers, or _
|
|
215
|
-
// 3: Multi-character comparison operators (>=, <=, ==, !=)
|
|
216
|
-
// 4: Single-character operators, parentheses, comma, or whitespace that might be part of an operator later (like '<' in '<=')
|
|
217
|
-
// 5: Whitespace sequences
|
|
218
|
-
const regex =
|
|
219
|
-
/(\d+(?:\.\d+)?)|([a-zA-Z_][a-zA-Z0-9_]*)|(>=|<=|==|!=)|([\+\-\*\/%^ \(\),<>])|(\s+)/g;
|
|
220
|
-
let match;
|
|
221
|
-
let lastToken = null; // Keep track of the previous token to help identify unary minus
|
|
222
|
-
let expectedIndex = 0; // Track the expected start index of the next token
|
|
223
|
-
|
|
224
|
-
// Iterate through all matches found by the regex in the expression string
|
|
225
|
-
while ((match = regex.exec(expression)) !== null) {
|
|
226
|
-
/* ... */ // Assume original complex logic might be here, focusing on the provided snippet
|
|
227
|
-
|
|
228
|
-
// Check for unrecognized character sequences between valid tokens
|
|
229
|
-
if (match.index !== expectedIndex) {
|
|
230
|
-
const gap = expression.substring(expectedIndex, match.index);
|
|
231
|
-
// Ignore gaps that are only whitespace
|
|
232
|
-
if (gap.trim() !== "") {
|
|
233
|
-
// Issue a warning for unrecognized characters, but attempt to continue tokenizing
|
|
234
|
-
console.warn(
|
|
235
|
-
`Invalid character sequence found near index ${expectedIndex}: '${gap}'`
|
|
236
|
-
);
|
|
237
|
-
// Note: Consider adding an 'error' or 'unknown_sequence' token type if needed for stricter parsing downstream
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
let tokenStr = match[0]; // The matched string
|
|
242
|
-
let token; // The token object to be created
|
|
243
|
-
|
|
244
|
-
// Group 5: Whitespace
|
|
245
|
-
if (match[5]) {
|
|
246
|
-
// Ignore whitespace; simply advance the expected index
|
|
247
|
-
expectedIndex = regex.lastIndex;
|
|
248
|
-
continue; // Move to the next match
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
// Group 1: Literal (Number)
|
|
252
|
-
if (match[1]) {
|
|
253
|
-
token = { type: "literal", value: parseFloat(tokenStr) };
|
|
254
|
-
}
|
|
255
|
-
// Group 2: Identifier (Constant or Function Name)
|
|
256
|
-
else if (match[2]) {
|
|
257
|
-
if (tokenStr in constants) {
|
|
258
|
-
// If it's a known constant, treat it as a literal value
|
|
259
|
-
token = { type: "literal", value: constants[tokenStr] };
|
|
260
|
-
} else if (tokenStr in functions) {
|
|
261
|
-
// If it's a known function name
|
|
262
|
-
token = { type: "function", value: tokenStr };
|
|
263
|
-
} else {
|
|
264
|
-
// If it's not a known constant or function
|
|
265
|
-
console.warn(`Unknown identifier: ${tokenStr}`);
|
|
266
|
-
// Create an 'unknown' token type. This allows the process to continue,
|
|
267
|
-
// but downstream functions (like Shunting-Yard or evaluator) should handle or ignore it.
|
|
268
|
-
token = { type: "unknown", value: tokenStr };
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
// Group 3: Comparison Operators (>=, <=, ==, !=)
|
|
272
|
-
else if (match[3]) {
|
|
273
|
-
token = {
|
|
274
|
-
type: "operator",
|
|
275
|
-
value: tokenStr,
|
|
276
|
-
precedence: 1, // Lower precedence than arithmetic operators
|
|
277
|
-
associativity: "left"
|
|
278
|
-
};
|
|
279
|
-
}
|
|
280
|
-
// Group 4: Other Operators/Punctuation (+, -, *, /, %, ^, (, ), ,, <, >)
|
|
281
|
-
else if (match[4]) {
|
|
282
|
-
tokenStr = tokenStr.trim(); // Remove surrounding whitespace if captured by the regex group
|
|
283
|
-
// This check should ideally not be needed if regex correctly excludes pure whitespace via group 5, but acts as a safeguard.
|
|
284
|
-
if (!tokenStr) {
|
|
285
|
-
expectedIndex = regex.lastIndex;
|
|
286
|
-
continue;
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
// Distinguish between unary minus and binary subtraction
|
|
290
|
-
if (tokenStr === "-") {
|
|
291
|
-
const isUnary =
|
|
292
|
-
lastToken === null || // Beginning of expression
|
|
293
|
-
["operator", "open_paren", "comma"].includes(
|
|
294
|
-
lastToken?.type
|
|
295
|
-
); // Following an operator, open parenthesis, or comma
|
|
296
|
-
|
|
297
|
-
token = isUnary
|
|
298
|
-
? {
|
|
299
|
-
// Unary minus
|
|
300
|
-
type: "operator",
|
|
301
|
-
value: "unary-", // Special value to distinguish from binary minus
|
|
302
|
-
precedence: 4, // Higher precedence than multiplication/division
|
|
303
|
-
associativity: "right"
|
|
304
|
-
}
|
|
305
|
-
: {
|
|
306
|
-
// Binary minus (subtraction)
|
|
307
|
-
type: "operator",
|
|
308
|
-
value: "-",
|
|
309
|
-
precedence: 2, // Same precedence as addition
|
|
310
|
-
associativity: "left"
|
|
311
|
-
};
|
|
312
|
-
} else if (tokenStr === "+") {
|
|
313
|
-
// Note: Unary plus is often ignored or handled implicitly, but could be tokenized similarly if needed.
|
|
314
|
-
token = {
|
|
315
|
-
type: "operator",
|
|
316
|
-
value: "+",
|
|
317
|
-
precedence: 2,
|
|
318
|
-
associativity: "left"
|
|
319
|
-
};
|
|
320
|
-
} else if (tokenStr === "*" || tokenStr === "/") {
|
|
321
|
-
token = {
|
|
322
|
-
type: "operator",
|
|
323
|
-
value: tokenStr,
|
|
324
|
-
precedence: 3,
|
|
325
|
-
associativity: "left"
|
|
326
|
-
};
|
|
327
|
-
} else if (tokenStr === "%") {
|
|
328
|
-
// Modulo operator
|
|
329
|
-
token = {
|
|
330
|
-
type: "operator",
|
|
331
|
-
value: tokenStr,
|
|
332
|
-
precedence: 3,
|
|
333
|
-
associativity: "left"
|
|
334
|
-
};
|
|
335
|
-
} else if (tokenStr === "^") {
|
|
336
|
-
// Exponentiation operator
|
|
337
|
-
token = {
|
|
338
|
-
type: "operator",
|
|
339
|
-
value: "^",
|
|
340
|
-
precedence: 5,
|
|
341
|
-
associativity: "right"
|
|
342
|
-
}; // Highest precedence, right-associative
|
|
343
|
-
} else if (tokenStr === ">" || tokenStr === "<") {
|
|
344
|
-
// Simple comparison operators
|
|
345
|
-
token = {
|
|
346
|
-
type: "operator",
|
|
347
|
-
value: tokenStr,
|
|
348
|
-
precedence: 1,
|
|
349
|
-
associativity: "left"
|
|
350
|
-
}; // Same low precedence as other comparisons
|
|
351
|
-
} else if (tokenStr === "(") {
|
|
352
|
-
token = { type: "open_paren", value: "(" };
|
|
353
|
-
} else if (tokenStr === ")") {
|
|
354
|
-
token = { type: "close_paren", value: ")" };
|
|
355
|
-
} else if (tokenStr === ",") {
|
|
356
|
-
// Comma, typically used as function argument separator
|
|
357
|
-
token = { type: "comma", value: "," };
|
|
358
|
-
} else {
|
|
359
|
-
// If the character is captured by group 4 but isn't handled above
|
|
360
|
-
console.warn(
|
|
361
|
-
`Unhandled punctuation/operator token: '${tokenStr}'`
|
|
362
|
-
);
|
|
363
|
-
// Mark as unknown
|
|
364
|
-
token = { type: "unknown", value: tokenStr };
|
|
365
|
-
}
|
|
366
|
-
} else {
|
|
367
|
-
// This block should theoretically not be reached if the regex covers all cases correctly.
|
|
368
|
-
// It acts as a fallback error indicator.
|
|
369
|
-
console.warn(
|
|
370
|
-
`Tokenizer internal regex error: No group matched near '${expression.substring(
|
|
371
|
-
expectedIndex
|
|
372
|
-
)}'`
|
|
373
|
-
);
|
|
374
|
-
// Optionally create an 'error' token or skip
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
// If a valid (or unknown) token was created, add it to the list
|
|
378
|
-
if (token) {
|
|
379
|
-
tokens.push(token);
|
|
380
|
-
lastToken = token; // Update lastToken for the next iteration's unary minus check
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
// Advance the expected starting position for the next token search
|
|
384
|
-
expectedIndex = regex.lastIndex;
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
// After the loop, check if the entire string was consumed by the tokenizer
|
|
388
|
-
if (expectedIndex < expression.length) {
|
|
389
|
-
const trailing = expression.substring(expectedIndex).trim();
|
|
390
|
-
// If there are non-whitespace characters remaining, they were not tokenized
|
|
391
|
-
if (trailing) {
|
|
392
|
-
console.warn(`Invalid trailing characters ignored: '${trailing}'`);
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
return tokens;
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
/**
|
|
400
|
-
* Converts an infix token stream (from tokenizeCore) to a Reverse Polish Notation (RPN) queue.
|
|
401
|
-
* Implements the Shunting-yard algorithm. Does not handle ternary operators directly.
|
|
402
|
-
* Handles operator precedence and associativity, functions, and parentheses.
|
|
403
|
-
*
|
|
404
|
-
* @param {Array<object>} tokens - The array of token objects from tokenizeCore.
|
|
405
|
-
* @returns {Array<object>} An array of token objects arranged in RPN order.
|
|
406
|
-
*/
|
|
407
|
-
function shuntingYardCore(tokens) {
|
|
408
|
-
const outputQueue = []; // Stores the RPN output
|
|
409
|
-
const operatorStack = []; // Temporary stack for operators, functions, and parentheses
|
|
410
|
-
|
|
411
|
-
// Helper function to view the top element of a stack without removing it
|
|
412
|
-
const peek = (stack) => (stack.length > 0 ? stack[stack.length - 1] : null);
|
|
413
|
-
|
|
414
|
-
// Process each token from the input array
|
|
415
|
-
for (const token of tokens) {
|
|
416
|
-
// If the token is invalid or marked as unknown by the tokenizer, skip it.
|
|
417
|
-
if (!token || token.type === "unknown") {
|
|
418
|
-
console.warn(
|
|
419
|
-
`Shunting-Yard skipping unknown token: ${token?.value}`
|
|
420
|
-
);
|
|
421
|
-
continue;
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
/* ... */ // Assume original SY logic structure might be here
|
|
425
|
-
|
|
426
|
-
// Handle token based on its type
|
|
427
|
-
switch (token.type) {
|
|
428
|
-
case "literal":
|
|
429
|
-
// Literals (numbers) are immediately added to the output queue.
|
|
430
|
-
outputQueue.push(token);
|
|
431
|
-
break;
|
|
432
|
-
|
|
433
|
-
case "function":
|
|
434
|
-
// Functions are pushed onto the operator stack.
|
|
435
|
-
operatorStack.push(token);
|
|
436
|
-
break;
|
|
437
|
-
|
|
438
|
-
case "comma":
|
|
439
|
-
// Commas indicate separation of arguments in a function call.
|
|
440
|
-
// Pop operators from the stack to the output until an opening parenthesis is found.
|
|
441
|
-
while (peek(operatorStack)?.type !== "open_paren") {
|
|
442
|
-
const topOp = peek(operatorStack);
|
|
443
|
-
// If the stack becomes empty before finding '(', it implies mismatched parentheses or comma.
|
|
444
|
-
if (topOp === null) {
|
|
445
|
-
console.warn(
|
|
446
|
-
"Mismatched comma or parentheses detected during comma handling."
|
|
447
|
-
);
|
|
448
|
-
// Break to prevent infinite loop in error case. Consider throwing an error for stricter handling.
|
|
449
|
-
break;
|
|
450
|
-
}
|
|
451
|
-
outputQueue.push(operatorStack.pop());
|
|
452
|
-
}
|
|
453
|
-
// The '(' remains on the stack to mark the start of the arguments.
|
|
454
|
-
break;
|
|
455
|
-
|
|
456
|
-
case "operator":
|
|
457
|
-
// Handle operators based on precedence and associativity.
|
|
458
|
-
const currentOp = token;
|
|
459
|
-
let topOp = peek(operatorStack);
|
|
460
|
-
// While there's an operator on the stack with higher or equal precedence (considering associativity)...
|
|
461
|
-
while (
|
|
462
|
-
topOp?.type === "operator" &&
|
|
463
|
-
((currentOp.associativity === "left" &&
|
|
464
|
-
currentOp.precedence <= topOp.precedence) ||
|
|
465
|
-
(currentOp.associativity === "right" &&
|
|
466
|
-
currentOp.precedence < topOp.precedence))
|
|
467
|
-
) {
|
|
468
|
-
// Pop the operator from the stack to the output queue.
|
|
469
|
-
outputQueue.push(operatorStack.pop());
|
|
470
|
-
topOp = peek(operatorStack); // Check the new top operator
|
|
471
|
-
}
|
|
472
|
-
// Push the current operator onto the stack.
|
|
473
|
-
operatorStack.push(currentOp);
|
|
474
|
-
break;
|
|
475
|
-
|
|
476
|
-
case "open_paren":
|
|
477
|
-
// Opening parentheses are always pushed onto the operator stack.
|
|
478
|
-
operatorStack.push(token);
|
|
479
|
-
break;
|
|
480
|
-
|
|
481
|
-
case "close_paren":
|
|
482
|
-
// Closing parenthesis: process operators until the matching opening parenthesis.
|
|
483
|
-
let foundOpenParen = false;
|
|
484
|
-
while (peek(operatorStack)?.type !== "open_paren") {
|
|
485
|
-
const opToPop = operatorStack.pop();
|
|
486
|
-
// If the stack runs out before finding '(', parentheses are mismatched.
|
|
487
|
-
if (!opToPop) {
|
|
488
|
-
console.warn(
|
|
489
|
-
"Mismatched parentheses: Closing parenthesis found without matching open parenthesis."
|
|
490
|
-
);
|
|
491
|
-
// Break to prevent potential infinite loop if stack is empty.
|
|
492
|
-
break;
|
|
493
|
-
}
|
|
494
|
-
outputQueue.push(opToPop);
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
// If an opening parenthesis was found, pop it from the stack (it's not added to output).
|
|
498
|
-
if (peek(operatorStack)?.type === "open_paren") {
|
|
499
|
-
operatorStack.pop();
|
|
500
|
-
foundOpenParen = true;
|
|
501
|
-
} // Mismatch case already warned inside the loop
|
|
502
|
-
|
|
503
|
-
// If the token preceding the parenthesis pair was a function name, pop it to the output.
|
|
504
|
-
// This places the function after its arguments in RPN.
|
|
505
|
-
if (peek(operatorStack)?.type === "function") {
|
|
506
|
-
outputQueue.push(operatorStack.pop());
|
|
507
|
-
}
|
|
508
|
-
break;
|
|
509
|
-
|
|
510
|
-
default:
|
|
511
|
-
// Should not happen if tokenizer provides known types, but acts as a safeguard.
|
|
512
|
-
console.warn(
|
|
513
|
-
`Unknown token type encountered in Shunting-Yard: ${token.type}`
|
|
514
|
-
);
|
|
515
|
-
}
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
// After processing all tokens, pop any remaining operators/functions from the stack to the output queue.
|
|
519
|
-
while (peek(operatorStack) !== null) {
|
|
520
|
-
const op = operatorStack.pop();
|
|
521
|
-
// If an opening parenthesis is found here, it means parentheses were mismatched.
|
|
522
|
-
if (op.type === "open_paren") {
|
|
523
|
-
console.warn(
|
|
524
|
-
"Mismatched parentheses: Open parenthesis remaining on stack at the end."
|
|
525
|
-
);
|
|
526
|
-
// Continue processing other operators, but the RPN is likely invalid.
|
|
527
|
-
} else {
|
|
528
|
-
outputQueue.push(op);
|
|
529
|
-
}
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
return outputQueue; // Return the final RPN token queue.
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
/**
|
|
536
|
-
* Evaluates a Reverse Polish Notation (RPN) token queue generated by shuntingYardCore.
|
|
537
|
-
* Performs the actual calculations based on operators and function calls.
|
|
538
|
-
* Handles basic error conditions like stack underflow, division by zero, and unknown tokens.
|
|
539
|
-
* Returns the numerical result or null if evaluation fails.
|
|
540
|
-
*
|
|
541
|
-
* @param {Array<object>} rpnQueue - The array of token objects in RPN order.
|
|
542
|
-
* @returns {number | null} The calculated numerical result, or null if an error occurs.
|
|
543
|
-
*/
|
|
544
|
-
function evaluateRPNCore(rpnQueue) {
|
|
545
|
-
if (!rpnQueue || rpnQueue.length === 0) {
|
|
546
|
-
return null;
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
const evaluationStack = []; // Stack used to hold operands during RPN evaluation.
|
|
550
|
-
|
|
551
|
-
for (const token of rpnQueue) {
|
|
552
|
-
if (token.type === "literal") {
|
|
553
|
-
evaluationStack.push(token.value);
|
|
554
|
-
} else if (token.type === "operator") {
|
|
555
|
-
if (token.value === "unary-") {
|
|
556
|
-
if (evaluationStack.length < 1) {
|
|
557
|
-
console.warn(
|
|
558
|
-
`Stack underflow error during unary '-' operation.`
|
|
559
|
-
);
|
|
560
|
-
return null;
|
|
561
|
-
}
|
|
562
|
-
// Pop the operand, negate it, and push the result back.
|
|
563
|
-
evaluationStack.push(-evaluationStack.pop());
|
|
564
|
-
}
|
|
565
|
-
// Handle binary operators
|
|
566
|
-
else {
|
|
567
|
-
// Requires two operands on the stack.
|
|
568
|
-
if (evaluationStack.length < 2) {
|
|
569
|
-
console.warn(
|
|
570
|
-
`Stack underflow error during binary '${token.value}' operation.`
|
|
571
|
-
);
|
|
572
|
-
return null;
|
|
573
|
-
}
|
|
574
|
-
// Pop the top two operands. Note: the second operand (b) is popped first.
|
|
575
|
-
const b = evaluationStack.pop();
|
|
576
|
-
const a = evaluationStack.pop();
|
|
577
|
-
let result;
|
|
578
|
-
|
|
579
|
-
// Perform the operation based on the operator value.
|
|
580
|
-
switch (token.value) {
|
|
581
|
-
case "+":
|
|
582
|
-
result = a + b;
|
|
583
|
-
break;
|
|
584
|
-
case "-":
|
|
585
|
-
result = a - b;
|
|
586
|
-
break;
|
|
587
|
-
case "*":
|
|
588
|
-
result = a * b;
|
|
589
|
-
break;
|
|
590
|
-
case "/":
|
|
591
|
-
// Check for division by zero.
|
|
592
|
-
if (b === 0) {
|
|
593
|
-
console.warn("Division by zero encountered.");
|
|
594
|
-
return null;
|
|
595
|
-
}
|
|
596
|
-
result = a / b;
|
|
597
|
-
break;
|
|
598
|
-
case "%":
|
|
599
|
-
// Check for modulo by zero (JavaScript's % operator returns NaN in this case).
|
|
600
|
-
if (b === 0) {
|
|
601
|
-
console.warn("Modulo by zero encountered.");
|
|
602
|
-
return null; // Return null for consistency with division by zero.
|
|
603
|
-
}
|
|
604
|
-
result = a % b;
|
|
605
|
-
break;
|
|
606
|
-
case "^":
|
|
607
|
-
result = Math.pow(a, b);
|
|
608
|
-
break;
|
|
609
|
-
// Comparison operators return 1 for true, 0 for false, consistent with C-like behavior.
|
|
610
|
-
case ">":
|
|
611
|
-
result = a > b ? 1 : 0;
|
|
612
|
-
break;
|
|
613
|
-
case "<":
|
|
614
|
-
result = a < b ? 1 : 0;
|
|
615
|
-
break;
|
|
616
|
-
case ">=":
|
|
617
|
-
result = a >= b ? 1 : 0;
|
|
618
|
-
break;
|
|
619
|
-
case "<=":
|
|
620
|
-
result = a <= b ? 1 : 0;
|
|
621
|
-
break;
|
|
622
|
-
case "==":
|
|
623
|
-
result = a === b ? 1 : 0;
|
|
624
|
-
break; // Use strict equality
|
|
625
|
-
case "!=":
|
|
626
|
-
result = a !== b ? 1 : 0;
|
|
627
|
-
break; // Use strict inequality
|
|
628
|
-
default:
|
|
629
|
-
console.warn(
|
|
630
|
-
`Unknown operator encountered during evaluation: ${token.value}`
|
|
631
|
-
);
|
|
632
|
-
return null;
|
|
633
|
-
}
|
|
634
|
-
evaluationStack.push(result);
|
|
635
|
-
}
|
|
636
|
-
}
|
|
637
|
-
// If the token is a function call...
|
|
638
|
-
else if (token.type === "function") {
|
|
639
|
-
// Look up the function implementation (assuming 'functions' is a globally accessible object/map).
|
|
640
|
-
const func = functions[token.value];
|
|
641
|
-
if (!func) {
|
|
642
|
-
// If the function name is not found in the available functions.
|
|
643
|
-
console.warn(
|
|
644
|
-
`Unknown function encountered during evaluation: ${token.value}`
|
|
645
|
-
);
|
|
646
|
-
return null;
|
|
647
|
-
}
|
|
648
|
-
|
|
649
|
-
// Determine the expected number of arguments (arity) for the function.
|
|
650
|
-
// Note: Relying solely on func.length can be unreliable for functions with default parameters or rest parameters.
|
|
651
|
-
// This example uses a mix of func.length and hardcoded arity for common Math functions.
|
|
652
|
-
// A more robust implementation might store arity explicitly alongside the function definition.
|
|
653
|
-
let arity = func.length; // Default assumption based on function definition
|
|
654
|
-
// Explicitly define arity for functions where .length might be ambiguous or for built-ins.
|
|
655
|
-
// (Example adjustments - tailor these to the actual functions defined)
|
|
656
|
-
if (["max", "min", "pow"].includes(token.value)) arity = 2;
|
|
657
|
-
if (
|
|
658
|
-
[
|
|
659
|
-
"sqrt",
|
|
660
|
-
"abs",
|
|
661
|
-
"ceil",
|
|
662
|
-
"floor",
|
|
663
|
-
"round",
|
|
664
|
-
"log",
|
|
665
|
-
"sin",
|
|
666
|
-
"cos",
|
|
667
|
-
"tan"
|
|
668
|
-
].includes(token.value)
|
|
669
|
-
)
|
|
670
|
-
arity = 1;
|
|
671
|
-
// Add more overrides as needed for your specific function set.
|
|
672
|
-
|
|
673
|
-
// Check if there are enough operands on the stack for the function's arity.
|
|
674
|
-
if (evaluationStack.length < arity) {
|
|
675
|
-
console.warn(
|
|
676
|
-
`Stack underflow for function '${token.value}'. Need ${arity} args, found ${evaluationStack.length}.`
|
|
677
|
-
);
|
|
678
|
-
return null;
|
|
679
|
-
}
|
|
680
|
-
|
|
681
|
-
// Pop the required number of arguments from the stack.
|
|
682
|
-
const args = [];
|
|
683
|
-
for (let i = 0; i < arity; i++) {
|
|
684
|
-
args.push(evaluationStack.pop());
|
|
685
|
-
}
|
|
686
|
-
|
|
687
|
-
try {
|
|
688
|
-
// Call the function with the arguments. Since they were popped in reverse,
|
|
689
|
-
const functionResult = func(...args.reverse());
|
|
690
|
-
evaluationStack.push(functionResult);
|
|
691
|
-
} catch (funcError) {
|
|
692
|
-
// Catch errors that might occur during the function's execution (e.g., Math.log(-1) -> NaN, invalid inputs).
|
|
693
|
-
console.warn(
|
|
694
|
-
`Error executing function '${token.value}': ${funcError.message}`
|
|
695
|
-
);
|
|
696
|
-
return null;
|
|
697
|
-
}
|
|
698
|
-
} else {
|
|
699
|
-
// If a token type other than literal, operator, or function appears in the RPN queue.
|
|
700
|
-
// This might indicate an error in the RPN generation (Shunting-Yard).
|
|
701
|
-
console.warn(
|
|
702
|
-
`Unknown RPN token type encountered: ${token?.type} (Value: ${token?.value})`
|
|
703
|
-
);
|
|
704
|
-
return null;
|
|
705
|
-
}
|
|
706
|
-
}
|
|
707
|
-
|
|
708
|
-
// After processing all tokens, the evaluation stack should contain exactly one value: the final result.
|
|
709
|
-
if (evaluationStack.length !== 1) {
|
|
710
|
-
// If the stack size is not 1, it usually indicates an invalid expression or a bug in the RPN conversion/evaluation.
|
|
711
|
-
console.warn(
|
|
712
|
-
`Evaluation finished with invalid stack size: ${
|
|
713
|
-
evaluationStack.length
|
|
714
|
-
}. Contents: ${JSON.stringify(evaluationStack)}`
|
|
715
|
-
);
|
|
716
|
-
return null;
|
|
717
|
-
}
|
|
718
|
-
|
|
719
|
-
const finalResult = evaluationStack[0];
|
|
720
|
-
|
|
721
|
-
// Validate the final result to ensure it's a usable number.
|
|
722
|
-
// Allow 0 and 1 specifically, as they are valid results from boolean comparisons.
|
|
723
|
-
if (finalResult === 0 || finalResult === 1) {
|
|
724
|
-
return finalResult;
|
|
725
|
-
}
|
|
726
|
-
// Check if the result is a finite number (not NaN, Infinity, or -Infinity).
|
|
727
|
-
if (typeof finalResult !== "number" || !Number.isFinite(finalResult)) {
|
|
728
|
-
console.warn(
|
|
729
|
-
`Final evaluation result is not a valid finite number: ${finalResult}`
|
|
730
|
-
);
|
|
731
|
-
return null;
|
|
732
|
-
}
|
|
733
|
-
|
|
734
|
-
return finalResult;
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
/**
|
|
738
|
-
* Parses a string expression to find the components of a *top-level* ternary expression.
|
|
739
|
-
* Looks for the first '?' and its corresponding ':' at the same parenthesis nesting level.
|
|
740
|
-
* Returns an object with { condition, trueExpr, falseExpr } if found, otherwise null.
|
|
741
|
-
* Respects parentheses to avoid splitting nested ternaries incorrectly.
|
|
742
|
-
*
|
|
743
|
-
* @param {string} expression - The expression string to parse.
|
|
744
|
-
* @returns {{condition: string, trueExpr: string, falseExpr: string} | null} Object with parts or null.
|
|
745
|
-
*/
|
|
746
|
-
function parseTernary(expression) {
|
|
747
|
-
let parenLevel = 0; // Tracks nesting level of parentheses
|
|
748
|
-
let qIndex = -1; // Index of the top-level '?'
|
|
749
|
-
|
|
750
|
-
// First pass: Find the first '?' at parenthesis level 0.
|
|
751
|
-
for (let i = 0; i < expression.length; i++) {
|
|
752
|
-
const char = expression[i];
|
|
753
|
-
if (char === "(") {
|
|
754
|
-
parenLevel++;
|
|
755
|
-
} else if (char === ")") {
|
|
756
|
-
parenLevel--;
|
|
757
|
-
} else if (char === "?" && parenLevel === 0) {
|
|
758
|
-
// Found the '?' at the top level
|
|
759
|
-
qIndex = i;
|
|
760
|
-
break; // Stop searching once the first top-level '?' is found
|
|
761
|
-
}
|
|
762
|
-
|
|
763
|
-
// Error check: If parenLevel goes below 0, parentheses are mismatched.
|
|
764
|
-
if (parenLevel < 0) {
|
|
765
|
-
console.warn(
|
|
766
|
-
`Mismatched parentheses detected (too many ')') in ternary structure near index ${i}.`
|
|
767
|
-
);
|
|
768
|
-
return null; // Indicate parsing failure due to invalid structure
|
|
769
|
-
}
|
|
770
|
-
}
|
|
771
|
-
|
|
772
|
-
// If no top-level '?' was found, it's not a simple ternary structure at this level.
|
|
773
|
-
if (qIndex === -1) {
|
|
774
|
-
return null;
|
|
775
|
-
}
|
|
776
|
-
|
|
777
|
-
// Second pass: Find the corresponding ':' at level 0, starting *after* the '?'.
|
|
778
|
-
parenLevel = 0; // Reset parenthesis level counter for the colon search
|
|
779
|
-
let cIndex = -1; // Index of the top-level ':'
|
|
780
|
-
for (let i = qIndex + 1; i < expression.length; i++) {
|
|
781
|
-
const char = expression[i];
|
|
782
|
-
if (char === "(") {
|
|
783
|
-
parenLevel++;
|
|
784
|
-
} else if (char === ")") {
|
|
785
|
-
parenLevel--;
|
|
786
|
-
} else if (char === ":" && parenLevel === 0) {
|
|
787
|
-
// Found the matching ':' at the top level
|
|
788
|
-
cIndex = i;
|
|
789
|
-
break; // Stop searching
|
|
790
|
-
}
|
|
791
|
-
// Error check during colon search
|
|
792
|
-
if (parenLevel < 0) {
|
|
793
|
-
console.warn(
|
|
794
|
-
`Mismatched parentheses detected (too many ')') after '?' in ternary structure near index ${i}.`
|
|
795
|
-
);
|
|
796
|
-
return null; // Indicate parsing failure
|
|
797
|
-
}
|
|
798
|
-
}
|
|
799
|
-
|
|
800
|
-
// If no matching top-level ':' was found after the '?', the structure is invalid.
|
|
801
|
-
if (cIndex === -1) {
|
|
802
|
-
console.warn(
|
|
803
|
-
`Invalid ternary structure: No matching top-level ':' found for '?' at index ${qIndex}.`
|
|
804
|
-
);
|
|
805
|
-
return null;
|
|
806
|
-
}
|
|
807
|
-
|
|
808
|
-
// Extract the three parts of the ternary expression.
|
|
809
|
-
const condition = expression.substring(0, qIndex).trim();
|
|
810
|
-
const trueExpr = expression.substring(qIndex + 1, cIndex).trim();
|
|
811
|
-
const falseExpr = expression.substring(cIndex + 1).trim();
|
|
812
|
-
|
|
813
|
-
// Validate that none of the parts are empty after trimming.
|
|
814
|
-
if (!condition || !trueExpr || !falseExpr) {
|
|
815
|
-
console.warn(
|
|
816
|
-
`Invalid ternary structure: empty part detected in "${expression}". Condition: "${condition}", True: "${trueExpr}", False: "${falseExpr}".`
|
|
817
|
-
);
|
|
818
|
-
return null;
|
|
819
|
-
}
|
|
820
|
-
|
|
821
|
-
return { condition, trueExpr, falseExpr };
|
|
822
|
-
}
|
|
823
|
-
|
|
824
|
-
/**
|
|
825
|
-
* Main entry point for evaluating a mathematical expression string.
|
|
826
|
-
* Handles nested ternary operators (`? :`) recursively with short-circuiting.
|
|
827
|
-
* For non-ternary expressions or sub-expressions, it uses the core engine:
|
|
828
|
-
* Tokenizer -> Shunting-Yard -> RPN Evaluator.
|
|
829
|
-
* Provides graceful handling of common errors, returning null on failure.
|
|
830
|
-
*
|
|
831
|
-
* @param {string | any} expression - The expression string to evaluate. Non-string inputs are converted.
|
|
832
|
-
* @returns {number | null} The final calculated result, or null if evaluation fails or the expression is invalid.
|
|
833
|
-
*/
|
|
834
|
-
function calculate(expression) {
|
|
835
|
-
// Store the original input, converting to string if necessary, for logging context.
|
|
836
|
-
const originalExpr =
|
|
837
|
-
typeof expression === "string" ? expression : String(expression);
|
|
838
|
-
|
|
839
|
-
try {
|
|
840
|
-
// Ensure we are working with a trimmed string.
|
|
841
|
-
let currentExpr =
|
|
842
|
-
typeof expression === "string" ? expression.trim() : "";
|
|
843
|
-
|
|
844
|
-
// Handle empty or whitespace-only expressions immediately.
|
|
845
|
-
if (!currentExpr) {
|
|
846
|
-
// Warning is optional here, depends if empty input is expected or an error.
|
|
847
|
-
// console.warn("Expression is empty or evaluates to empty string.");
|
|
848
|
-
return null; // Return null for empty expression.
|
|
849
|
-
}
|
|
850
|
-
|
|
851
|
-
/* --- Optional Step: Remove Fully Wrapping Parentheses ---
|
|
852
|
-
* This simplifies parsing by removing redundant outer parentheses, e.g., "((1 + 2))" becomes "1 + 2".
|
|
853
|
-
* It iteratively unwraps as long as the outermost characters are '(' and ')'
|
|
854
|
-
* and they correctly balance across the entire contained expression.
|
|
855
|
-
*/
|
|
856
|
-
let unwrapped = false; // Flag to track if any unwrapping occurred (mainly for debugging)
|
|
857
|
-
while (currentExpr.startsWith("(") && currentExpr.endsWith(")")) {
|
|
858
|
-
let balance = 0;
|
|
859
|
-
let canUnwrap = true; // Assume it can be unwrapped unless proven otherwise
|
|
860
|
-
|
|
861
|
-
// Handle edge case like "()" which cannot be unwrapped to an empty string meaningfully here.
|
|
862
|
-
if (currentExpr.length <= 2) {
|
|
863
|
-
canUnwrap = false;
|
|
864
|
-
break;
|
|
865
|
-
}
|
|
866
|
-
|
|
867
|
-
// Check if the parentheses truly wrap the *entire* internal expression.
|
|
868
|
-
for (let i = 0; i < currentExpr.length; i++) {
|
|
869
|
-
if (currentExpr[i] === "(") balance++;
|
|
870
|
-
else if (currentExpr[i] === ")") balance--;
|
|
871
|
-
|
|
872
|
-
// If balance returns to 0 *before* the very last character,
|
|
873
|
-
// it means the parentheses don't wrap the whole thing, e.g., "(1) + (2)".
|
|
874
|
-
if (balance === 0 && i < currentExpr.length - 1) {
|
|
875
|
-
canUnwrap = false;
|
|
876
|
-
break;
|
|
877
|
-
}
|
|
878
|
-
// If balance goes negative at any point, parentheses are mismatched.
|
|
879
|
-
if (balance < 0) {
|
|
880
|
-
canUnwrap = false; // Should ideally be caught later, but good safeguard.
|
|
881
|
-
break;
|
|
882
|
-
}
|
|
883
|
-
}
|
|
884
|
-
|
|
885
|
-
// The final balance must also be 0 for the wrapping to be valid.
|
|
886
|
-
if (balance !== 0) {
|
|
887
|
-
canUnwrap = false;
|
|
888
|
-
}
|
|
889
|
-
|
|
890
|
-
// If the checks pass, perform the unwrap.
|
|
891
|
-
if (canUnwrap) {
|
|
892
|
-
currentExpr = currentExpr
|
|
893
|
-
.substring(1, currentExpr.length - 1)
|
|
894
|
-
.trim();
|
|
895
|
-
unwrapped = true;
|
|
896
|
-
} else {
|
|
897
|
-
// If cannot unwrap this layer, stop the unwrapping process.
|
|
898
|
-
break;
|
|
899
|
-
}
|
|
900
|
-
}
|
|
901
|
-
/* --- End Parenthesis Unwrapping --- */
|
|
902
|
-
|
|
903
|
-
// 1. Attempt to parse the current (potentially unwrapped) expression as a top-level ternary.
|
|
904
|
-
const ternaryParts = parseTernary(currentExpr);
|
|
905
|
-
|
|
906
|
-
// 2. If it successfully parsed as a ternary structure...
|
|
907
|
-
if (ternaryParts) {
|
|
908
|
-
// 2a. Recursively evaluate the condition part first.
|
|
909
|
-
const condResult = calculate(ternaryParts.condition);
|
|
910
|
-
|
|
911
|
-
// Handle cases where the condition itself fails to evaluate.
|
|
912
|
-
if (condResult === null) {
|
|
913
|
-
// Log a warning indicating the condition evaluation failed.
|
|
914
|
-
console.warn(
|
|
915
|
-
`Failed to evaluate ternary condition: "${ternaryParts.condition}" in context: "${originalExpr}". Defaulting to false branch.`
|
|
916
|
-
);
|
|
917
|
-
// Proceed as if the condition is false for robustness, evaluating the 'falseExpr'.
|
|
918
|
-
// Alternatively, could return null here to propagate the failure.
|
|
919
|
-
return calculate(ternaryParts.falseExpr);
|
|
920
|
-
}
|
|
921
|
-
|
|
922
|
-
// 2b. Short-circuiting: Evaluate *only* the required branch based on the condition result.
|
|
923
|
-
// The core evaluator returns 1 for true comparisons, 0 for false.
|
|
924
|
-
// Any non-zero number is treated as "truthy" here.
|
|
925
|
-
if (condResult) {
|
|
926
|
-
// Checks for truthiness (non-zero result)
|
|
927
|
-
return calculate(ternaryParts.trueExpr); // Evaluate the true branch recursively.
|
|
928
|
-
} else {
|
|
929
|
-
return calculate(ternaryParts.falseExpr); // Evaluate the false branch recursively.
|
|
930
|
-
}
|
|
931
|
-
}
|
|
932
|
-
// 3. If it's not a top-level ternary (or parseTernary returned null due to errors)...
|
|
933
|
-
else {
|
|
934
|
-
// Evaluate the expression using the standard core math engine pipeline.
|
|
935
|
-
const tokens = tokenizeCore(currentExpr);
|
|
936
|
-
const rpnQueue = shuntingYardCore(tokens);
|
|
937
|
-
// evaluateRPNCore handles internal errors (like division by zero, unknown tokens) and returns null on failure.
|
|
938
|
-
const result = evaluateRPNCore(rpnQueue);
|
|
939
|
-
return result; // Return the result (which could be a number or null).
|
|
940
|
-
}
|
|
941
|
-
} catch (error) {
|
|
942
|
-
// Catch any unexpected runtime errors during the calculation process.
|
|
943
|
-
const contextExpr =
|
|
944
|
-
originalExpr.length > 50
|
|
945
|
-
? originalExpr.substring(0, 47) + "..." // Truncate long expressions for logging
|
|
946
|
-
: originalExpr;
|
|
947
|
-
console.warn(
|
|
948
|
-
`Unexpected calculation error: ${error.message} (Expression context: "${contextExpr}")`,
|
|
949
|
-
error
|
|
950
|
-
);
|
|
951
|
-
return null;
|
|
952
|
-
}
|
|
190
|
+
function calculate(expression, context = {}) {
|
|
191
|
+
// Offload parsing to the safe utility function
|
|
192
|
+
return safeParse(expression, context);
|
|
953
193
|
}
|
|
954
194
|
|
|
955
195
|
observer.init([
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
196
|
+
{
|
|
197
|
+
name: "CoCreateCalculateChangeValue",
|
|
198
|
+
types: ["attributes"],
|
|
199
|
+
attributeFilter: ["calculate"],
|
|
200
|
+
callback(mutation) {
|
|
201
|
+
setCalcationValue(mutation.target);
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
name: "CoCreateCalculateInit",
|
|
206
|
+
types: ["addedNodes"],
|
|
207
|
+
selector: "[calculate]",
|
|
208
|
+
callback(mutation) {
|
|
209
|
+
initElement(mutation.target);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
972
212
|
]);
|
|
973
213
|
|
|
974
|
-
init();
|
|
214
|
+
init();
|
|
975
215
|
|
|
976
|
-
export default { initElements, initElement, calculate };
|
|
216
|
+
export default { initElements, initElement, calculate };
|