@teachinglab/omd 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. package/README.md +138 -0
  2. package/canvas/core/canvasConfig.js +203 -0
  3. package/canvas/core/omdCanvas.js +475 -0
  4. package/canvas/drawing/segment.js +168 -0
  5. package/canvas/drawing/stroke.js +386 -0
  6. package/canvas/events/eventManager.js +435 -0
  7. package/canvas/events/pointerEventHandler.js +263 -0
  8. package/canvas/features/focusFrameManager.js +287 -0
  9. package/canvas/index.js +49 -0
  10. package/canvas/tools/eraserTool.js +322 -0
  11. package/canvas/tools/pencilTool.js +319 -0
  12. package/canvas/tools/selectTool.js +457 -0
  13. package/canvas/tools/tool.js +223 -0
  14. package/canvas/tools/toolManager.js +394 -0
  15. package/canvas/ui/cursor.js +438 -0
  16. package/canvas/ui/toolbar.js +304 -0
  17. package/canvas/utils/boundingBox.js +378 -0
  18. package/canvas/utils/mathUtils.js +259 -0
  19. package/docs/api/configuration-options.md +104 -0
  20. package/docs/api/eventManager.md +68 -0
  21. package/docs/api/focusFrameManager.md +150 -0
  22. package/docs/api/index.md +91 -0
  23. package/docs/api/main.md +58 -0
  24. package/docs/api/omdBinaryExpressionNode.md +227 -0
  25. package/docs/api/omdCanvas.md +142 -0
  26. package/docs/api/omdConfigManager.md +192 -0
  27. package/docs/api/omdConstantNode.md +117 -0
  28. package/docs/api/omdDisplay.md +121 -0
  29. package/docs/api/omdEquationNode.md +161 -0
  30. package/docs/api/omdEquationSequenceNode.md +301 -0
  31. package/docs/api/omdEquationStack.md +139 -0
  32. package/docs/api/omdFunctionNode.md +141 -0
  33. package/docs/api/omdGroupNode.md +182 -0
  34. package/docs/api/omdHelpers.md +96 -0
  35. package/docs/api/omdLeafNode.md +163 -0
  36. package/docs/api/omdNode.md +101 -0
  37. package/docs/api/omdOperationDisplayNode.md +139 -0
  38. package/docs/api/omdOperatorNode.md +127 -0
  39. package/docs/api/omdParenthesisNode.md +122 -0
  40. package/docs/api/omdPopup.md +117 -0
  41. package/docs/api/omdPowerNode.md +127 -0
  42. package/docs/api/omdRationalNode.md +128 -0
  43. package/docs/api/omdSequenceNode.md +128 -0
  44. package/docs/api/omdSimplification.md +110 -0
  45. package/docs/api/omdSqrtNode.md +79 -0
  46. package/docs/api/omdStepVisualizer.md +115 -0
  47. package/docs/api/omdStepVisualizerHighlighting.md +61 -0
  48. package/docs/api/omdStepVisualizerInteractiveSteps.md +129 -0
  49. package/docs/api/omdStepVisualizerLayout.md +60 -0
  50. package/docs/api/omdStepVisualizerNodeUtils.md +140 -0
  51. package/docs/api/omdStepVisualizerTextBoxes.md +68 -0
  52. package/docs/api/omdToolbar.md +102 -0
  53. package/docs/api/omdTranscriptionService.md +76 -0
  54. package/docs/api/omdTreeDiff.md +134 -0
  55. package/docs/api/omdUnaryExpressionNode.md +174 -0
  56. package/docs/api/omdUtilities.md +70 -0
  57. package/docs/api/omdVariableNode.md +148 -0
  58. package/docs/api/selectTool.md +74 -0
  59. package/docs/api/simplificationEngine.md +98 -0
  60. package/docs/api/simplificationRules.md +77 -0
  61. package/docs/api/simplificationUtils.md +64 -0
  62. package/docs/api/transcribe.md +43 -0
  63. package/docs/api-reference.md +85 -0
  64. package/docs/index.html +454 -0
  65. package/docs/user-guide.md +9 -0
  66. package/index.js +67 -0
  67. package/omd/config/omdConfigManager.js +267 -0
  68. package/omd/core/index.js +150 -0
  69. package/omd/core/omdEquationStack.js +347 -0
  70. package/omd/core/omdUtilities.js +115 -0
  71. package/omd/display/omdDisplay.js +443 -0
  72. package/omd/display/omdToolbar.js +502 -0
  73. package/omd/nodes/omdBinaryExpressionNode.js +460 -0
  74. package/omd/nodes/omdConstantNode.js +142 -0
  75. package/omd/nodes/omdEquationNode.js +1223 -0
  76. package/omd/nodes/omdEquationSequenceNode.js +1273 -0
  77. package/omd/nodes/omdFunctionNode.js +352 -0
  78. package/omd/nodes/omdGroupNode.js +68 -0
  79. package/omd/nodes/omdLeafNode.js +77 -0
  80. package/omd/nodes/omdNode.js +557 -0
  81. package/omd/nodes/omdOperationDisplayNode.js +322 -0
  82. package/omd/nodes/omdOperatorNode.js +109 -0
  83. package/omd/nodes/omdParenthesisNode.js +293 -0
  84. package/omd/nodes/omdPowerNode.js +236 -0
  85. package/omd/nodes/omdRationalNode.js +295 -0
  86. package/omd/nodes/omdSqrtNode.js +308 -0
  87. package/omd/nodes/omdUnaryExpressionNode.js +178 -0
  88. package/omd/nodes/omdVariableNode.js +123 -0
  89. package/omd/simplification/omdSimplification.js +171 -0
  90. package/omd/simplification/omdSimplificationEngine.js +886 -0
  91. package/omd/simplification/package.json +6 -0
  92. package/omd/simplification/rules/binaryRules.js +1037 -0
  93. package/omd/simplification/rules/functionRules.js +111 -0
  94. package/omd/simplification/rules/index.js +48 -0
  95. package/omd/simplification/rules/parenthesisRules.js +19 -0
  96. package/omd/simplification/rules/powerRules.js +143 -0
  97. package/omd/simplification/rules/rationalRules.js +475 -0
  98. package/omd/simplification/rules/sqrtRules.js +48 -0
  99. package/omd/simplification/rules/unaryRules.js +37 -0
  100. package/omd/simplification/simplificationRules.js +32 -0
  101. package/omd/simplification/simplificationUtils.js +1056 -0
  102. package/omd/step-visualizer/omdStepVisualizer.js +597 -0
  103. package/omd/step-visualizer/omdStepVisualizerHighlighting.js +206 -0
  104. package/omd/step-visualizer/omdStepVisualizerLayout.js +245 -0
  105. package/omd/step-visualizer/omdStepVisualizerTextBoxes.js +163 -0
  106. package/omd/utils/omdNodeOverlay.js +638 -0
  107. package/omd/utils/omdPopup.js +1084 -0
  108. package/omd/utils/omdStepVisualizerInteractiveSteps.js +491 -0
  109. package/omd/utils/omdStepVisualizerNodeUtils.js +268 -0
  110. package/omd/utils/omdTranscriptionService.js +125 -0
  111. package/omd/utils/omdTreeDiff.js +734 -0
  112. package/package.json +46 -0
  113. package/src/index.js +62 -0
  114. package/src/json-schemas.md +109 -0
  115. package/src/omd-json-samples.js +115 -0
  116. package/src/omd.js +109 -0
  117. package/src/omdApp.js +391 -0
  118. package/src/omdAppCanvas.js +336 -0
  119. package/src/omdBalanceHanger.js +172 -0
  120. package/src/omdColor.js +13 -0
  121. package/src/omdCoordinatePlane.js +467 -0
  122. package/src/omdEquation.js +125 -0
  123. package/src/omdExpression.js +104 -0
  124. package/src/omdFunction.js +113 -0
  125. package/src/omdMetaExpression.js +287 -0
  126. package/src/omdNaturalExpression.js +564 -0
  127. package/src/omdNode.js +384 -0
  128. package/src/omdNumber.js +53 -0
  129. package/src/omdNumberLine.js +107 -0
  130. package/src/omdNumberTile.js +119 -0
  131. package/src/omdOperator.js +73 -0
  132. package/src/omdPowerExpression.js +92 -0
  133. package/src/omdProblem.js +55 -0
  134. package/src/omdRatioChart.js +232 -0
  135. package/src/omdRationalExpression.js +115 -0
  136. package/src/omdSampleData.js +215 -0
  137. package/src/omdShapes.js +476 -0
  138. package/src/omdSpinner.js +148 -0
  139. package/src/omdString.js +39 -0
  140. package/src/omdTable.js +369 -0
  141. package/src/omdTapeDiagram.js +245 -0
  142. package/src/omdTerm.js +92 -0
  143. package/src/omdTileEquation.js +349 -0
  144. package/src/omdVariable.js +51 -0
@@ -0,0 +1,1223 @@
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
+ }
1223
+ }