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