@teachinglab/omd 0.2.5 → 0.2.6

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.
@@ -1,1223 +1,1274 @@
1
- import { omdNode } from "./omdNode.js";
2
- import { getNodeForAST } from "../core/omdUtilities.js";
3
- import { omdOperatorNode } from "./omdOperatorNode.js";
4
- import { omdBinaryExpressionNode } from "./omdBinaryExpressionNode.js";
5
- import { omdConstantNode } from "./omdConstantNode.js";
6
- import { omdParenthesisNode } from "./omdParenthesisNode.js";
7
- import { omdRationalNode } from "./omdRationalNode.js";
8
- import { omdFunctionNode } from "./omdFunctionNode.js";
9
- import { omdUnaryExpressionNode } from './omdUnaryExpressionNode.js';
10
-
11
- /**
12
- * @global {math} math - The global math.js instance.
13
- */
14
-
15
- export class omdEquationNode extends omdNode {
16
- constructor(ast) {
17
- super(ast);
18
- this.type = "omdEquationNode";
19
-
20
- const type = ast.type || ast.mathjs;
21
-
22
- if (type === "AssignmentNode") {
23
- const LeftNodeType = getNodeForAST(ast.object);
24
- this.left = new LeftNodeType(ast.object);
25
-
26
- const RightNodeType = getNodeForAST(ast.value);
27
- this.right = new RightNodeType(ast.value);
28
-
29
- this.argumentNodeList.left = this.left;
30
- this.argumentNodeList.right = this.right;
31
-
32
- } else if (ast.args && ast.args.length === 2) { // Fallback for other potential structures
33
- const LeftNodeType = getNodeForAST(ast.args[0]);
34
- this.left = new LeftNodeType(ast.args[0]);
35
-
36
- const RightNodeType = getNodeForAST(ast.args[1]);
37
- this.right = new RightNodeType(ast.args[1]);
38
-
39
- // Ensure argumentNodeList is populated for replacement machinery
40
- this.argumentNodeList.left = this.left;
41
- this.argumentNodeList.right = this.right;
42
- } else {
43
- // Create dummy nodes to prevent further errors
44
- this.left = new omdNode({ type: 'SymbolNode', name: 'error' });
45
- this.right = new omdNode({ type: 'SymbolNode', name: 'error' });
46
- this.argumentNodeList.left = this.left;
47
- this.argumentNodeList.right = this.right;
48
- }
49
-
50
- this.equalsSign = new omdOperatorNode({ type: "OperatorNode", op: "=" });
51
-
52
- this.addChild(this.left);
53
- this.addChild(this.equalsSign);
54
- this.addChild(this.right);
55
-
56
- // Optional background style configuration
57
- this._backgroundStyle = null; // { backgroundColor, cornerRadius, pill }
58
- }
59
-
60
- computeDimensions() {
61
- this.left.computeDimensions();
62
- this.equalsSign.computeDimensions();
63
- this.right.computeDimensions();
64
-
65
- const spacing = 8 * this.getFontSize() / this.getRootFontSize();
66
- let totalWidth = this.left.width + this.equalsSign.width + this.right.width + (spacing * 2);
67
- const contentHeight = Math.max(this.left.height, this.equalsSign.height, this.right.height);
68
- const { padX, padY } = this._getEffectivePadding(contentHeight);
69
- const maxHeight = contentHeight + (padY * 2);
70
- totalWidth += (padX * 2);
71
-
72
- this.setWidthAndHeight(totalWidth, maxHeight);
73
- }
74
-
75
- updateLayout() {
76
- // Keep argumentNodeList synchronized for replacement machinery
77
- this.argumentNodeList = { left: this.left, right: this.right };
78
-
79
- const spacing = 8 * this.getFontSize() / this.getRootFontSize();
80
-
81
- const maxBaseline = Math.max(
82
- this.left.getAlignmentBaseline(),
83
- this.equalsSign.getAlignmentBaseline(),
84
- this.right.getAlignmentBaseline()
85
- );
86
-
87
- // Optional background padding offset (reuse effective padding)
88
- const contentHeight2 = Math.max(this.left.height, this.equalsSign.height, this.right.height);
89
- let { padX, padY } = this._getEffectivePadding(contentHeight2);
90
-
91
- let x = padX;
92
-
93
- // Position left node
94
- this.left.updateLayout();
95
- this.left.setPosition(x, padY + (maxBaseline - this.left.getAlignmentBaseline()));
96
- x += this.left.width + spacing;
97
-
98
- // Position equals sign
99
- this.equalsSign.updateLayout();
100
- this.equalsSign.setPosition(x, padY + (maxBaseline - this.equalsSign.getAlignmentBaseline()));
101
- x += this.equalsSign.width + spacing;
102
-
103
- // Position right node
104
- this.right.updateLayout();
105
- this.right.setPosition(x, padY + (maxBaseline - this.right.getAlignmentBaseline()));
106
-
107
- // Recompute overall dimensions now that children are positioned (handles tall nodes like rationals)
108
- this.computeDimensions();
109
-
110
- // Apply configured background styling after layout to ensure correct dimensions
111
- if (this._backgroundStyle) {
112
- const { backgroundColor, cornerRadius, pill } = this._backgroundStyle;
113
- if (backgroundColor) {
114
- this.backRect.setFillColor(backgroundColor);
115
- this.backRect.setOpacity(1.0);
116
- this.defaultOpaqueBack = true;
117
- }
118
- if (pill === true) {
119
- // Pill shape: half the height
120
- const radius = Math.max(0, Math.floor(this.height / 2));
121
- this.backRect.setCornerRadius(radius);
122
- // Also apply pill corners to all descendant nodes so their backgrounds don't show square edges
123
- this._applyPillToDescendants();
124
- } else if (typeof cornerRadius === 'number') {
125
- this.backRect.setCornerRadius(cornerRadius);
126
- }
127
-
128
- // Make all descendant backgrounds match the equation background color
129
- if (backgroundColor) {
130
- this._matchChildBackgrounds(backgroundColor);
131
- }
132
- }
133
-
134
- // Ensure the background rectangle always matches the current equation size
135
- if (this.backRect && (this.width || this.height)) {
136
- this.backRect.setWidthAndHeight(this.width, this.height);
137
- }
138
-
139
- // Final pass: center content visually within backRect
140
- const minTop2 = Math.min(this.left.ypos, this.equalsSign.ypos, this.right.ypos);
141
- const maxBottom2 = Math.max(
142
- this.left.ypos + this.left.height,
143
- this.equalsSign.ypos + this.equalsSign.height,
144
- this.right.ypos + this.right.height
145
- );
146
- const topPad = minTop2;
147
- const bottomPad = Math.max(0, (this.height || 0) - maxBottom2);
148
- let deltaY2 = (topPad - bottomPad) / 2 - (0.06 * this.getFontSize());
149
- if (Math.abs(deltaY2) > 0.01) {
150
- this.left.setPosition(this.left.xpos, this.left.ypos - deltaY2);
151
- this.equalsSign.setPosition(this.equalsSign.xpos, this.equalsSign.ypos - deltaY2);
152
- this.right.setPosition(this.right.xpos, this.right.ypos - deltaY2);
153
- }
154
- }
155
-
156
- /**
157
- * Computes effective padding taking into account defaults, user overrides, and pill radius clamping.
158
- * @param {number} contentHeight
159
- * @returns {{padX:number,padY:number}}
160
- */
161
- _getEffectivePadding(contentHeight) {
162
- const ratio = this.getFontSize() / this.getRootFontSize();
163
- const baseX = 2 * ratio;
164
- const baseY = 2 * ratio;
165
- const pad = this._backgroundStyle?.padding;
166
- let padX = (typeof pad === 'number' ? pad : pad?.x) ?? baseX;
167
- let padY = (typeof pad === 'number' ? pad : pad?.y) ?? baseY;
168
- if (this._backgroundStyle?.pill === true) {
169
- const radius = Math.ceil((contentHeight + 2 * padY) / 2);
170
- if (padX < radius) padX = radius;
171
- }
172
- return { padX, padY };
173
- }
174
-
175
- /**
176
- * Applies pill-shaped corner radius to all descendant nodes' backgrounds.
177
- * Ensures child nodes don't show square corners when the parent equation uses a pill.
178
- * @private
179
- */
180
- _applyPillToDescendants() {
181
- const visited = new Set();
182
- const stack = Array.isArray(this.childList) ? [...this.childList] : [];
183
- while (stack.length) {
184
- const node = stack.pop();
185
- if (!node || visited.has(node)) continue;
186
- visited.add(node);
187
-
188
- if (node !== this && node.backRect && typeof node.backRect.setCornerRadius === 'function') {
189
- const h = typeof node.height === 'number' && node.height > 0 ? node.height : 0;
190
- const r = Math.max(0, Math.floor(h / 2));
191
- node.backRect.setCornerRadius(r);
192
- }
193
-
194
- if (Array.isArray(node.childList)) {
195
- for (const c of node.childList) stack.push(c);
196
- }
197
- if (node.argumentNodeList && typeof node.argumentNodeList === 'object') {
198
- for (const val of Object.values(node.argumentNodeList)) {
199
- if (Array.isArray(val)) {
200
- val.forEach(v => v && stack.push(v));
201
- } else if (val) {
202
- stack.push(val);
203
- }
204
- }
205
- }
206
- }
207
- }
208
-
209
- /**
210
- * Creates a value node from a number or a Math.js AST object.
211
- * @param {number|object} value - The value to convert.
212
- * @returns {omdNode} The corresponding OMD node.
213
- * @private
214
- */
215
- _createNodeFromValue(value) {
216
- if (typeof value === 'number') {
217
- const node = new omdConstantNode({ value });
218
- node.initialize(); // Constants need initialization to compute dimensions
219
- return node;
220
- }
221
- if (typeof value === 'object' && value !== null) { // It's a mathjs AST
222
- const NodeClass = getNodeForAST(value);
223
- const node = new NodeClass(value);
224
- // Most non-leaf nodes have initialize, but we call it just in case
225
- if (typeof node.initialize === 'function') {
226
- node.initialize();
227
- }
228
- return node;
229
- }
230
- return null;
231
- }
232
-
233
- /**
234
- * Applies an operation to both sides of the equation.
235
- * @param {number|object} value - The value to apply.
236
- * @param {string} op - The operator symbol (e.g., '+', '-', '*', '/').
237
- * @param {string} fn - The function name for the AST (e.g., 'add', 'subtract').
238
- * @returns {omdEquationNode} A new equation node with the operation applied.
239
- * @private
240
- */
241
- _applyOperation(value, op, fn) {
242
- const valueNode = this._createNodeFromValue(value);
243
- if (!valueNode) return this; // Return original if value is invalid
244
-
245
- // Determine if we need to wrap sides in parentheses for correct precedence
246
- const leftSideNeedsParens = this._needsParenthesesForOperation(this.left, op);
247
- const rightSideNeedsParens = this._needsParenthesesForOperation(this.right, op);
248
-
249
- // Wrap sides in parentheses if needed
250
- const leftOperand = leftSideNeedsParens ?
251
- { type: 'ParenthesisNode', content: this.left.toMathJSNode() } :
252
- this.left.toMathJSNode();
253
- const rightOperand = rightSideNeedsParens ?
254
- { type: 'ParenthesisNode', content: this.right.toMathJSNode() } :
255
- this.right.toMathJSNode();
256
-
257
- const newLeftAst = { type: 'OperatorNode', op, fn, args: [leftOperand, valueNode.toMathJSNode()] };
258
- const newRightAst = { type: 'OperatorNode', op, fn, args: [rightOperand, valueNode.toMathJSNode()] };
259
-
260
- let newLeft, newRight;
261
-
262
- if (op === '/') {
263
- newLeft = new omdRationalNode(newLeftAst);
264
- newRight = new omdRationalNode(newRightAst);
265
- } else {
266
- newLeft = new omdBinaryExpressionNode(newLeftAst);
267
- newRight = new omdBinaryExpressionNode(newRightAst);
268
- }
269
-
270
- const newEquationAst = {
271
- type: 'AssignmentNode',
272
- object: newLeft.toMathJSNode(),
273
- index: null,
274
- value: newRight.toMathJSNode()
275
- };
276
-
277
- const newEquation = new omdEquationNode(newEquationAst);
278
- newEquation.setFontSize(this.getFontSize());
279
-
280
- // Establish provenance tracking from original equation to new equation
281
- newEquation.provenance.push(this.id);
282
-
283
- // Establish granular provenance: left side to left side, right side to right side
284
- if (newEquation.left && this.left) {
285
- this._establishGranularProvenance(newEquation.left, this.left, value, fn);
286
- }
287
- if (newEquation.right && this.right) {
288
- this._establishGranularProvenance(newEquation.right, this.right, value, fn);
289
- }
290
-
291
- newEquation.initialize();
292
- return newEquation;
293
- }
294
-
295
- /**
296
- * Determines if a node needs parentheses when used as an operand with the given operation.
297
- * This ensures correct operator precedence.
298
- * @param {omdNode} node - The node to check
299
- * @param {string} operation - The operation that will be applied ('*', '/', '+', '-')
300
- * @returns {boolean} True if parentheses are needed
301
- * @private
302
- */
303
- _needsParenthesesForOperation(node, operation) {
304
- // If the node is not a binary expression, no parentheses needed
305
- if (!node || node.type !== 'omdBinaryExpressionNode') {
306
- return false;
307
- }
308
-
309
- // Define operator precedence (higher number = higher precedence)
310
- const precedence = {
311
- '+': 1,
312
- '-': 1,
313
- '*': 2,
314
- '/': 2,
315
- '^': 3
316
- };
317
-
318
- // Get the operation of the existing node
319
- let existingOp = node.operation;
320
- if (typeof existingOp === 'object' && existingOp && existingOp.name) {
321
- existingOp = existingOp.name;
322
- }
323
- if (node.astNodeData && node.astNodeData.op) {
324
- existingOp = node.astNodeData.op;
325
- }
326
-
327
- // Convert operation names to symbols if needed
328
- const opMap = {
329
- 'add': '+',
330
- 'subtract': '-',
331
- 'multiply': '*',
332
- 'divide': '/',
333
- 'pow': '^'
334
- };
335
-
336
- const currentOpSymbol = opMap[existingOp] || existingOp;
337
- const newOpSymbol = opMap[operation] || operation;
338
-
339
- // If we can't determine the precedence, be safe and add parentheses
340
- if (!precedence[currentOpSymbol] || !precedence[newOpSymbol]) {
341
- return true;
342
- }
343
-
344
- // Need parentheses if the existing operation has lower precedence than the new operation
345
- // For example: (x + 2) * 3 needs parentheses, but x * 2 + 3 doesn't need them around x * 2
346
- return precedence[currentOpSymbol] < precedence[newOpSymbol];
347
- }
348
-
349
- /**
350
- * Returns a new equation with a value added to both sides.
351
- * @param {number|object} value - The value to add.
352
- */
353
- addToBothSides(value) {
354
- return this._applyOperation(value, '+', 'add');
355
- }
356
-
357
- /**
358
- * Returns a new equation with a value subtracted from both sides.
359
- * @param {number|object} value - The value to subtract.
360
- */
361
- subtractFromBothSides(value) {
362
- return this._applyOperation(value, '-', 'subtract');
363
- }
364
-
365
- /**
366
- * Returns a new equation with both sides multiplied by a value.
367
- * @param {number|object} value - The value to multiply by.
368
- * @param {string} [operationDisplayId] - Optional ID of the operation display for provenance tracking.
369
- */
370
- multiplyBothSides(value, operationDisplayId) {
371
- return this._applyOperation(value, '*', 'multiply', operationDisplayId);
372
- }
373
-
374
- /**
375
- * Returns a new equation with both sides divided by a value.
376
- * @param {number|object} value - The value to divide by.
377
- */
378
- divideBothSides(value) {
379
- return this._applyOperation(value, '/', 'divide');
380
- }
381
-
382
-
383
-
384
- /**
385
- * Establishes granular provenance tracking between new and original nodes
386
- * This handles equation operations like "multiply both sides" by linking the new expression to the original
387
- * @param {omdNode} newNode - The new node being created (the result of the operation)
388
- * @param {omdNode} originalNode - The original node being transformed
389
- * @param {number|Object} operationValue - The value used in the operation
390
- * @param {string} operation - The operation being performed ('add', 'subtract', 'multiply', 'divide')
391
- * @private
392
- */
393
- _establishGranularProvenance(newNode, originalNode, operationValue, operation) {
394
- if (!newNode || !originalNode) return;
395
-
396
- // Ensure newNode has a provenance array
397
- if (!newNode.provenance) {
398
- newNode.provenance = [];
399
- }
400
-
401
- // For equation operations, we want to establish provenance between corresponding parts
402
- if (operation === 'divide') {
403
- // For division operations like (2x)/2 = x, check if we can simplify
404
- if (originalNode.type === 'omdBinaryExpressionNode' &&
405
- this._isMultiplicationOperation(originalNode)) {
406
-
407
- // Check if the operation value matches one of the factors
408
- const leftIsConstant = originalNode.left.isConstant();
409
- const rightIsConstant = originalNode.right.isConstant();
410
-
411
- // Convert operationValue to number if it's an object
412
- const opValue = (typeof operationValue === 'object' && operationValue.getValue) ?
413
- operationValue.getValue() : operationValue;
414
-
415
- if (leftIsConstant && originalNode.left.getValue() === opValue) {
416
- // Dividing by the left factor, so result should trace to right factor
417
- this._copyProvenanceStructure(newNode, originalNode.right);
418
- } else if (rightIsConstant && originalNode.right.getValue() === opValue) {
419
- // Dividing by the right factor, so result should trace to left factor
420
- this._copyProvenanceStructure(newNode, originalNode.left);
421
- } else {
422
- // Not a simple factor division, link to the whole expression
423
- this._copyProvenanceStructure(newNode, originalNode);
424
- }
425
- } else {
426
- // Not a multiplication, link to the whole original
427
- this._copyProvenanceStructure(newNode, originalNode);
428
- }
429
- }
430
- else if (operation === 'multiply') {
431
- // For multiplication operations like x * 2 = 2x
432
- // The new expression should trace back to the original expression
433
- this._copyProvenanceStructure(newNode, originalNode);
434
-
435
- // Also establish provenance for the binary expression structure
436
- if (newNode.type === 'omdBinaryExpressionNode') {
437
- // Link the left operand (which should be the original expression) to the original
438
- if (newNode.left) {
439
- this._copyProvenanceStructure(newNode.left, originalNode);
440
- }
441
- // The right operand is the operation value, no additional provenance needed
442
- }
443
- }
444
- else if (operation === 'add' || operation === 'subtract') {
445
- // For addition/subtraction, the new binary expression's provenance should
446
- // link to the original expression, but we should handle operands separately
447
- // to avoid incorrect linking of the added/subtracted value.
448
- newNode.provenance.push(originalNode.id);
449
-
450
- if (newNode.type === 'omdBinaryExpressionNode') {
451
- // Link the left operand (the original side of the equation) to the original node structure.
452
- if (newNode.left) {
453
- this._copyProvenanceStructure(newNode.left, originalNode);
454
- }
455
- // The right operand is the new value being added/subtracted - preserve its provenance
456
- // for proper highlighting when constants are combined later
457
- // (Don't clear provenance - let it maintain its own identity for combination rules)
458
- }
459
- }
460
- else {
461
- // For any other operations, link to the whole original expression
462
- this._copyProvenanceStructure(newNode, originalNode);
463
- }
464
- }
465
-
466
- /**
467
- * Helper method to check if a node represents a multiplication operation
468
- * @param {omdNode} node - The node to check
469
- * @returns {boolean} True if it's a multiplication operation
470
- * @private
471
- */
472
- _isMultiplicationOperation(node) {
473
- if (node.type !== 'omdBinaryExpressionNode') return false;
474
-
475
- const op = node.operation;
476
- return op === 'multiply' ||
477
- (typeof op === 'object' && op && op.name === 'multiply') ||
478
- (node.op && node.op.opName === '*');
479
- }
480
-
481
- /**
482
- * Copies the provenance structure from source to target, maintaining granularity
483
- * @param {omdNode} target - The node to set provenance on
484
- * @param {omdNode} source - The node to copy provenance from
485
- * @private
486
- */
487
- _copyProvenanceStructure(target, source) {
488
- if (!target || !source) return;
489
-
490
- // Initialize provenance array if it doesn't exist
491
- if (!target.provenance) {
492
- target.provenance = [];
493
- }
494
-
495
- // If the source has its own provenance, copy it
496
- if (source.provenance && source.provenance.length > 0) {
497
- // Create a Set to track unique IDs we've already processed
498
- const processedIds = new Set(target.provenance);
499
-
500
- // Process each provenance ID from source
501
- source.provenance.forEach(id => {
502
- if (!processedIds.has(id)) {
503
- processedIds.add(id);
504
- target.provenance.push(id);
505
- }
506
- });
507
- }
508
-
509
- // Add the source's own ID if not already present
510
- if (!target.provenance.includes(source.id)) {
511
- target.provenance.push(source.id);
512
- }
513
-
514
- // If both nodes have the same structure, recursively copy provenance
515
- if (target.type === source.type) {
516
- if (target.argumentNodeList && source.argumentNodeList) {
517
- for (const key of Object.keys(source.argumentNodeList)) {
518
- const targetChild = target.argumentNodeList[key];
519
- const sourceChild = source.argumentNodeList[key];
520
-
521
- if (targetChild && sourceChild) {
522
- if (Array.isArray(targetChild) && Array.isArray(sourceChild)) {
523
- // Handle array of children
524
- for (let i = 0; i < Math.min(targetChild.length, sourceChild.length); i++) {
525
- if (targetChild[i] && sourceChild[i]) {
526
- this._copyProvenanceStructure(targetChild[i], sourceChild[i]);
527
- }
528
- }
529
- } else {
530
- // Handle single child node
531
- this._copyProvenanceStructure(targetChild, sourceChild);
532
- }
533
- }
534
- }
535
- }
536
- }
537
- }
538
-
539
-
540
- /**
541
- * Creates an omdEquationNode instance from a string.
542
- * @param {string} equationString - The string to parse (e.g., "2x+4=10").
543
- * @returns {omdEquationNode} A new instance of omdEquationNode.
544
- */
545
- static fromString(equationString) {
546
- if (!equationString.includes('=')) {
547
- throw new Error("Input string is not a valid equation.");
548
- }
549
-
550
- const parts = equationString.split('=');
551
- if (parts.length > 2) {
552
- throw new Error("Equation can only have one '=' sign.");
553
- }
554
-
555
- const left = parts[0].trim();
556
- const right = parts[1].trim();
557
-
558
- if (!left || !right) {
559
- throw new Error("Equation must have a left and a right side.");
560
- }
561
-
562
- // Manually construct an AST-like object that the constructor can understand.
563
- const ast = {
564
- type: "AssignmentNode",
565
- object: math.parse(left),
566
- value: math.parse(right),
567
- // Add a clone method so it behaves like a real math.js node for our system.
568
- clone: function () {
569
- return {
570
- type: this.type,
571
- object: this.object.clone(),
572
- value: this.value.clone(),
573
- clone: this.clone
574
- };
575
- }
576
- };
577
-
578
- return new omdEquationNode(ast);
579
- }
580
-
581
- clone() {
582
- // Create a clone from a deep-copied AST. This creates a node tree
583
- // with the exact structure needed for simplification.
584
- const newAstNodeData = JSON.parse(JSON.stringify(this.astNodeData));
585
- const clone = new omdEquationNode(newAstNodeData);
586
-
587
- // Recursively fix the provenance chain for the new clone.
588
- clone._syncProvenanceFrom(this);
589
-
590
- clone.setFontSize(this.getFontSize());
591
-
592
- // Ensure argumentNodeList exists on clone for replacement machinery
593
- clone.argumentNodeList = { left: clone.left, right: clone.right };
594
-
595
- return clone;
596
- }
597
-
598
- /**
599
- * Overrides default deselect behavior for equations inside a calculation.
600
- * @param {omdNode} root - The root of the deselection event.
601
- */
602
- deselect(root) {
603
- if (!(root instanceof omdNode)) root = this;
604
-
605
- if (this === root && this.parent instanceof omdNode) {
606
- this.parent.select(root);
607
- }
608
-
609
- this.backRect.setFillColor(omdColor.lightGray);
610
- if (this.defaultOpaqueBack == false) {
611
- this.backRect.setOpacity(0.01);
612
- }
613
-
614
- this.childList.forEach((child) => {
615
- if (child !== root && child instanceof omdNode) {
616
- child.deselect(root);
617
- }
618
- });
619
- }
620
-
621
- /**
622
- * Converts the omdEquationNode to a math.js AST node.
623
- * @returns {Object} A math.js-compatible AST node.
624
- */
625
- toMathJSNode() {
626
- let astNode;
627
-
628
- // Get fresh AST representations from children to ensure parentheses and other
629
- // structural elements are properly preserved
630
- if (this.astNodeData.type === "AssignmentNode") {
631
- astNode = {
632
- type: 'AssignmentNode',
633
- object: this.left.toMathJSNode(),
634
- value: this.right.toMathJSNode(),
635
- id: this.id,
636
- provenance: this.provenance
637
- };
638
- } else {
639
- astNode = {
640
- type: 'OperatorNode', op: '=', fn: 'equal',
641
- args: [this.left.toMathJSNode(), this.right.toMathJSNode()],
642
- id: this.id,
643
- provenance: this.provenance
644
- };
645
- }
646
-
647
- // Add a clone method to maintain compatibility with math.js's expectations.
648
- astNode.clone = function() {
649
- const clonedNode = { ...this };
650
- if (this.object) clonedNode.object = this.object.clone();
651
- if (this.value) clonedNode.value = this.value.clone();
652
- if (this.args) clonedNode.args = this.args.map(arg => arg.clone());
653
- return clonedNode;
654
- };
655
- return astNode;
656
- }
657
-
658
- /**
659
- * Applies a function to both sides of the equation
660
- * @param {string} functionName - The name of the function to apply
661
- * @returns {omdEquationNode} A new equation with the function applied to both sides
662
- */
663
- applyFunction(functionName) {
664
- const leftWithFunction = this._createFunctionNode(functionName, this.left);
665
- const rightWithFunction = this._createFunctionNode(functionName, this.right);
666
-
667
- const newEquation = this._createNewEquation(leftWithFunction, rightWithFunction);
668
- newEquation.provenance.push(this.id);
669
-
670
- return newEquation;
671
- }
672
-
673
- /**
674
- * Creates a function node wrapping the given argument
675
- * @param {string} functionName - The function name
676
- * @param {omdNode} argument - The argument to wrap
677
- * @returns {omdNode} The function node
678
- * @private
679
- */
680
- _createFunctionNode(functionName, argument) {
681
- // Create a math.js AST for the function
682
- const functionAst = {
683
- type: 'FunctionNode',
684
- fn: { type: 'SymbolNode', name: functionName },
685
- args: [argument.toMathJSNode()]
686
- };
687
-
688
- // Use the already imported getNodeForAST function
689
- const NodeClass = getNodeForAST(functionAst);
690
- const functionNode = new NodeClass(functionAst);
691
- functionNode.setFontSize(this.getFontSize());
692
- return functionNode;
693
- }
694
-
695
- /**
696
- * Creates a new equation from left and right sides
697
- * @param {omdNode} left - The left side
698
- * @param {omdNode} right - The right side
699
- * @returns {omdEquationNode} The new equation
700
- * @private
701
- */
702
- _createNewEquation(left, right) {
703
- const newAst = {
704
- type: "AssignmentNode",
705
- object: left.toMathJSNode(),
706
- value: right.toMathJSNode(),
707
- clone: function () {
708
- return {
709
- type: this.type,
710
- object: this.object.clone(),
711
- value: this.value.clone(),
712
- clone: this.clone
713
- };
714
- }
715
- };
716
-
717
- return new omdEquationNode(newAst);
718
- }
719
-
720
- /**
721
- * Apply an operation to one or both sides of the equation
722
- * @param {number|omdNode} value - The value to apply
723
- * @param {string} operation - 'add', 'subtract', 'multiply', or 'divide'
724
- * @param {string} side - 'left', 'right', or 'both' (default: 'both')
725
- * @returns {omdEquationNode} New equation with operation applied
726
- */
727
- applyOperation(value, operation, side = 'both') {
728
- // Map operation names to operators and function names
729
- const operationMap = {
730
- 'add': { op: '+', fn: 'add' },
731
- 'subtract': { op: '-', fn: 'subtract' },
732
- 'multiply': { op: '*', fn: 'multiply' },
733
- 'divide': { op: '/', fn: 'divide' }
734
- };
735
-
736
- const opInfo = operationMap[operation];
737
- if (!opInfo) {
738
- throw new Error(`Unknown operation: ${operation}`);
739
- }
740
-
741
- // Handle different side options
742
- if (side === 'both') {
743
- // Use existing methods for both sides
744
- return this._applyOperation(value, opInfo.op, opInfo.fn);
745
- }
746
-
747
- // For single side operations, we need to create the new equation manually
748
- const valueNode = this._createNodeFromValue(value);
749
- if (!valueNode) {
750
- throw new Error("Invalid value provided");
751
- }
752
-
753
- // Create new AST for the specified side
754
- let newLeftAst, newRightAst;
755
-
756
- if (side === 'left') {
757
- // Apply operation to left side only
758
- const leftNeedsParens = this._needsParenthesesForOperation(this.left, opInfo.op);
759
- const leftOperand = leftNeedsParens ?
760
- { type: 'ParenthesisNode', content: this.left.toMathJSNode() } :
761
- this.left.toMathJSNode();
762
-
763
- newLeftAst = { type: 'OperatorNode', op: opInfo.op, fn: opInfo.fn, args: [leftOperand, valueNode.toMathJSNode()] };
764
- newRightAst = this.right.toMathJSNode();
765
- } else if (side === 'right') {
766
- // Apply operation to right side only
767
- const rightNeedsParens = this._needsParenthesesForOperation(this.right, opInfo.op);
768
- const rightOperand = rightNeedsParens ?
769
- { type: 'ParenthesisNode', content: this.right.toMathJSNode() } :
770
- this.right.toMathJSNode();
771
-
772
- newLeftAst = this.left.toMathJSNode();
773
- newRightAst = { type: 'OperatorNode', op: opInfo.op, fn: opInfo.fn, args: [rightOperand, valueNode.toMathJSNode()] };
774
- } else {
775
- throw new Error(`Invalid side: ${side}. Must be 'left', 'right', or 'both'`);
776
- }
777
-
778
- // Create nodes from ASTs
779
- let newLeft, newRight;
780
-
781
- if (side === 'left' && opInfo.op === '/') {
782
- newLeft = new omdRationalNode(newLeftAst);
783
- newRight = getNodeForAST(newRightAst) === omdNode ? this.right : new (getNodeForAST(newRightAst))(newRightAst);
784
- } else if (side === 'right' && opInfo.op === '/') {
785
- newLeft = getNodeForAST(newLeftAst) === omdNode ? this.left : new (getNodeForAST(newLeftAst))(newLeftAst);
786
- newRight = new omdRationalNode(newRightAst);
787
- } else if (side === 'left') {
788
- newLeft = new omdBinaryExpressionNode(newLeftAst);
789
- newRight = getNodeForAST(newRightAst) === omdNode ? this.right : new (getNodeForAST(newRightAst))(newRightAst);
790
- } else {
791
- newLeft = getNodeForAST(newLeftAst) === omdNode ? this.left : new (getNodeForAST(newLeftAst))(newLeftAst);
792
- newRight = new omdBinaryExpressionNode(newRightAst);
793
- }
794
-
795
- // Create new equation
796
- const newEquationAst = {
797
- type: 'AssignmentNode',
798
- object: newLeft.toMathJSNode(),
799
- value: newRight.toMathJSNode()
800
- };
801
-
802
- const newEquation = new omdEquationNode(newEquationAst);
803
- newEquation.setFontSize(this.getFontSize());
804
- newEquation.provenance.push(this.id);
805
-
806
- // Initialize to compute dimensions
807
- newEquation.initialize();
808
-
809
- return newEquation;
810
- }
811
-
812
- /**
813
- * Swap left and right sides of the equation
814
- * @returns {omdEquationNode} New equation with sides swapped
815
- */
816
- swapSides() {
817
- const newEquation = this.clone();
818
- [newEquation.left, newEquation.right] = [newEquation.right, newEquation.left];
819
-
820
- // Update the AST for consistency
821
- [newEquation.astNodeData.object, newEquation.astNodeData.value] =
822
- [newEquation.astNodeData.value, newEquation.astNodeData.object];
823
-
824
- newEquation.provenance.push(this.id);
825
-
826
- // This is a layout change, not a mathematical simplification, so no need for granular provenance
827
- newEquation.initialize();
828
- return newEquation;
829
- }
830
-
831
- /**
832
- * Returns a string representation of the equation
833
- * @returns {string} The equation as a string
834
- */
835
- toString() {
836
- return `${this.left.toString()} = ${this.right.toString()}`;
837
- }
838
-
839
- /**
840
- * Configure equation background styling. Defaults remain unchanged if not provided.
841
- * @param {{ backgroundColor?: string, cornerRadius?: number, pill?: boolean }} style
842
- */
843
- setBackgroundStyle(style = {}) {
844
- this._backgroundStyle = { ...(this._backgroundStyle || {}), ...style };
845
- // If layout already computed, re-apply immediately
846
- if (this.backRect && (this.width || this.height)) {
847
- this.updateLayout();
848
- }
849
- }
850
-
851
- /**
852
- * Returns the horizontal anchor X for the equals sign center relative to this node's origin.
853
- * Accounts for background padding and internal spacing.
854
- * @returns {number}
855
- */
856
- getEqualsAnchorX() {
857
- const spacing = 8 * this.getFontSize() / this.getRootFontSize();
858
- // Use EFFECTIVE padding so pill clamping and tall nodes are accounted for
859
- const contentHeight = Math.max(this.left?.height || 0, this.equalsSign?.height || 0, this.right?.height || 0);
860
- const { padX } = this._getEffectivePadding(contentHeight);
861
- // Anchor at center of equals sign
862
- return padX + this.left.width + spacing + (this.equalsSign?.width || 0) / 2;
863
- }
864
-
865
- /**
866
- * Returns the X padding applied by background style
867
- * @returns {number}
868
- */
869
- getBackgroundPaddingX() {
870
- const pad = this._backgroundStyle?.padding;
871
- return pad == null ? 0 : (typeof pad === 'number' ? pad : (pad.x ?? 0));
872
- }
873
-
874
- /**
875
- * Returns the effective horizontal padding used in layout, including pill clamping
876
- * @returns {number}
877
- */
878
- getEffectiveBackgroundPaddingX() {
879
- const contentHeight = Math.max(this.left?.height || 0, this.equalsSign?.height || 0, this.right?.height || 0);
880
- const { padX } = this._getEffectivePadding(contentHeight);
881
- return padX;
882
- }
883
-
884
- /**
885
- * Hides the backgrounds of all child nodes (descendants), preserving only this node's background.
886
- * @private
887
- */
888
- _matchChildBackgrounds(color) {
889
- const visited = new Set();
890
- const stack = Array.isArray(this.childList) ? [...this.childList] : [];
891
- while (stack.length) {
892
- const node = stack.pop();
893
- if (!node || visited.has(node)) continue;
894
- visited.add(node);
895
-
896
- if (node !== this && node.backRect) {
897
- node.backRect.setFillColor(color);
898
- node.backRect.setOpacity(1.0);
899
- }
900
-
901
- if (Array.isArray(node.childList)) {
902
- for (const c of node.childList) stack.push(c);
903
- }
904
- if (node.argumentNodeList && typeof node.argumentNodeList === 'object') {
905
- for (const val of Object.values(node.argumentNodeList)) {
906
- if (Array.isArray(val)) {
907
- val.forEach(v => v && stack.push(v));
908
- } else if (val) {
909
- stack.push(val);
910
- }
911
- }
912
- }
913
- }
914
- }
915
-
916
- /**
917
- * Evaluates the equation by evaluating both sides and checking for equality.
918
- * @param {Object} variables - A map of variable names to their numeric values.
919
- * @returns {Object} An object containing the evaluated left and right sides.
920
- */
921
- evaluate(variables = {}) {
922
- const leftValue = this.left.evaluate(variables);
923
- const rightValue = this.right.evaluate(variables);
924
-
925
- return { left: leftValue, right: rightValue };
926
- }
927
- /**
928
- * Renders the equation to different visualization formats
929
- * @param {string} visualizationType - "graph" | "table" | "hanger"
930
- * @param {Object} options - Optional configuration
931
- * @param {string} options.side - "both" (default), "left", or "right"
932
- * @param {number} options.xMin - Domain min for x (default: -10)
933
- * @param {number} options.xMax - Domain max for x (default: 10)
934
- * @param {number} options.yMin - Range min for y (graph only, default: -10)
935
- * @param {number} options.yMax - Range max for y (graph only, default: 10)
936
- * @param {number} options.stepSize - Step size for table (default: 1)
937
- * @returns {Object} JSON per schemas in src/json-schemas.md
938
- */
939
- renderTo(visualizationType, options = {}) {
940
- // Set default options
941
- const defaultOptions = {
942
- side: "both",
943
- xMin: -10,
944
- xMax: 10,
945
- yMin: -10,
946
- yMax: 10,
947
- stepSize: 1
948
- };
949
- const mergedOptions = { ...defaultOptions, ...options };
950
-
951
- switch (visualizationType.toLowerCase()) {
952
- case 'graph':
953
- return this._renderToGraph(mergedOptions);
954
- case 'table':
955
- return this._renderToTable(mergedOptions);
956
- case 'hanger':
957
- return this._renderToHanger();
958
- case 'tileequation': {
959
- const leftExpr = this.getLeft().toString();
960
- const rightExpr = this.getRight().toString();
961
- const eqString = `${leftExpr}=${rightExpr}`;
962
- // Colors/options passthrough
963
- const plusColor = mergedOptions.plusColor || '#79BBFD';
964
- const equalsColor = mergedOptions.equalsColor || '#FF6B6B';
965
- const xPillColor = mergedOptions.xPillColor; // optional
966
- const tileBgColor = mergedOptions.tileBackgroundColor; // optional
967
- const dotColor = mergedOptions.dotColor; // optional
968
- const tileSize = mergedOptions.tileSize || 28;
969
- const dotsPerColumn = mergedOptions.dotsPerColumn || 10;
970
- return {
971
- omdType: 'tileEquation',
972
- equation: eqString,
973
- tileSize,
974
- dotsPerColumn,
975
- plusColor,
976
- equalsColor,
977
- xPill: xPillColor ? { color: xPillColor } : undefined,
978
- numberTileDefaults: {
979
- backgroundColor: tileBgColor,
980
- dotColor
981
- }
982
- };
983
- }
984
- default:
985
- throw new Error(`Unknown visualization type: ${visualizationType}. Supported types are: graph, table, hanger`);
986
- }
987
- }
988
-
989
- /**
990
- * Gets the left side of the equation
991
- * @returns {omdNode} The left side node
992
- */
993
- getLeft() {
994
- return this.left;
995
- }
996
-
997
- /**
998
- * Gets the right side of the equation
999
- * @returns {omdNode} The right side node
1000
- */
1001
- getRight() {
1002
- return this.right;
1003
- }
1004
-
1005
- /**
1006
- * Generates JSON configuration for coordinate plane graph visualization
1007
- * @param {Object} options - Configuration options
1008
- * @returns {Object} JSON configuration for omdCoordinatePlane
1009
- * @private
1010
- */
1011
- _renderToGraph(options) {
1012
- const leftExpr = this._normalizeExpressionString(this.getLeft().toString());
1013
- const rightExpr = this._normalizeExpressionString(this.getRight().toString());
1014
-
1015
- let graphEquations = [];
1016
- if (options.side === 'left') {
1017
- graphEquations = [{ equation: `y = ${leftExpr}`, domain: { min: options.xMin, max: options.xMax }, color: 'blue', strokeWidth: 2 }];
1018
- } else if (options.side === 'right') {
1019
- graphEquations = [{ equation: `y = ${rightExpr}`, domain: { min: options.xMin, max: options.xMax }, color: 'red', strokeWidth: 2 }];
1020
- } else {
1021
- // both: plot left and right as two functions; intersection corresponds to equality
1022
- graphEquations = [
1023
- { equation: `y = ${leftExpr}`, domain: { min: options.xMin, max: options.xMax }, color: 'blue', strokeWidth: 2 },
1024
- { equation: `y = ${rightExpr}`, domain: { min: options.xMin, max: options.xMax }, color: 'red', strokeWidth: 2 }
1025
- ];
1026
- }
1027
-
1028
- return {
1029
- omdType: "coordinatePlane",
1030
- xMin: options.xMin,
1031
- xMax: options.xMax,
1032
- yMin: options.yMin,
1033
- yMax: options.yMax,
1034
- // Allow caller to override visual settings via options
1035
- xLabel: (options.xLabel !== undefined) ? options.xLabel : "x",
1036
- yLabel: (options.yLabel !== undefined) ? options.yLabel : "y",
1037
- size: (options.size !== undefined) ? options.size : "medium",
1038
- tickInterval: (options.tickInterval !== undefined) ? options.tickInterval : 1,
1039
- forceAllTickLabels: (options.forceAllTickLabels !== undefined) ? options.forceAllTickLabels : true,
1040
- showTickLabels: (options.showTickLabels !== undefined) ? options.showTickLabels : true,
1041
- graphEquations,
1042
- lineSegments: [],
1043
- dotValues: [],
1044
- shapeSet: []
1045
- };
1046
- }
1047
-
1048
- /**
1049
- * Generates JSON configuration for table visualization
1050
- * @param {Object} options - Configuration options
1051
- * @returns {Object} JSON configuration for omdTable
1052
- * @private
1053
- */
1054
- _renderToTable(options) {
1055
- // Single side: let omdTable generate rows from equation
1056
- if (options.side === 'left') {
1057
- const expr = this._normalizeExpressionString(this.getLeft().toString());
1058
- return {
1059
- omdType: "table",
1060
- title: `Function Table: y = ${expr}`,
1061
- headers: ["x", "y"],
1062
- equation: `y = ${expr}`,
1063
- xMin: options.xMin,
1064
- xMax: options.xMax,
1065
- stepSize: options.stepSize
1066
- };
1067
- } else if (options.side === 'right') {
1068
- const expr = this._normalizeExpressionString(this.getRight().toString());
1069
- return {
1070
- omdType: "table",
1071
- title: `Function Table: y = ${expr}`,
1072
- headers: ["x", "y"],
1073
- equation: `y = ${expr}`,
1074
- xMin: options.xMin,
1075
- xMax: options.xMax,
1076
- stepSize: options.stepSize
1077
- };
1078
- }
1079
-
1080
- // Both sides: compute data for x, left(x), right(x)
1081
- const leftSide = this.getLeft();
1082
- const rightSide = this.getRight();
1083
- const leftLabel = leftSide.toString();
1084
- const rightLabel = rightSide.toString();
1085
-
1086
- const headers = ["x", leftLabel, rightLabel];
1087
- const data = [];
1088
- const start = options.xMin;
1089
- const end = options.xMax;
1090
- const step = options.stepSize || 1;
1091
- for (let x = start; x <= end; x += step) {
1092
- try {
1093
- const l = leftSide.evaluate({ x });
1094
- const r = rightSide.evaluate({ x });
1095
- if (isFinite(l) && isFinite(r)) {
1096
- data.push([x, Number(l), Number(r)]);
1097
- }
1098
- } catch (_) {
1099
- // Skip points that fail to evaluate
1100
- }
1101
- }
1102
-
1103
- return {
1104
- omdType: "table",
1105
- title: `Equation Table: ${this.toString()}`,
1106
- headers,
1107
- data
1108
- };
1109
- }
1110
-
1111
- /**
1112
- * Generates table for a single side of the equation
1113
- * @param {omdNode} side - The side to render
1114
- * @param {string} title - Title for the table
1115
- * @returns {Object} JSON configuration for omdTable
1116
- * @private
1117
- */
1118
- _renderSingleSideTable(side, title, options = {}) {
1119
- const expression = this._normalizeExpressionString(side.toString());
1120
- return {
1121
- omdType: "table",
1122
- title: `${title}: ${expression}`,
1123
- headers: ["x", "y"],
1124
- equation: `y = ${expression}`,
1125
- xMin: options.xMin ?? -5,
1126
- xMax: options.xMax ?? 5,
1127
- stepSize: options.stepSize ?? 1
1128
- };
1129
- }
1130
-
1131
- /**
1132
- * Generates JSON configuration for balance hanger visualization
1133
- * @returns {Object} JSON configuration for omdBalanceHanger
1134
- * @private
1135
- */
1136
- _renderToHanger() {
1137
- // Convert equation sides to hanger representation
1138
- const leftValues = this._convertToHangerValues(this.getLeft());
1139
- const rightValues = this._convertToHangerValues(this.getRight());
1140
-
1141
- return {
1142
- omdType: "balanceHanger",
1143
- leftValues: leftValues,
1144
- rightValues: rightValues,
1145
- tilt: "none" // Equations should be balanced by definition
1146
- };
1147
- }
1148
-
1149
- /**
1150
- * Normalizes an expression string for evaluation/graphing
1151
- * - Inserts '*' between number-variable and variable-number
1152
- * @param {string} expr
1153
- * @returns {string}
1154
- * @private
1155
- */
1156
- _normalizeExpressionString(expr) {
1157
- if (!expr || typeof expr !== 'string') return String(expr || '');
1158
- return expr
1159
- .replace(/(\d)([a-zA-Z])/g, '$1*$2')
1160
- .replace(/([a-zA-Z])(\d)/g, '$1*$2');
1161
- }
1162
-
1163
- /**
1164
- * Converts an equation side to balance hanger values (simple array of values)
1165
- * @param {omdNode} node - The node to convert
1166
- * @returns {Array} Array of simple values for the hanger
1167
- * @private
1168
- */
1169
- _convertToHangerValues(node) {
1170
- const values = [];
1171
-
1172
- // Handle different node types
1173
- if (node.type === 'omdConstantNode') {
1174
- // Add the constant value
1175
- const value = node.getValue();
1176
- if (value !== 0) {
1177
- values.push(value);
1178
- }
1179
- } else if (node.type === 'omdVariableNode') {
1180
- // Add variable name
1181
- values.push(node.name || "x");
1182
- } else if (node.type === 'omdBinaryExpressionNode') {
1183
- // Handle binary expressions by recursively processing operands
1184
- const leftValues = this._convertToHangerValues(node.left);
1185
- const rightValues = this._convertToHangerValues(node.right);
1186
-
1187
- // For addition, combine values
1188
- if (node.operation === 'add' || node.operation === 'plus') {
1189
- values.push(...leftValues, ...rightValues);
1190
- }
1191
- // For multiplication, handle special cases
1192
- else if (node.operation === 'multiply') {
1193
- // Check if one operand is a constant (coefficient)
1194
- if (node.left.type === 'omdConstantNode' && node.right.type === 'omdVariableNode') {
1195
- const coefficient = Math.abs(node.left.getValue());
1196
- const varName = node.right.name || "x";
1197
- // Add multiple instances of the variable
1198
- for (let i = 0; i < coefficient; i++) {
1199
- values.push(varName);
1200
- }
1201
- } else if (node.right.type === 'omdConstantNode' && node.left.type === 'omdVariableNode') {
1202
- const coefficient = Math.abs(node.right.getValue());
1203
- const varName = node.left.name || "x";
1204
- // Add multiple instances of the variable
1205
- for (let i = 0; i < coefficient; i++) {
1206
- values.push(varName);
1207
- }
1208
- } else {
1209
- // For other multiplications, treat as a single expression
1210
- values.push(node.toString());
1211
- }
1212
- } else {
1213
- // For other operations, treat as a single expression
1214
- values.push(node.toString());
1215
- }
1216
- } else {
1217
- // For any other node types, treat as expression string
1218
- values.push(node.toString());
1219
- }
1220
-
1221
- return values;
1222
- }
1
+ import { omdNode } from "./omdNode.js";
2
+ import { getNodeForAST } from "../core/omdUtilities.js";
3
+ import { omdOperatorNode } from "./omdOperatorNode.js";
4
+ import { omdBinaryExpressionNode } from "./omdBinaryExpressionNode.js";
5
+ import { omdConstantNode } from "./omdConstantNode.js";
6
+ import { omdParenthesisNode } from "./omdParenthesisNode.js";
7
+ import { omdRationalNode } from "./omdRationalNode.js";
8
+ import { omdFunctionNode } from "./omdFunctionNode.js";
9
+ import { omdUnaryExpressionNode } from './omdUnaryExpressionNode.js';
10
+
11
+ /**
12
+ * @global {math} math - The global math.js instance.
13
+ */
14
+
15
+ export class omdEquationNode extends omdNode {
16
+ constructor(ast) {
17
+ super(ast);
18
+ this.type = "omdEquationNode";
19
+
20
+ const type = ast.type || ast.mathjs;
21
+
22
+ if (type === "AssignmentNode") {
23
+ const LeftNodeType = getNodeForAST(ast.object);
24
+ this.left = new LeftNodeType(ast.object);
25
+
26
+ const RightNodeType = getNodeForAST(ast.value);
27
+ this.right = new RightNodeType(ast.value);
28
+
29
+ this.argumentNodeList.left = this.left;
30
+ this.argumentNodeList.right = this.right;
31
+
32
+ } else if (ast.args && ast.args.length === 2) { // Fallback for other potential structures
33
+ const LeftNodeType = getNodeForAST(ast.args[0]);
34
+ this.left = new LeftNodeType(ast.args[0]);
35
+
36
+ const RightNodeType = getNodeForAST(ast.args[1]);
37
+ this.right = new RightNodeType(ast.args[1]);
38
+
39
+ // Ensure argumentNodeList is populated for replacement machinery
40
+ this.argumentNodeList.left = this.left;
41
+ this.argumentNodeList.right = this.right;
42
+ } else {
43
+ // Create dummy nodes to prevent further errors
44
+ this.left = new omdNode({ type: 'SymbolNode', name: 'error' });
45
+ this.right = new omdNode({ type: 'SymbolNode', name: 'error' });
46
+ this.argumentNodeList.left = this.left;
47
+ this.argumentNodeList.right = this.right;
48
+ }
49
+
50
+ this.equalsSign = new omdOperatorNode({ type: "OperatorNode", op: "=" });
51
+
52
+ this.addChild(this.left);
53
+ this.addChild(this.equalsSign);
54
+ this.addChild(this.right);
55
+
56
+ // Optional background style configuration
57
+ this._backgroundStyle = null; // { backgroundColor, cornerRadius, pill }
58
+ this._propagateBackgroundStyle(this._backgroundStyle);
59
+ }
60
+
61
+ computeDimensions() {
62
+ this.left.computeDimensions();
63
+ this.equalsSign.computeDimensions();
64
+ this.right.computeDimensions();
65
+
66
+ const spacing = 8 * this.getFontSize() / this.getRootFontSize();
67
+ let totalWidth = this.left.width + this.equalsSign.width + this.right.width + (spacing * 2);
68
+ const contentHeight = Math.max(this.left.height, this.equalsSign.height, this.right.height);
69
+ const { padX, padY } = this._getEffectivePadding(contentHeight);
70
+ const maxHeight = contentHeight + (padY * 2);
71
+ totalWidth += (padX * 2);
72
+
73
+ this.setWidthAndHeight(totalWidth, maxHeight);
74
+ }
75
+
76
+ updateLayout() {
77
+ // Keep argumentNodeList synchronized for replacement machinery
78
+ this.argumentNodeList = { left: this.left, right: this.right };
79
+
80
+ const spacing = 8 * this.getFontSize() / this.getRootFontSize();
81
+
82
+ const maxBaseline = Math.max(
83
+ this.left.getAlignmentBaseline(),
84
+ this.equalsSign.getAlignmentBaseline(),
85
+ this.right.getAlignmentBaseline()
86
+ );
87
+
88
+ // Optional background padding offset (reuse effective padding)
89
+ const contentHeight2 = Math.max(this.left.height, this.equalsSign.height, this.right.height);
90
+ let { padX, padY } = this._getEffectivePadding(contentHeight2);
91
+
92
+ let x = padX;
93
+
94
+ // Position left node
95
+ this.left.updateLayout();
96
+ this.left.setPosition(x, padY + (maxBaseline - this.left.getAlignmentBaseline()));
97
+ x += this.left.width + spacing;
98
+
99
+ // Position equals sign
100
+ this.equalsSign.updateLayout();
101
+ this.equalsSign.setPosition(x, padY + (maxBaseline - this.equalsSign.getAlignmentBaseline()));
102
+ x += this.equalsSign.width + spacing;
103
+
104
+ // Position right node
105
+ this.right.updateLayout();
106
+ this.right.setPosition(x, padY + (maxBaseline - this.right.getAlignmentBaseline()));
107
+
108
+ // Recompute overall dimensions now that children are positioned (handles tall nodes like rationals)
109
+ this.computeDimensions();
110
+
111
+ // Apply configured background styling after layout to ensure correct dimensions
112
+ if (this._backgroundStyle) {
113
+ const { backgroundColor, cornerRadius, pill } = this._backgroundStyle;
114
+ if (backgroundColor) {
115
+ this.backRect.setFillColor(backgroundColor);
116
+ this.backRect.setOpacity(1.0);
117
+ this.defaultOpaqueBack = true;
118
+ }
119
+ if (pill === true) {
120
+ // Pill shape: half the height
121
+ const radius = Math.max(0, Math.floor(this.height / 2));
122
+ this.backRect.setCornerRadius(radius);
123
+ // Also apply pill corners to all descendant nodes so their backgrounds don't show square edges
124
+ this._applyPillToDescendants();
125
+ } else if (typeof cornerRadius === 'number') {
126
+ this.backRect.setCornerRadius(cornerRadius);
127
+ }
128
+
129
+ // Make all descendant backgrounds match the equation background color
130
+ if (backgroundColor) {
131
+ this._matchChildBackgrounds(backgroundColor);
132
+ }
133
+ }
134
+
135
+ // Ensure the background rectangle always matches the current equation size
136
+ if (this.backRect && (this.width || this.height)) {
137
+ this.backRect.setWidthAndHeight(this.width, this.height);
138
+ }
139
+
140
+ // Final pass: center content visually within backRect
141
+ const minTop2 = Math.min(this.left.ypos, this.equalsSign.ypos, this.right.ypos);
142
+ const maxBottom2 = Math.max(
143
+ this.left.ypos + this.left.height,
144
+ this.equalsSign.ypos + this.equalsSign.height,
145
+ this.right.ypos + this.right.height
146
+ );
147
+ const topPad = minTop2;
148
+ const bottomPad = Math.max(0, (this.height || 0) - maxBottom2);
149
+ let deltaY2 = (topPad - bottomPad) / 2 - (0.06 * this.getFontSize());
150
+ if (Math.abs(deltaY2) > 0.01) {
151
+ this.left.setPosition(this.left.xpos, this.left.ypos - deltaY2);
152
+ this.equalsSign.setPosition(this.equalsSign.xpos, this.equalsSign.ypos - deltaY2);
153
+ this.right.setPosition(this.right.xpos, this.right.ypos - deltaY2);
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Computes effective padding taking into account defaults, user overrides, and pill radius clamping.
159
+ * @param {number} contentHeight
160
+ * @returns {{padX:number,padY:number}}
161
+ */
162
+ _getEffectivePadding(contentHeight) {
163
+ const ratio = this.getFontSize() / this.getRootFontSize();
164
+ const baseX = 2 * ratio;
165
+ const baseY = 2 * ratio;
166
+ const pad = this._backgroundStyle?.padding;
167
+ let padX = (typeof pad === 'number' ? pad : pad?.x) ?? baseX;
168
+ let padY = (typeof pad === 'number' ? pad : pad?.y) ?? baseY;
169
+ if (this._backgroundStyle?.pill === true) {
170
+ const radius = Math.ceil((contentHeight + 2 * padY) / 2);
171
+ if (padX < radius) padX = radius;
172
+ }
173
+ return { padX, padY };
174
+ }
175
+
176
+ _propagateBackgroundStyle(style, visited = new Set()) {
177
+ if (visited.has(this)) return;
178
+ visited.add(this);
179
+ this._backgroundStyle = style;
180
+
181
+ // Helper to recursively walk any object that might be a node
182
+ function walkNode(node, style, visited) {
183
+ if (!node || visited.has(node)) return;
184
+ visited.add(node);
185
+ if (node._propagateBackgroundStyle) {
186
+ node._propagateBackgroundStyle(style, visited);
187
+ return;
188
+ }
189
+
190
+ node._backgroundStyle = style;
191
+ if (Array.isArray(node.childList)) {
192
+ for (const c of node.childList) walkNode(c, style, visited);
193
+ }
194
+ if (node.argumentNodeList) {
195
+ for (const val of Object.values(node.argumentNodeList)) {
196
+ if (Array.isArray(val)) {
197
+ for (const v of val) walkNode(v, style, visited);
198
+ } else {
199
+ walkNode(val, style, visited);
200
+ }
201
+ }
202
+ }
203
+ }
204
+
205
+ // Propagate to childList
206
+ if (Array.isArray(this.childList)) {
207
+ for (const child of this.childList) {
208
+ walkNode(child, style, visited);
209
+ }
210
+ }
211
+
212
+ // Propagate to argumentNodeList (recursively, including arrays)
213
+ if (this.argumentNodeList && typeof this.argumentNodeList === 'object') {
214
+ for (const val of Object.values(this.argumentNodeList)) {
215
+ if (Array.isArray(val)) {
216
+ for (const v of val) {
217
+ walkNode(v, style, visited);
218
+ }
219
+ } else {
220
+ walkNode(val, style, visited);
221
+ }
222
+ }
223
+ }
224
+ }
225
+
226
+ /**
227
+ * Applies pill-shaped corner radius to all descendant nodes' backgrounds.
228
+ * Ensures child nodes don't show square corners when the parent equation uses a pill.
229
+ * @private
230
+ */
231
+ _applyPillToDescendants() {
232
+ const visited = new Set();
233
+ const stack = Array.isArray(this.childList) ? [...this.childList] : [];
234
+ while (stack.length) {
235
+ const node = stack.pop();
236
+ if (!node || visited.has(node)) continue;
237
+ visited.add(node);
238
+
239
+ if (node !== this && node.backRect && typeof node.backRect.setCornerRadius === 'function') {
240
+ const h = typeof node.height === 'number' && node.height > 0 ? node.height : 0;
241
+ const r = Math.max(0, Math.floor(h / 2));
242
+ node.backRect.setCornerRadius(r);
243
+ }
244
+
245
+ if (Array.isArray(node.childList)) {
246
+ for (const c of node.childList) stack.push(c);
247
+ }
248
+ if (node.argumentNodeList && typeof node.argumentNodeList === 'object') {
249
+ for (const val of Object.values(node.argumentNodeList)) {
250
+ if (Array.isArray(val)) {
251
+ val.forEach(v => v && stack.push(v));
252
+ } else if (val) {
253
+ stack.push(val);
254
+ }
255
+ }
256
+ }
257
+ }
258
+ }
259
+
260
+ /**
261
+ * Creates a value node from a number or a Math.js AST object.
262
+ * @param {number|object} value - The value to convert.
263
+ * @returns {omdNode} The corresponding OMD node.
264
+ * @private
265
+ */
266
+ _createNodeFromValue(value) {
267
+ if (typeof value === 'number') {
268
+ const node = new omdConstantNode({ value });
269
+ node.initialize(); // Constants need initialization to compute dimensions
270
+ return node;
271
+ }
272
+ if (typeof value === 'object' && value !== null) { // It's a mathjs AST
273
+ const NodeClass = getNodeForAST(value);
274
+ const node = new NodeClass(value);
275
+ // Most non-leaf nodes have initialize, but we call it just in case
276
+ if (typeof node.initialize === 'function') {
277
+ node.initialize();
278
+ }
279
+ return node;
280
+ }
281
+ return null;
282
+ }
283
+
284
+ /**
285
+ * Applies an operation to both sides of the equation.
286
+ * @param {number|object} value - The value to apply.
287
+ * @param {string} op - The operator symbol (e.g., '+', '-', '*', '/').
288
+ * @param {string} fn - The function name for the AST (e.g., 'add', 'subtract').
289
+ * @returns {omdEquationNode} A new equation node with the operation applied.
290
+ * @private
291
+ */
292
+ _applyOperation(value, op, fn) {
293
+ const valueNode = this._createNodeFromValue(value);
294
+ if (!valueNode) return this; // Return original if value is invalid
295
+
296
+ // Determine if we need to wrap sides in parentheses for correct precedence
297
+ const leftSideNeedsParens = this._needsParenthesesForOperation(this.left, op);
298
+ const rightSideNeedsParens = this._needsParenthesesForOperation(this.right, op);
299
+
300
+ // Wrap sides in parentheses if needed
301
+ const leftOperand = leftSideNeedsParens ?
302
+ { type: 'ParenthesisNode', content: this.left.toMathJSNode() } :
303
+ this.left.toMathJSNode();
304
+ const rightOperand = rightSideNeedsParens ?
305
+ { type: 'ParenthesisNode', content: this.right.toMathJSNode() } :
306
+ this.right.toMathJSNode();
307
+
308
+ const newLeftAst = { type: 'OperatorNode', op, fn, args: [leftOperand, valueNode.toMathJSNode()] };
309
+ const newRightAst = { type: 'OperatorNode', op, fn, args: [rightOperand, valueNode.toMathJSNode()] };
310
+
311
+ let newLeft, newRight;
312
+
313
+ if (op === '/') {
314
+ newLeft = new omdRationalNode(newLeftAst);
315
+ newRight = new omdRationalNode(newRightAst);
316
+ } else {
317
+ newLeft = new omdBinaryExpressionNode(newLeftAst);
318
+ newRight = new omdBinaryExpressionNode(newRightAst);
319
+ }
320
+
321
+ const newEquationAst = {
322
+ type: 'AssignmentNode',
323
+ object: newLeft.toMathJSNode(),
324
+ index: null,
325
+ value: newRight.toMathJSNode()
326
+ };
327
+
328
+ const newEquation = new omdEquationNode(newEquationAst);
329
+ newEquation.setFontSize(this.getFontSize());
330
+
331
+ // Establish provenance tracking from original equation to new equation
332
+ newEquation.provenance.push(this.id);
333
+
334
+ // Establish granular provenance: left side to left side, right side to right side
335
+ if (newEquation.left && this.left) {
336
+ this._establishGranularProvenance(newEquation.left, this.left, value, fn);
337
+ }
338
+ if (newEquation.right && this.right) {
339
+ this._establishGranularProvenance(newEquation.right, this.right, value, fn);
340
+ }
341
+
342
+ newEquation.initialize();
343
+ return newEquation;
344
+ }
345
+
346
+ /**
347
+ * Determines if a node needs parentheses when used as an operand with the given operation.
348
+ * This ensures correct operator precedence.
349
+ * @param {omdNode} node - The node to check
350
+ * @param {string} operation - The operation that will be applied ('*', '/', '+', '-')
351
+ * @returns {boolean} True if parentheses are needed
352
+ * @private
353
+ */
354
+ _needsParenthesesForOperation(node, operation) {
355
+ // If the node is not a binary expression, no parentheses needed
356
+ if (!node || node.type !== 'omdBinaryExpressionNode') {
357
+ return false;
358
+ }
359
+
360
+ // Define operator precedence (higher number = higher precedence)
361
+ const precedence = {
362
+ '+': 1,
363
+ '-': 1,
364
+ '*': 2,
365
+ '/': 2,
366
+ '^': 3
367
+ };
368
+
369
+ // Get the operation of the existing node
370
+ let existingOp = node.operation;
371
+ if (typeof existingOp === 'object' && existingOp && existingOp.name) {
372
+ existingOp = existingOp.name;
373
+ }
374
+ if (node.astNodeData && node.astNodeData.op) {
375
+ existingOp = node.astNodeData.op;
376
+ }
377
+
378
+ // Convert operation names to symbols if needed
379
+ const opMap = {
380
+ 'add': '+',
381
+ 'subtract': '-',
382
+ 'multiply': '*',
383
+ 'divide': '/',
384
+ 'pow': '^'
385
+ };
386
+
387
+ const currentOpSymbol = opMap[existingOp] || existingOp;
388
+ const newOpSymbol = opMap[operation] || operation;
389
+
390
+ // If we can't determine the precedence, be safe and add parentheses
391
+ if (!precedence[currentOpSymbol] || !precedence[newOpSymbol]) {
392
+ return true;
393
+ }
394
+
395
+ // Need parentheses if the existing operation has lower precedence than the new operation
396
+ // For example: (x + 2) * 3 needs parentheses, but x * 2 + 3 doesn't need them around x * 2
397
+ return precedence[currentOpSymbol] < precedence[newOpSymbol];
398
+ }
399
+
400
+ /**
401
+ * Returns a new equation with a value added to both sides.
402
+ * @param {number|object} value - The value to add.
403
+ */
404
+ addToBothSides(value) {
405
+ return this._applyOperation(value, '+', 'add');
406
+ }
407
+
408
+ /**
409
+ * Returns a new equation with a value subtracted from both sides.
410
+ * @param {number|object} value - The value to subtract.
411
+ */
412
+ subtractFromBothSides(value) {
413
+ return this._applyOperation(value, '-', 'subtract');
414
+ }
415
+
416
+ /**
417
+ * Returns a new equation with both sides multiplied by a value.
418
+ * @param {number|object} value - The value to multiply by.
419
+ * @param {string} [operationDisplayId] - Optional ID of the operation display for provenance tracking.
420
+ */
421
+ multiplyBothSides(value, operationDisplayId) {
422
+ return this._applyOperation(value, '*', 'multiply', operationDisplayId);
423
+ }
424
+
425
+ /**
426
+ * Returns a new equation with both sides divided by a value.
427
+ * @param {number|object} value - The value to divide by.
428
+ */
429
+ divideBothSides(value) {
430
+ return this._applyOperation(value, '/', 'divide');
431
+ }
432
+
433
+
434
+
435
+ /**
436
+ * Establishes granular provenance tracking between new and original nodes
437
+ * This handles equation operations like "multiply both sides" by linking the new expression to the original
438
+ * @param {omdNode} newNode - The new node being created (the result of the operation)
439
+ * @param {omdNode} originalNode - The original node being transformed
440
+ * @param {number|Object} operationValue - The value used in the operation
441
+ * @param {string} operation - The operation being performed ('add', 'subtract', 'multiply', 'divide')
442
+ * @private
443
+ */
444
+ _establishGranularProvenance(newNode, originalNode, operationValue, operation) {
445
+ if (!newNode || !originalNode) return;
446
+
447
+ // Ensure newNode has a provenance array
448
+ if (!newNode.provenance) {
449
+ newNode.provenance = [];
450
+ }
451
+
452
+ // For equation operations, we want to establish provenance between corresponding parts
453
+ if (operation === 'divide') {
454
+ // For division operations like (2x)/2 = x, check if we can simplify
455
+ if (originalNode.type === 'omdBinaryExpressionNode' &&
456
+ this._isMultiplicationOperation(originalNode)) {
457
+
458
+ // Check if the operation value matches one of the factors
459
+ const leftIsConstant = originalNode.left.isConstant();
460
+ const rightIsConstant = originalNode.right.isConstant();
461
+
462
+ // Convert operationValue to number if it's an object
463
+ const opValue = (typeof operationValue === 'object' && operationValue.getValue) ?
464
+ operationValue.getValue() : operationValue;
465
+
466
+ if (leftIsConstant && originalNode.left.getValue() === opValue) {
467
+ // Dividing by the left factor, so result should trace to right factor
468
+ this._copyProvenanceStructure(newNode, originalNode.right);
469
+ } else if (rightIsConstant && originalNode.right.getValue() === opValue) {
470
+ // Dividing by the right factor, so result should trace to left factor
471
+ this._copyProvenanceStructure(newNode, originalNode.left);
472
+ } else {
473
+ // Not a simple factor division, link to the whole expression
474
+ this._copyProvenanceStructure(newNode, originalNode);
475
+ }
476
+ } else {
477
+ // Not a multiplication, link to the whole original
478
+ this._copyProvenanceStructure(newNode, originalNode);
479
+ }
480
+ }
481
+ else if (operation === 'multiply') {
482
+ // For multiplication operations like x * 2 = 2x
483
+ // The new expression should trace back to the original expression
484
+ this._copyProvenanceStructure(newNode, originalNode);
485
+
486
+ // Also establish provenance for the binary expression structure
487
+ if (newNode.type === 'omdBinaryExpressionNode') {
488
+ // Link the left operand (which should be the original expression) to the original
489
+ if (newNode.left) {
490
+ this._copyProvenanceStructure(newNode.left, originalNode);
491
+ }
492
+ // The right operand is the operation value, no additional provenance needed
493
+ }
494
+ }
495
+ else if (operation === 'add' || operation === 'subtract') {
496
+ // For addition/subtraction, the new binary expression's provenance should
497
+ // link to the original expression, but we should handle operands separately
498
+ // to avoid incorrect linking of the added/subtracted value.
499
+ newNode.provenance.push(originalNode.id);
500
+
501
+ if (newNode.type === 'omdBinaryExpressionNode') {
502
+ // Link the left operand (the original side of the equation) to the original node structure.
503
+ if (newNode.left) {
504
+ this._copyProvenanceStructure(newNode.left, originalNode);
505
+ }
506
+ // The right operand is the new value being added/subtracted - preserve its provenance
507
+ // for proper highlighting when constants are combined later
508
+ // (Don't clear provenance - let it maintain its own identity for combination rules)
509
+ }
510
+ }
511
+ else {
512
+ // For any other operations, link to the whole original expression
513
+ this._copyProvenanceStructure(newNode, originalNode);
514
+ }
515
+ }
516
+
517
+ /**
518
+ * Helper method to check if a node represents a multiplication operation
519
+ * @param {omdNode} node - The node to check
520
+ * @returns {boolean} True if it's a multiplication operation
521
+ * @private
522
+ */
523
+ _isMultiplicationOperation(node) {
524
+ if (node.type !== 'omdBinaryExpressionNode') return false;
525
+
526
+ const op = node.operation;
527
+ return op === 'multiply' ||
528
+ (typeof op === 'object' && op && op.name === 'multiply') ||
529
+ (node.op && node.op.opName === '*');
530
+ }
531
+
532
+ /**
533
+ * Copies the provenance structure from source to target, maintaining granularity
534
+ * @param {omdNode} target - The node to set provenance on
535
+ * @param {omdNode} source - The node to copy provenance from
536
+ * @private
537
+ */
538
+ _copyProvenanceStructure(target, source) {
539
+ if (!target || !source) return;
540
+
541
+ // Initialize provenance array if it doesn't exist
542
+ if (!target.provenance) {
543
+ target.provenance = [];
544
+ }
545
+
546
+ // If the source has its own provenance, copy it
547
+ if (source.provenance && source.provenance.length > 0) {
548
+ // Create a Set to track unique IDs we've already processed
549
+ const processedIds = new Set(target.provenance);
550
+
551
+ // Process each provenance ID from source
552
+ source.provenance.forEach(id => {
553
+ if (!processedIds.has(id)) {
554
+ processedIds.add(id);
555
+ target.provenance.push(id);
556
+ }
557
+ });
558
+ }
559
+
560
+ // Add the source's own ID if not already present
561
+ if (!target.provenance.includes(source.id)) {
562
+ target.provenance.push(source.id);
563
+ }
564
+
565
+ // If both nodes have the same structure, recursively copy provenance
566
+ if (target.type === source.type) {
567
+ if (target.argumentNodeList && source.argumentNodeList) {
568
+ for (const key of Object.keys(source.argumentNodeList)) {
569
+ const targetChild = target.argumentNodeList[key];
570
+ const sourceChild = source.argumentNodeList[key];
571
+
572
+ if (targetChild && sourceChild) {
573
+ if (Array.isArray(targetChild) && Array.isArray(sourceChild)) {
574
+ // Handle array of children
575
+ for (let i = 0; i < Math.min(targetChild.length, sourceChild.length); i++) {
576
+ if (targetChild[i] && sourceChild[i]) {
577
+ this._copyProvenanceStructure(targetChild[i], sourceChild[i]);
578
+ }
579
+ }
580
+ } else {
581
+ // Handle single child node
582
+ this._copyProvenanceStructure(targetChild, sourceChild);
583
+ }
584
+ }
585
+ }
586
+ }
587
+ }
588
+ }
589
+
590
+
591
+ /**
592
+ * Creates an omdEquationNode instance from a string.
593
+ * @param {string} equationString - The string to parse (e.g., "2x+4=10").
594
+ * @returns {omdEquationNode} A new instance of omdEquationNode.
595
+ */
596
+ static fromString(equationString) {
597
+ if (!equationString.includes('=')) {
598
+ throw new Error("Input string is not a valid equation.");
599
+ }
600
+
601
+ const parts = equationString.split('=');
602
+ if (parts.length > 2) {
603
+ throw new Error("Equation can only have one '=' sign.");
604
+ }
605
+
606
+ const left = parts[0].trim();
607
+ const right = parts[1].trim();
608
+
609
+ if (!left || !right) {
610
+ throw new Error("Equation must have a left and a right side.");
611
+ }
612
+
613
+ // Manually construct an AST-like object that the constructor can understand.
614
+ const ast = {
615
+ type: "AssignmentNode",
616
+ object: math.parse(left),
617
+ value: math.parse(right),
618
+ // Add a clone method so it behaves like a real math.js node for our system.
619
+ clone: function () {
620
+ return {
621
+ type: this.type,
622
+ object: this.object.clone(),
623
+ value: this.value.clone(),
624
+ clone: this.clone
625
+ };
626
+ }
627
+ };
628
+
629
+ return new omdEquationNode(ast);
630
+ }
631
+
632
+ clone() {
633
+ // Create a clone from a deep-copied AST. This creates a node tree
634
+ // with the exact structure needed for simplification.
635
+ const newAstNodeData = JSON.parse(JSON.stringify(this.astNodeData));
636
+ const clone = new omdEquationNode(newAstNodeData);
637
+
638
+ // Recursively fix the provenance chain for the new clone.
639
+ clone._syncProvenanceFrom(this);
640
+
641
+ clone.setFontSize(this.getFontSize());
642
+
643
+ // Ensure argumentNodeList exists on clone for replacement machinery
644
+ clone.argumentNodeList = { left: clone.left, right: clone.right };
645
+
646
+ return clone;
647
+ }
648
+
649
+ /**
650
+ * Overrides default deselect behavior for equations inside a calculation.
651
+ * @param {omdNode} root - The root of the deselection event.
652
+ */
653
+ deselect(root) {
654
+ if (!(root instanceof omdNode)) root = this;
655
+
656
+ if (this === root && this.parent instanceof omdNode) {
657
+ this.parent.select(root);
658
+ }
659
+
660
+ this.backRect.setFillColor(omdColor.lightGray);
661
+ if (this.defaultOpaqueBack == false) {
662
+ this.backRect.setOpacity(0.01);
663
+ }
664
+
665
+ this.childList.forEach((child) => {
666
+ if (child !== root && child instanceof omdNode) {
667
+ child.deselect(root);
668
+ }
669
+ });
670
+ }
671
+
672
+ /**
673
+ * Converts the omdEquationNode to a math.js AST node.
674
+ * @returns {Object} A math.js-compatible AST node.
675
+ */
676
+ toMathJSNode() {
677
+ let astNode;
678
+
679
+ // Get fresh AST representations from children to ensure parentheses and other
680
+ // structural elements are properly preserved
681
+ if (this.astNodeData.type === "AssignmentNode") {
682
+ astNode = {
683
+ type: 'AssignmentNode',
684
+ object: this.left.toMathJSNode(),
685
+ value: this.right.toMathJSNode(),
686
+ id: this.id,
687
+ provenance: this.provenance
688
+ };
689
+ } else {
690
+ astNode = {
691
+ type: 'OperatorNode', op: '=', fn: 'equal',
692
+ args: [this.left.toMathJSNode(), this.right.toMathJSNode()],
693
+ id: this.id,
694
+ provenance: this.provenance
695
+ };
696
+ }
697
+
698
+ // Add a clone method to maintain compatibility with math.js's expectations.
699
+ astNode.clone = function() {
700
+ const clonedNode = { ...this };
701
+ if (this.object) clonedNode.object = this.object.clone();
702
+ if (this.value) clonedNode.value = this.value.clone();
703
+ if (this.args) clonedNode.args = this.args.map(arg => arg.clone());
704
+ return clonedNode;
705
+ };
706
+ return astNode;
707
+ }
708
+
709
+ /**
710
+ * Applies a function to both sides of the equation
711
+ * @param {string} functionName - The name of the function to apply
712
+ * @returns {omdEquationNode} A new equation with the function applied to both sides
713
+ */
714
+ applyFunction(functionName) {
715
+ const leftWithFunction = this._createFunctionNode(functionName, this.left);
716
+ const rightWithFunction = this._createFunctionNode(functionName, this.right);
717
+
718
+ const newEquation = this._createNewEquation(leftWithFunction, rightWithFunction);
719
+ newEquation.provenance.push(this.id);
720
+
721
+ return newEquation;
722
+ }
723
+
724
+ /**
725
+ * Creates a function node wrapping the given argument
726
+ * @param {string} functionName - The function name
727
+ * @param {omdNode} argument - The argument to wrap
728
+ * @returns {omdNode} The function node
729
+ * @private
730
+ */
731
+ _createFunctionNode(functionName, argument) {
732
+ // Create a math.js AST for the function
733
+ const functionAst = {
734
+ type: 'FunctionNode',
735
+ fn: { type: 'SymbolNode', name: functionName },
736
+ args: [argument.toMathJSNode()]
737
+ };
738
+
739
+ // Use the already imported getNodeForAST function
740
+ const NodeClass = getNodeForAST(functionAst);
741
+ const functionNode = new NodeClass(functionAst);
742
+ functionNode.setFontSize(this.getFontSize());
743
+ return functionNode;
744
+ }
745
+
746
+ /**
747
+ * Creates a new equation from left and right sides
748
+ * @param {omdNode} left - The left side
749
+ * @param {omdNode} right - The right side
750
+ * @returns {omdEquationNode} The new equation
751
+ * @private
752
+ */
753
+ _createNewEquation(left, right) {
754
+ const newAst = {
755
+ type: "AssignmentNode",
756
+ object: left.toMathJSNode(),
757
+ value: right.toMathJSNode(),
758
+ clone: function () {
759
+ return {
760
+ type: this.type,
761
+ object: this.object.clone(),
762
+ value: this.value.clone(),
763
+ clone: this.clone
764
+ };
765
+ }
766
+ };
767
+
768
+ return new omdEquationNode(newAst);
769
+ }
770
+
771
+ /**
772
+ * Apply an operation to one or both sides of the equation
773
+ * @param {number|omdNode} value - The value to apply
774
+ * @param {string} operation - 'add', 'subtract', 'multiply', or 'divide'
775
+ * @param {string} side - 'left', 'right', or 'both' (default: 'both')
776
+ * @returns {omdEquationNode} New equation with operation applied
777
+ */
778
+ applyOperation(value, operation, side = 'both') {
779
+ // Map operation names to operators and function names
780
+ const operationMap = {
781
+ 'add': { op: '+', fn: 'add' },
782
+ 'subtract': { op: '-', fn: 'subtract' },
783
+ 'multiply': { op: '*', fn: 'multiply' },
784
+ 'divide': { op: '/', fn: 'divide' }
785
+ };
786
+
787
+ const opInfo = operationMap[operation];
788
+ if (!opInfo) {
789
+ throw new Error(`Unknown operation: ${operation}`);
790
+ }
791
+
792
+ // Handle different side options
793
+ if (side === 'both') {
794
+ // Use existing methods for both sides
795
+ return this._applyOperation(value, opInfo.op, opInfo.fn);
796
+ }
797
+
798
+ // For single side operations, we need to create the new equation manually
799
+ const valueNode = this._createNodeFromValue(value);
800
+ if (!valueNode) {
801
+ throw new Error("Invalid value provided");
802
+ }
803
+
804
+ // Create new AST for the specified side
805
+ let newLeftAst, newRightAst;
806
+
807
+ if (side === 'left') {
808
+ // Apply operation to left side only
809
+ const leftNeedsParens = this._needsParenthesesForOperation(this.left, opInfo.op);
810
+ const leftOperand = leftNeedsParens ?
811
+ { type: 'ParenthesisNode', content: this.left.toMathJSNode() } :
812
+ this.left.toMathJSNode();
813
+
814
+ newLeftAst = { type: 'OperatorNode', op: opInfo.op, fn: opInfo.fn, args: [leftOperand, valueNode.toMathJSNode()] };
815
+ newRightAst = this.right.toMathJSNode();
816
+ } else if (side === 'right') {
817
+ // Apply operation to right side only
818
+ const rightNeedsParens = this._needsParenthesesForOperation(this.right, opInfo.op);
819
+ const rightOperand = rightNeedsParens ?
820
+ { type: 'ParenthesisNode', content: this.right.toMathJSNode() } :
821
+ this.right.toMathJSNode();
822
+
823
+ newLeftAst = this.left.toMathJSNode();
824
+ newRightAst = { type: 'OperatorNode', op: opInfo.op, fn: opInfo.fn, args: [rightOperand, valueNode.toMathJSNode()] };
825
+ } else {
826
+ throw new Error(`Invalid side: ${side}. Must be 'left', 'right', or 'both'`);
827
+ }
828
+
829
+ // Create nodes from ASTs
830
+ let newLeft, newRight;
831
+
832
+ if (side === 'left' && opInfo.op === '/') {
833
+ newLeft = new omdRationalNode(newLeftAst);
834
+ newRight = getNodeForAST(newRightAst) === omdNode ? this.right : new (getNodeForAST(newRightAst))(newRightAst);
835
+ } else if (side === 'right' && opInfo.op === '/') {
836
+ newLeft = getNodeForAST(newLeftAst) === omdNode ? this.left : new (getNodeForAST(newLeftAst))(newLeftAst);
837
+ newRight = new omdRationalNode(newRightAst);
838
+ } else if (side === 'left') {
839
+ newLeft = new omdBinaryExpressionNode(newLeftAst);
840
+ newRight = getNodeForAST(newRightAst) === omdNode ? this.right : new (getNodeForAST(newRightAst))(newRightAst);
841
+ } else {
842
+ newLeft = getNodeForAST(newLeftAst) === omdNode ? this.left : new (getNodeForAST(newLeftAst))(newLeftAst);
843
+ newRight = new omdBinaryExpressionNode(newRightAst);
844
+ }
845
+
846
+ // Create new equation
847
+ const newEquationAst = {
848
+ type: 'AssignmentNode',
849
+ object: newLeft.toMathJSNode(),
850
+ value: newRight.toMathJSNode()
851
+ };
852
+
853
+ const newEquation = new omdEquationNode(newEquationAst);
854
+ newEquation.setFontSize(this.getFontSize());
855
+ newEquation.provenance.push(this.id);
856
+
857
+ // Initialize to compute dimensions
858
+ newEquation.initialize();
859
+
860
+ return newEquation;
861
+ }
862
+
863
+ /**
864
+ * Swap left and right sides of the equation
865
+ * @returns {omdEquationNode} New equation with sides swapped
866
+ */
867
+ swapSides() {
868
+ const newEquation = this.clone();
869
+ [newEquation.left, newEquation.right] = [newEquation.right, newEquation.left];
870
+
871
+ // Update the AST for consistency
872
+ [newEquation.astNodeData.object, newEquation.astNodeData.value] =
873
+ [newEquation.astNodeData.value, newEquation.astNodeData.object];
874
+
875
+ newEquation.provenance.push(this.id);
876
+
877
+ // This is a layout change, not a mathematical simplification, so no need for granular provenance
878
+ newEquation.initialize();
879
+ return newEquation;
880
+ }
881
+
882
+ /**
883
+ * Returns a string representation of the equation
884
+ * @returns {string} The equation as a string
885
+ */
886
+ toString() {
887
+ return `${this.left.toString()} = ${this.right.toString()}`;
888
+ }
889
+
890
+ /**
891
+ * Configure equation background styling. Defaults remain unchanged if not provided.
892
+ * @param {{ backgroundColor?: string, cornerRadius?: number, pill?: boolean }} style
893
+ */
894
+ setBackgroundStyle(style = {}) {
895
+ this._backgroundStyle = { ...(this._backgroundStyle || {}), ...style };
896
+ this._propagateBackgroundStyle(this._backgroundStyle);
897
+ if (this.backRect && (this.width || this.height)) {
898
+ this.updateLayout();
899
+ }
900
+ }
901
+
902
+ /**
903
+ * Returns the horizontal anchor X for the equals sign center relative to this node's origin.
904
+ * Accounts for background padding and internal spacing.
905
+ * @returns {number}
906
+ */
907
+ getEqualsAnchorX() {
908
+ const spacing = 8 * this.getFontSize() / this.getRootFontSize();
909
+ // Use EFFECTIVE padding so pill clamping and tall nodes are accounted for
910
+ const contentHeight = Math.max(this.left?.height || 0, this.equalsSign?.height || 0, this.right?.height || 0);
911
+ const { padX } = this._getEffectivePadding(contentHeight);
912
+ // Anchor at center of equals sign
913
+ return padX + this.left.width + spacing + (this.equalsSign?.width || 0) / 2;
914
+ }
915
+
916
+ /**
917
+ * Returns the X padding applied by background style
918
+ * @returns {number}
919
+ */
920
+ getBackgroundPaddingX() {
921
+ const pad = this._backgroundStyle?.padding;
922
+ return pad == null ? 0 : (typeof pad === 'number' ? pad : (pad.x ?? 0));
923
+ }
924
+
925
+ /**
926
+ * Returns the effective horizontal padding used in layout, including pill clamping
927
+ * @returns {number}
928
+ */
929
+ getEffectiveBackgroundPaddingX() {
930
+ const contentHeight = Math.max(this.left?.height || 0, this.equalsSign?.height || 0, this.right?.height || 0);
931
+ const { padX } = this._getEffectivePadding(contentHeight);
932
+ return padX;
933
+ }
934
+
935
+ /**
936
+ * Hides the backgrounds of all child nodes (descendants), preserving only this node's background.
937
+ * @private
938
+ */
939
+ _matchChildBackgrounds(color) {
940
+ const visited = new Set();
941
+ const stack = Array.isArray(this.childList) ? [...this.childList] : [];
942
+ while (stack.length) {
943
+ const node = stack.pop();
944
+ if (!node || visited.has(node)) continue;
945
+ visited.add(node);
946
+
947
+ if (node !== this && node.backRect) {
948
+ node.backRect.setFillColor(color);
949
+ node.backRect.setOpacity(1.0);
950
+ }
951
+
952
+ if (Array.isArray(node.childList)) {
953
+ for (const c of node.childList) stack.push(c);
954
+ }
955
+ if (node.argumentNodeList && typeof node.argumentNodeList === 'object') {
956
+ for (const val of Object.values(node.argumentNodeList)) {
957
+ if (Array.isArray(val)) {
958
+ val.forEach(v => v && stack.push(v));
959
+ } else if (val) {
960
+ stack.push(val);
961
+ }
962
+ }
963
+ }
964
+ }
965
+ }
966
+
967
+ /**
968
+ * Evaluates the equation by evaluating both sides and checking for equality.
969
+ * @param {Object} variables - A map of variable names to their numeric values.
970
+ * @returns {Object} An object containing the evaluated left and right sides.
971
+ */
972
+ evaluate(variables = {}) {
973
+ const leftValue = this.left.evaluate(variables);
974
+ const rightValue = this.right.evaluate(variables);
975
+
976
+ return { left: leftValue, right: rightValue };
977
+ }
978
+ /**
979
+ * Renders the equation to different visualization formats
980
+ * @param {string} visualizationType - "graph" | "table" | "hanger"
981
+ * @param {Object} options - Optional configuration
982
+ * @param {string} options.side - "both" (default), "left", or "right"
983
+ * @param {number} options.xMin - Domain min for x (default: -10)
984
+ * @param {number} options.xMax - Domain max for x (default: 10)
985
+ * @param {number} options.yMin - Range min for y (graph only, default: -10)
986
+ * @param {number} options.yMax - Range max for y (graph only, default: 10)
987
+ * @param {number} options.stepSize - Step size for table (default: 1)
988
+ * @returns {Object} JSON per schemas in src/json-schemas.md
989
+ */
990
+ renderTo(visualizationType, options = {}) {
991
+ // Set default options
992
+ const defaultOptions = {
993
+ side: "both",
994
+ xMin: -10,
995
+ xMax: 10,
996
+ yMin: -10,
997
+ yMax: 10,
998
+ stepSize: 1
999
+ };
1000
+ const mergedOptions = { ...defaultOptions, ...options };
1001
+
1002
+ switch (visualizationType.toLowerCase()) {
1003
+ case 'graph':
1004
+ return this._renderToGraph(mergedOptions);
1005
+ case 'table':
1006
+ return this._renderToTable(mergedOptions);
1007
+ case 'hanger':
1008
+ return this._renderToHanger();
1009
+ case 'tileequation': {
1010
+ const leftExpr = this.getLeft().toString();
1011
+ const rightExpr = this.getRight().toString();
1012
+ const eqString = `${leftExpr}=${rightExpr}`;
1013
+ // Colors/options passthrough
1014
+ const plusColor = mergedOptions.plusColor || '#79BBFD';
1015
+ const equalsColor = mergedOptions.equalsColor || '#FF6B6B';
1016
+ const xPillColor = mergedOptions.xPillColor; // optional
1017
+ const tileBgColor = mergedOptions.tileBackgroundColor; // optional
1018
+ const dotColor = mergedOptions.dotColor; // optional
1019
+ const tileSize = mergedOptions.tileSize || 28;
1020
+ const dotsPerColumn = mergedOptions.dotsPerColumn || 10;
1021
+ return {
1022
+ omdType: 'tileEquation',
1023
+ equation: eqString,
1024
+ tileSize,
1025
+ dotsPerColumn,
1026
+ plusColor,
1027
+ equalsColor,
1028
+ xPill: xPillColor ? { color: xPillColor } : undefined,
1029
+ numberTileDefaults: {
1030
+ backgroundColor: tileBgColor,
1031
+ dotColor
1032
+ }
1033
+ };
1034
+ }
1035
+ default:
1036
+ throw new Error(`Unknown visualization type: ${visualizationType}. Supported types are: graph, table, hanger`);
1037
+ }
1038
+ }
1039
+
1040
+ /**
1041
+ * Gets the left side of the equation
1042
+ * @returns {omdNode} The left side node
1043
+ */
1044
+ getLeft() {
1045
+ return this.left;
1046
+ }
1047
+
1048
+ /**
1049
+ * Gets the right side of the equation
1050
+ * @returns {omdNode} The right side node
1051
+ */
1052
+ getRight() {
1053
+ return this.right;
1054
+ }
1055
+
1056
+ /**
1057
+ * Generates JSON configuration for coordinate plane graph visualization
1058
+ * @param {Object} options - Configuration options
1059
+ * @returns {Object} JSON configuration for omdCoordinatePlane
1060
+ * @private
1061
+ */
1062
+ _renderToGraph(options) {
1063
+ const leftExpr = this._normalizeExpressionString(this.getLeft().toString());
1064
+ const rightExpr = this._normalizeExpressionString(this.getRight().toString());
1065
+
1066
+ let graphEquations = [];
1067
+ if (options.side === 'left') {
1068
+ graphEquations = [{ equation: `y = ${leftExpr}`, domain: { min: options.xMin, max: options.xMax }, color: 'blue', strokeWidth: 2 }];
1069
+ } else if (options.side === 'right') {
1070
+ graphEquations = [{ equation: `y = ${rightExpr}`, domain: { min: options.xMin, max: options.xMax }, color: 'red', strokeWidth: 2 }];
1071
+ } else {
1072
+ // both: plot left and right as two functions; intersection corresponds to equality
1073
+ graphEquations = [
1074
+ { equation: `y = ${leftExpr}`, domain: { min: options.xMin, max: options.xMax }, color: 'blue', strokeWidth: 2 },
1075
+ { equation: `y = ${rightExpr}`, domain: { min: options.xMin, max: options.xMax }, color: 'red', strokeWidth: 2 }
1076
+ ];
1077
+ }
1078
+
1079
+ return {
1080
+ omdType: "coordinatePlane",
1081
+ xMin: options.xMin,
1082
+ xMax: options.xMax,
1083
+ yMin: options.yMin,
1084
+ yMax: options.yMax,
1085
+ // Allow caller to override visual settings via options
1086
+ xLabel: (options.xLabel !== undefined) ? options.xLabel : "x",
1087
+ yLabel: (options.yLabel !== undefined) ? options.yLabel : "y",
1088
+ size: (options.size !== undefined) ? options.size : "medium",
1089
+ tickInterval: (options.tickInterval !== undefined) ? options.tickInterval : 1,
1090
+ forceAllTickLabels: (options.forceAllTickLabels !== undefined) ? options.forceAllTickLabels : true,
1091
+ showTickLabels: (options.showTickLabels !== undefined) ? options.showTickLabels : true,
1092
+ graphEquations,
1093
+ lineSegments: [],
1094
+ dotValues: [],
1095
+ shapeSet: []
1096
+ };
1097
+ }
1098
+
1099
+ /**
1100
+ * Generates JSON configuration for table visualization
1101
+ * @param {Object} options - Configuration options
1102
+ * @returns {Object} JSON configuration for omdTable
1103
+ * @private
1104
+ */
1105
+ _renderToTable(options) {
1106
+ // Single side: let omdTable generate rows from equation
1107
+ if (options.side === 'left') {
1108
+ const expr = this._normalizeExpressionString(this.getLeft().toString());
1109
+ return {
1110
+ omdType: "table",
1111
+ title: `Function Table: y = ${expr}`,
1112
+ headers: ["x", "y"],
1113
+ equation: `y = ${expr}`,
1114
+ xMin: options.xMin,
1115
+ xMax: options.xMax,
1116
+ stepSize: options.stepSize
1117
+ };
1118
+ } else if (options.side === 'right') {
1119
+ const expr = this._normalizeExpressionString(this.getRight().toString());
1120
+ return {
1121
+ omdType: "table",
1122
+ title: `Function Table: y = ${expr}`,
1123
+ headers: ["x", "y"],
1124
+ equation: `y = ${expr}`,
1125
+ xMin: options.xMin,
1126
+ xMax: options.xMax,
1127
+ stepSize: options.stepSize
1128
+ };
1129
+ }
1130
+
1131
+ // Both sides: compute data for x, left(x), right(x)
1132
+ const leftSide = this.getLeft();
1133
+ const rightSide = this.getRight();
1134
+ const leftLabel = leftSide.toString();
1135
+ const rightLabel = rightSide.toString();
1136
+
1137
+ const headers = ["x", leftLabel, rightLabel];
1138
+ const data = [];
1139
+ const start = options.xMin;
1140
+ const end = options.xMax;
1141
+ const step = options.stepSize || 1;
1142
+ for (let x = start; x <= end; x += step) {
1143
+ try {
1144
+ const l = leftSide.evaluate({ x });
1145
+ const r = rightSide.evaluate({ x });
1146
+ if (isFinite(l) && isFinite(r)) {
1147
+ data.push([x, Number(l), Number(r)]);
1148
+ }
1149
+ } catch (_) {
1150
+ // Skip points that fail to evaluate
1151
+ }
1152
+ }
1153
+
1154
+ return {
1155
+ omdType: "table",
1156
+ title: `Equation Table: ${this.toString()}`,
1157
+ headers,
1158
+ data
1159
+ };
1160
+ }
1161
+
1162
+ /**
1163
+ * Generates table for a single side of the equation
1164
+ * @param {omdNode} side - The side to render
1165
+ * @param {string} title - Title for the table
1166
+ * @returns {Object} JSON configuration for omdTable
1167
+ * @private
1168
+ */
1169
+ _renderSingleSideTable(side, title, options = {}) {
1170
+ const expression = this._normalizeExpressionString(side.toString());
1171
+ return {
1172
+ omdType: "table",
1173
+ title: `${title}: ${expression}`,
1174
+ headers: ["x", "y"],
1175
+ equation: `y = ${expression}`,
1176
+ xMin: options.xMin ?? -5,
1177
+ xMax: options.xMax ?? 5,
1178
+ stepSize: options.stepSize ?? 1
1179
+ };
1180
+ }
1181
+
1182
+ /**
1183
+ * Generates JSON configuration for balance hanger visualization
1184
+ * @returns {Object} JSON configuration for omdBalanceHanger
1185
+ * @private
1186
+ */
1187
+ _renderToHanger() {
1188
+ // Convert equation sides to hanger representation
1189
+ const leftValues = this._convertToHangerValues(this.getLeft());
1190
+ const rightValues = this._convertToHangerValues(this.getRight());
1191
+
1192
+ return {
1193
+ omdType: "balanceHanger",
1194
+ leftValues: leftValues,
1195
+ rightValues: rightValues,
1196
+ tilt: "none" // Equations should be balanced by definition
1197
+ };
1198
+ }
1199
+
1200
+ /**
1201
+ * Normalizes an expression string for evaluation/graphing
1202
+ * - Inserts '*' between number-variable and variable-number
1203
+ * @param {string} expr
1204
+ * @returns {string}
1205
+ * @private
1206
+ */
1207
+ _normalizeExpressionString(expr) {
1208
+ if (!expr || typeof expr !== 'string') return String(expr || '');
1209
+ return expr
1210
+ .replace(/(\d)([a-zA-Z])/g, '$1*$2')
1211
+ .replace(/([a-zA-Z])(\d)/g, '$1*$2');
1212
+ }
1213
+
1214
+ /**
1215
+ * Converts an equation side to balance hanger values (simple array of values)
1216
+ * @param {omdNode} node - The node to convert
1217
+ * @returns {Array} Array of simple values for the hanger
1218
+ * @private
1219
+ */
1220
+ _convertToHangerValues(node) {
1221
+ const values = [];
1222
+
1223
+ // Handle different node types
1224
+ if (node.type === 'omdConstantNode') {
1225
+ // Add the constant value
1226
+ const value = node.getValue();
1227
+ if (value !== 0) {
1228
+ values.push(value);
1229
+ }
1230
+ } else if (node.type === 'omdVariableNode') {
1231
+ // Add variable name
1232
+ values.push(node.name || "x");
1233
+ } else if (node.type === 'omdBinaryExpressionNode') {
1234
+ // Handle binary expressions by recursively processing operands
1235
+ const leftValues = this._convertToHangerValues(node.left);
1236
+ const rightValues = this._convertToHangerValues(node.right);
1237
+
1238
+ // For addition, combine values
1239
+ if (node.operation === 'add' || node.operation === 'plus') {
1240
+ values.push(...leftValues, ...rightValues);
1241
+ }
1242
+ // For multiplication, handle special cases
1243
+ else if (node.operation === 'multiply') {
1244
+ // Check if one operand is a constant (coefficient)
1245
+ if (node.left.type === 'omdConstantNode' && node.right.type === 'omdVariableNode') {
1246
+ const coefficient = Math.abs(node.left.getValue());
1247
+ const varName = node.right.name || "x";
1248
+ // Add multiple instances of the variable
1249
+ for (let i = 0; i < coefficient; i++) {
1250
+ values.push(varName);
1251
+ }
1252
+ } else if (node.right.type === 'omdConstantNode' && node.left.type === 'omdVariableNode') {
1253
+ const coefficient = Math.abs(node.right.getValue());
1254
+ const varName = node.left.name || "x";
1255
+ // Add multiple instances of the variable
1256
+ for (let i = 0; i < coefficient; i++) {
1257
+ values.push(varName);
1258
+ }
1259
+ } else {
1260
+ // For other multiplications, treat as a single expression
1261
+ values.push(node.toString());
1262
+ }
1263
+ } else {
1264
+ // For other operations, treat as a single expression
1265
+ values.push(node.toString());
1266
+ }
1267
+ } else {
1268
+ // For any other node types, treat as expression string
1269
+ values.push(node.toString());
1270
+ }
1271
+
1272
+ return values;
1273
+ }
1223
1274
  }