@teachinglab/omd 0.6.0 → 0.6.2

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 (198) hide show
  1. package/README.md +257 -251
  2. package/README.old.md +137 -137
  3. package/canvas/core/canvasConfig.js +202 -202
  4. package/canvas/drawing/segment.js +167 -167
  5. package/canvas/drawing/stroke.js +385 -385
  6. package/canvas/events/eventManager.js +444 -444
  7. package/canvas/events/pointerEventHandler.js +262 -262
  8. package/canvas/index.js +48 -48
  9. package/canvas/tools/PointerTool.js +71 -71
  10. package/canvas/tools/tool.js +222 -222
  11. package/canvas/utils/boundingBox.js +377 -377
  12. package/canvas/utils/mathUtils.js +258 -258
  13. package/docs/api/configuration-options.md +198 -198
  14. package/docs/api/eventManager.md +82 -82
  15. package/docs/api/focusFrameManager.md +144 -144
  16. package/docs/api/index.md +105 -105
  17. package/docs/api/main.md +62 -62
  18. package/docs/api/omdBinaryExpressionNode.md +86 -86
  19. package/docs/api/omdCanvas.md +83 -83
  20. package/docs/api/omdConfigManager.md +112 -112
  21. package/docs/api/omdConstantNode.md +52 -52
  22. package/docs/api/omdDisplay.md +87 -87
  23. package/docs/api/omdEquationNode.md +174 -174
  24. package/docs/api/omdEquationSequenceNode.md +258 -258
  25. package/docs/api/omdEquationStack.md +192 -192
  26. package/docs/api/omdFunctionNode.md +82 -82
  27. package/docs/api/omdGroupNode.md +78 -78
  28. package/docs/api/omdHelpers.md +87 -87
  29. package/docs/api/omdLeafNode.md +85 -85
  30. package/docs/api/omdNode.md +201 -201
  31. package/docs/api/omdOperationDisplayNode.md +117 -117
  32. package/docs/api/omdOperatorNode.md +91 -91
  33. package/docs/api/omdParenthesisNode.md +133 -133
  34. package/docs/api/omdPopup.md +191 -191
  35. package/docs/api/omdPowerNode.md +131 -131
  36. package/docs/api/omdRationalNode.md +144 -144
  37. package/docs/api/omdSequenceNode.md +128 -128
  38. package/docs/api/omdSimplification.md +78 -78
  39. package/docs/api/omdSqrtNode.md +144 -144
  40. package/docs/api/omdStepVisualizer.md +146 -146
  41. package/docs/api/omdStepVisualizerHighlighting.md +65 -65
  42. package/docs/api/omdStepVisualizerInteractiveSteps.md +108 -108
  43. package/docs/api/omdStepVisualizerLayout.md +70 -70
  44. package/docs/api/omdStepVisualizerNodeUtils.md +140 -140
  45. package/docs/api/omdStepVisualizerTextBoxes.md +76 -76
  46. package/docs/api/omdToolbar.md +130 -130
  47. package/docs/api/omdTranscriptionService.md +95 -95
  48. package/docs/api/omdTreeDiff.md +169 -169
  49. package/docs/api/omdUnaryExpressionNode.md +137 -137
  50. package/docs/api/omdUtilities.md +82 -82
  51. package/docs/api/omdVariableNode.md +123 -123
  52. package/docs/api/selectTool.md +74 -74
  53. package/docs/api/simplificationEngine.md +97 -97
  54. package/docs/api/simplificationRules.md +76 -76
  55. package/docs/api/simplificationUtils.md +64 -64
  56. package/docs/api/transcribe.md +43 -43
  57. package/docs/api-reference.md +85 -85
  58. package/docs/index.html +453 -453
  59. package/docs/index.md +38 -38
  60. package/docs/omd-objects.md +258 -258
  61. package/index.js +79 -79
  62. package/jsvg/index.js +3 -0
  63. package/jsvg/jsvg.js +898 -898
  64. package/jsvg/jsvgComponents.js +357 -358
  65. package/npm-docs/DOCUMENTATION_SUMMARY.md +220 -220
  66. package/npm-docs/README.md +251 -251
  67. package/npm-docs/api/api-reference.md +85 -85
  68. package/npm-docs/api/configuration-options.md +198 -198
  69. package/npm-docs/api/eventManager.md +82 -82
  70. package/npm-docs/api/expression-nodes.md +561 -561
  71. package/npm-docs/api/focusFrameManager.md +144 -144
  72. package/npm-docs/api/index.md +105 -105
  73. package/npm-docs/api/main.md +62 -62
  74. package/npm-docs/api/omdBinaryExpressionNode.md +86 -86
  75. package/npm-docs/api/omdCanvas.md +83 -83
  76. package/npm-docs/api/omdConfigManager.md +112 -112
  77. package/npm-docs/api/omdConstantNode.md +52 -52
  78. package/npm-docs/api/omdDisplay.md +87 -87
  79. package/npm-docs/api/omdEquationNode.md +174 -174
  80. package/npm-docs/api/omdEquationSequenceNode.md +258 -258
  81. package/npm-docs/api/omdEquationStack.md +192 -192
  82. package/npm-docs/api/omdFunctionNode.md +82 -82
  83. package/npm-docs/api/omdGroupNode.md +78 -78
  84. package/npm-docs/api/omdHelpers.md +87 -87
  85. package/npm-docs/api/omdLeafNode.md +85 -85
  86. package/npm-docs/api/omdNode.md +201 -201
  87. package/npm-docs/api/omdOperationDisplayNode.md +117 -117
  88. package/npm-docs/api/omdOperatorNode.md +91 -91
  89. package/npm-docs/api/omdParenthesisNode.md +133 -133
  90. package/npm-docs/api/omdPopup.md +191 -191
  91. package/npm-docs/api/omdPowerNode.md +131 -131
  92. package/npm-docs/api/omdRationalNode.md +144 -144
  93. package/npm-docs/api/omdSequenceNode.md +128 -128
  94. package/npm-docs/api/omdSimplification.md +78 -78
  95. package/npm-docs/api/omdSqrtNode.md +144 -144
  96. package/npm-docs/api/omdStepVisualizer.md +146 -146
  97. package/npm-docs/api/omdStepVisualizerHighlighting.md +65 -65
  98. package/npm-docs/api/omdStepVisualizerInteractiveSteps.md +108 -108
  99. package/npm-docs/api/omdStepVisualizerLayout.md +70 -70
  100. package/npm-docs/api/omdStepVisualizerNodeUtils.md +140 -140
  101. package/npm-docs/api/omdStepVisualizerTextBoxes.md +76 -76
  102. package/npm-docs/api/omdToolbar.md +130 -130
  103. package/npm-docs/api/omdTranscriptionService.md +95 -95
  104. package/npm-docs/api/omdTreeDiff.md +169 -169
  105. package/npm-docs/api/omdUnaryExpressionNode.md +137 -137
  106. package/npm-docs/api/omdUtilities.md +82 -82
  107. package/npm-docs/api/omdVariableNode.md +123 -123
  108. package/npm-docs/api/selectTool.md +74 -74
  109. package/npm-docs/api/simplificationEngine.md +97 -97
  110. package/npm-docs/api/simplificationRules.md +76 -76
  111. package/npm-docs/api/simplificationUtils.md +64 -64
  112. package/npm-docs/api/transcribe.md +43 -43
  113. package/npm-docs/guides/equations.md +854 -854
  114. package/npm-docs/guides/factory-functions.md +354 -354
  115. package/npm-docs/guides/getting-started.md +318 -318
  116. package/npm-docs/guides/quick-examples.md +525 -525
  117. package/npm-docs/guides/visualizations.md +682 -682
  118. package/npm-docs/index.html +12 -0
  119. package/npm-docs/json-schemas.md +826 -826
  120. package/omd/config/omdConfigManager.js +279 -267
  121. package/omd/core/index.js +158 -158
  122. package/omd/core/omdEquationStack.js +546 -546
  123. package/omd/core/omdUtilities.js +113 -113
  124. package/omd/display/omdDisplay.js +969 -962
  125. package/omd/display/omdToolbar.js +501 -501
  126. package/omd/nodes/omdBinaryExpressionNode.js +459 -459
  127. package/omd/nodes/omdConstantNode.js +141 -141
  128. package/omd/nodes/omdEquationNode.js +1327 -1327
  129. package/omd/nodes/omdFunctionNode.js +351 -351
  130. package/omd/nodes/omdGroupNode.js +67 -67
  131. package/omd/nodes/omdLeafNode.js +76 -76
  132. package/omd/nodes/omdNode.js +556 -556
  133. package/omd/nodes/omdOperationDisplayNode.js +321 -321
  134. package/omd/nodes/omdOperatorNode.js +108 -108
  135. package/omd/nodes/omdParenthesisNode.js +292 -292
  136. package/omd/nodes/omdPowerNode.js +235 -235
  137. package/omd/nodes/omdRationalNode.js +295 -295
  138. package/omd/nodes/omdSqrtNode.js +307 -307
  139. package/omd/nodes/omdUnaryExpressionNode.js +227 -227
  140. package/omd/nodes/omdVariableNode.js +122 -122
  141. package/omd/simplification/omdSimplification.js +140 -140
  142. package/omd/simplification/omdSimplificationEngine.js +887 -887
  143. package/omd/simplification/package.json +5 -5
  144. package/omd/simplification/rules/binaryRules.js +1037 -1037
  145. package/omd/simplification/rules/functionRules.js +111 -111
  146. package/omd/simplification/rules/index.js +48 -48
  147. package/omd/simplification/rules/parenthesisRules.js +19 -19
  148. package/omd/simplification/rules/powerRules.js +143 -143
  149. package/omd/simplification/rules/rationalRules.js +725 -725
  150. package/omd/simplification/rules/sqrtRules.js +48 -48
  151. package/omd/simplification/rules/unaryRules.js +37 -37
  152. package/omd/simplification/simplificationRules.js +31 -31
  153. package/omd/simplification/simplificationUtils.js +1055 -1055
  154. package/omd/step-visualizer/omdStepVisualizer.js +947 -947
  155. package/omd/step-visualizer/omdStepVisualizerHighlighting.js +246 -246
  156. package/omd/step-visualizer/omdStepVisualizerLayout.js +892 -892
  157. package/omd/step-visualizer/omdStepVisualizerTextBoxes.js +200 -200
  158. package/omd/utils/aiNextEquationStep.js +106 -106
  159. package/omd/utils/omdNodeOverlay.js +638 -638
  160. package/omd/utils/omdPopup.js +1203 -1203
  161. package/omd/utils/omdStepVisualizerInteractiveSteps.js +684 -684
  162. package/omd/utils/omdStepVisualizerNodeUtils.js +267 -267
  163. package/omd/utils/omdTranscriptionService.js +123 -123
  164. package/omd/utils/omdTreeDiff.js +733 -733
  165. package/package.json +59 -56
  166. package/readme.html +184 -120
  167. package/src/index.js +74 -74
  168. package/src/json-schemas.md +576 -576
  169. package/src/omd-json-samples.js +147 -147
  170. package/src/omdApp.js +391 -391
  171. package/src/omdAppCanvas.js +335 -335
  172. package/src/omdBalanceHanger.js +199 -199
  173. package/src/omdColor.js +13 -13
  174. package/src/omdCoordinatePlane.js +541 -541
  175. package/src/omdExpression.js +115 -115
  176. package/src/omdFactory.js +150 -150
  177. package/src/omdFunction.js +114 -114
  178. package/src/omdMetaExpression.js +290 -290
  179. package/src/omdNaturalExpression.js +563 -563
  180. package/src/omdNode.js +383 -383
  181. package/src/omdNumber.js +52 -52
  182. package/src/omdNumberLine.js +114 -112
  183. package/src/omdNumberTile.js +118 -118
  184. package/src/omdOperator.js +72 -72
  185. package/src/omdPowerExpression.js +91 -91
  186. package/src/omdProblem.js +259 -259
  187. package/src/omdRatioChart.js +251 -251
  188. package/src/omdRationalExpression.js +114 -114
  189. package/src/omdSampleData.js +215 -215
  190. package/src/omdShapes.js +512 -512
  191. package/src/omdSpinner.js +151 -151
  192. package/src/omdString.js +49 -49
  193. package/src/omdTable.js +498 -498
  194. package/src/omdTapeDiagram.js +244 -244
  195. package/src/omdTerm.js +91 -91
  196. package/src/omdTileEquation.js +349 -349
  197. package/src/omdUtils.js +84 -84
  198. package/src/omdVariable.js +51 -51
@@ -1,1328 +1,1328 @@
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(mergedOptions);
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
- // Background customization options
1093
- backgroundColor: (options.backgroundColor !== undefined) ? options.backgroundColor : undefined,
1094
- backgroundCornerRadius: (options.backgroundCornerRadius !== undefined) ? options.backgroundCornerRadius : undefined,
1095
- backgroundOpacity: (options.backgroundOpacity !== undefined) ? options.backgroundOpacity : undefined,
1096
- showBackground: (options.showBackground !== undefined) ? options.showBackground : undefined,
1097
- graphEquations,
1098
- lineSegments: [],
1099
- dotValues: [],
1100
- shapeSet: []
1101
- };
1102
- }
1103
-
1104
- /**
1105
- * Generates JSON configuration for table visualization
1106
- * @param {Object} options - Configuration options
1107
- * @returns {Object} JSON configuration for omdTable
1108
- * @private
1109
- */
1110
- _renderToTable(options) {
1111
- // Single side: let omdTable generate rows from equation
1112
- if (options.side === 'left') {
1113
- const expr = this._normalizeExpressionString(this.getLeft().toString());
1114
- return {
1115
- omdType: "table",
1116
- title: `Function Table: y = ${expr}`,
1117
- headers: ["x", "y"],
1118
- equation: `y = ${expr}`,
1119
- xMin: options.xMin,
1120
- xMax: options.xMax,
1121
- stepSize: options.stepSize,
1122
- // Background customization options
1123
- backgroundColor: (options.backgroundColor !== undefined) ? options.backgroundColor : undefined,
1124
- backgroundCornerRadius: (options.backgroundCornerRadius !== undefined) ? options.backgroundCornerRadius : undefined,
1125
- backgroundOpacity: (options.backgroundOpacity !== undefined) ? options.backgroundOpacity : undefined,
1126
- showBackground: (options.showBackground !== undefined) ? options.showBackground : undefined,
1127
- // Alternating row color options
1128
- alternatingRowColors: (options.alternatingRowColors !== undefined) ? options.alternatingRowColors : undefined,
1129
- evenRowColor: (options.evenRowColor !== undefined) ? options.evenRowColor : undefined,
1130
- oddRowColor: (options.oddRowColor !== undefined) ? options.oddRowColor : undefined,
1131
- alternatingRowOpacity: (options.alternatingRowOpacity !== undefined) ? options.alternatingRowOpacity : undefined
1132
- };
1133
- } else if (options.side === 'right') {
1134
- const expr = this._normalizeExpressionString(this.getRight().toString());
1135
- return {
1136
- omdType: "table",
1137
- title: `Function Table: y = ${expr}`,
1138
- headers: ["x", "y"],
1139
- equation: `y = ${expr}`,
1140
- xMin: options.xMin,
1141
- xMax: options.xMax,
1142
- stepSize: options.stepSize,
1143
- // Background customization options
1144
- backgroundColor: (options.backgroundColor !== undefined) ? options.backgroundColor : undefined,
1145
- backgroundCornerRadius: (options.backgroundCornerRadius !== undefined) ? options.backgroundCornerRadius : undefined,
1146
- backgroundOpacity: (options.backgroundOpacity !== undefined) ? options.backgroundOpacity : undefined,
1147
- showBackground: (options.showBackground !== undefined) ? options.showBackground : undefined,
1148
- // Alternating row color options
1149
- alternatingRowColors: (options.alternatingRowColors !== undefined) ? options.alternatingRowColors : undefined,
1150
- evenRowColor: (options.evenRowColor !== undefined) ? options.evenRowColor : undefined,
1151
- oddRowColor: (options.oddRowColor !== undefined) ? options.oddRowColor : undefined,
1152
- alternatingRowOpacity: (options.alternatingRowOpacity !== undefined) ? options.alternatingRowOpacity : undefined
1153
- };
1154
- }
1155
-
1156
- // Both sides: compute data for x, left(x), right(x)
1157
- const leftSide = this.getLeft();
1158
- const rightSide = this.getRight();
1159
- const leftLabel = leftSide.toString();
1160
- const rightLabel = rightSide.toString();
1161
-
1162
- const headers = ["x", leftLabel, rightLabel];
1163
- const data = [];
1164
- const start = options.xMin;
1165
- const end = options.xMax;
1166
- const step = options.stepSize || 1;
1167
- for (let x = start; x <= end; x += step) {
1168
- try {
1169
- const l = leftSide.evaluate({ x });
1170
- const r = rightSide.evaluate({ x });
1171
- if (isFinite(l) && isFinite(r)) {
1172
- data.push([x, Number(l), Number(r)]);
1173
- }
1174
- } catch (_) {
1175
- // Skip points that fail to evaluate
1176
- }
1177
- }
1178
-
1179
- return {
1180
- omdType: "table",
1181
- title: `Equation Table: ${this.toString()}`,
1182
- headers,
1183
- data,
1184
- // Background customization options
1185
- backgroundColor: (options.backgroundColor !== undefined) ? options.backgroundColor : undefined,
1186
- backgroundCornerRadius: (options.backgroundCornerRadius !== undefined) ? options.backgroundCornerRadius : undefined,
1187
- backgroundOpacity: (options.backgroundOpacity !== undefined) ? options.backgroundOpacity : undefined,
1188
- showBackground: (options.showBackground !== undefined) ? options.showBackground : undefined,
1189
- // Alternating row color options
1190
- alternatingRowColors: (options.alternatingRowColors !== undefined) ? options.alternatingRowColors : undefined,
1191
- evenRowColor: (options.evenRowColor !== undefined) ? options.evenRowColor : undefined,
1192
- oddRowColor: (options.oddRowColor !== undefined) ? options.oddRowColor : undefined,
1193
- alternatingRowOpacity: (options.alternatingRowOpacity !== undefined) ? options.alternatingRowOpacity : undefined
1194
- };
1195
- }
1196
-
1197
- /**
1198
- * Generates table for a single side of the equation
1199
- * @param {omdNode} side - The side to render
1200
- * @param {string} title - Title for the table
1201
- * @returns {Object} JSON configuration for omdTable
1202
- * @private
1203
- */
1204
- _renderSingleSideTable(side, title, options = {}) {
1205
- const expression = this._normalizeExpressionString(side.toString());
1206
- return {
1207
- omdType: "table",
1208
- title: `${title}: ${expression}`,
1209
- headers: ["x", "y"],
1210
- equation: `y = ${expression}`,
1211
- xMin: options.xMin ?? -5,
1212
- xMax: options.xMax ?? 5,
1213
- stepSize: options.stepSize ?? 1
1214
- };
1215
- }
1216
-
1217
- /**
1218
- * Generates JSON configuration for balance hanger visualization
1219
- * @returns {Object} JSON configuration for omdBalanceHanger
1220
- * @private
1221
- */
1222
- _renderToHanger(options = {}) {
1223
- // Convert equation sides to hanger representation
1224
- const leftValues = this._convertToHangerValues(this.getLeft());
1225
- const rightValues = this._convertToHangerValues(this.getRight());
1226
-
1227
- return {
1228
- omdType: "balanceHanger",
1229
- leftValues: leftValues,
1230
- rightValues: rightValues,
1231
- tilt: "none", // Equations should be balanced by definition
1232
- // Background customization options
1233
- backgroundColor: (options.backgroundColor !== undefined) ? options.backgroundColor : undefined,
1234
- backgroundCornerRadius: (options.backgroundCornerRadius !== undefined) ? options.backgroundCornerRadius : undefined,
1235
- backgroundOpacity: (options.backgroundOpacity !== undefined) ? options.backgroundOpacity : undefined,
1236
- showBackground: (options.showBackground !== undefined) ? options.showBackground : undefined
1237
- };
1238
- }
1239
-
1240
- /**
1241
- * Normalizes an expression string for evaluation/graphing
1242
- * - Inserts '*' between number-variable and variable-number
1243
- * @param {string} expr
1244
- * @returns {string}
1245
- * @private
1246
- */
1247
- _normalizeExpressionString(expr) {
1248
- if (!expr || typeof expr !== 'string') return String(expr || '');
1249
- return expr
1250
- .replace(/(\d)([a-zA-Z])/g, '$1*$2')
1251
- .replace(/([a-zA-Z])(\d)/g, '$1*$2');
1252
- }
1253
-
1254
- /**
1255
- * Converts an equation side to balance hanger values (simple array of values)
1256
- * @param {omdNode} node - The node to convert
1257
- * @returns {Array} Array of simple values for the hanger
1258
- * @private
1259
- */
1260
- _convertToHangerValues(node) {
1261
- const values = [];
1262
-
1263
- // Handle different node types
1264
- if (node.type === 'omdConstantNode') {
1265
- // Add the constant value
1266
- const value = node.getValue();
1267
- if (value !== 0) {
1268
- values.push(value);
1269
- }
1270
- } else if (node.type === 'omdVariableNode') {
1271
- // Add variable name
1272
- values.push(node.name || "x");
1273
- } else if (node.type === 'omdBinaryExpressionNode') {
1274
- // Handle binary expressions by recursively processing operands
1275
- const leftValues = this._convertToHangerValues(node.left);
1276
- const rightValues = this._convertToHangerValues(node.right);
1277
-
1278
- // For addition, combine values
1279
- if (node.operation === 'add' || node.operation === 'plus') {
1280
- values.push(...leftValues, ...rightValues);
1281
- }
1282
- // For subtraction, add left values and negate right values
1283
- else if (node.operation === 'subtract' || node.operation === 'minus') {
1284
- values.push(...leftValues);
1285
- // For subtraction, we need to represent negative values
1286
- for (const rightValue of rightValues) {
1287
- if (typeof rightValue === 'number') {
1288
- // Negate numeric values
1289
- values.push(-rightValue);
1290
- } else {
1291
- // For variables/expressions, prepend with negative sign
1292
- values.push(`-${rightValue}`);
1293
- }
1294
- }
1295
- }
1296
- // For multiplication, handle special cases
1297
- else if (node.operation === 'multiply') {
1298
- // Check if one operand is a constant (coefficient)
1299
- if (node.left.type === 'omdConstantNode' && node.right.type === 'omdVariableNode') {
1300
- const coefficient = Math.abs(node.left.getValue());
1301
- const varName = node.right.name || "x";
1302
- // Add multiple instances of the variable
1303
- for (let i = 0; i < coefficient; i++) {
1304
- values.push(varName);
1305
- }
1306
- } else if (node.right.type === 'omdConstantNode' && node.left.type === 'omdVariableNode') {
1307
- const coefficient = Math.abs(node.right.getValue());
1308
- const varName = node.left.name || "x";
1309
- // Add multiple instances of the variable
1310
- for (let i = 0; i < coefficient; i++) {
1311
- values.push(varName);
1312
- }
1313
- } else {
1314
- // For other multiplications, treat as a single expression
1315
- values.push(node.toString());
1316
- }
1317
- } else {
1318
- // For other operations, treat as a single expression
1319
- values.push(node.toString());
1320
- }
1321
- } else {
1322
- // For any other node types, treat as expression string
1323
- values.push(node.toString());
1324
- }
1325
-
1326
- return values;
1327
- }
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(mergedOptions);
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
+ // Background customization options
1093
+ backgroundColor: (options.backgroundColor !== undefined) ? options.backgroundColor : undefined,
1094
+ backgroundCornerRadius: (options.backgroundCornerRadius !== undefined) ? options.backgroundCornerRadius : undefined,
1095
+ backgroundOpacity: (options.backgroundOpacity !== undefined) ? options.backgroundOpacity : undefined,
1096
+ showBackground: (options.showBackground !== undefined) ? options.showBackground : undefined,
1097
+ graphEquations,
1098
+ lineSegments: [],
1099
+ dotValues: [],
1100
+ shapeSet: []
1101
+ };
1102
+ }
1103
+
1104
+ /**
1105
+ * Generates JSON configuration for table visualization
1106
+ * @param {Object} options - Configuration options
1107
+ * @returns {Object} JSON configuration for omdTable
1108
+ * @private
1109
+ */
1110
+ _renderToTable(options) {
1111
+ // Single side: let omdTable generate rows from equation
1112
+ if (options.side === 'left') {
1113
+ const expr = this._normalizeExpressionString(this.getLeft().toString());
1114
+ return {
1115
+ omdType: "table",
1116
+ title: `Function Table: y = ${expr}`,
1117
+ headers: ["x", "y"],
1118
+ equation: `y = ${expr}`,
1119
+ xMin: options.xMin,
1120
+ xMax: options.xMax,
1121
+ stepSize: options.stepSize,
1122
+ // Background customization options
1123
+ backgroundColor: (options.backgroundColor !== undefined) ? options.backgroundColor : undefined,
1124
+ backgroundCornerRadius: (options.backgroundCornerRadius !== undefined) ? options.backgroundCornerRadius : undefined,
1125
+ backgroundOpacity: (options.backgroundOpacity !== undefined) ? options.backgroundOpacity : undefined,
1126
+ showBackground: (options.showBackground !== undefined) ? options.showBackground : undefined,
1127
+ // Alternating row color options
1128
+ alternatingRowColors: (options.alternatingRowColors !== undefined) ? options.alternatingRowColors : undefined,
1129
+ evenRowColor: (options.evenRowColor !== undefined) ? options.evenRowColor : undefined,
1130
+ oddRowColor: (options.oddRowColor !== undefined) ? options.oddRowColor : undefined,
1131
+ alternatingRowOpacity: (options.alternatingRowOpacity !== undefined) ? options.alternatingRowOpacity : undefined
1132
+ };
1133
+ } else if (options.side === 'right') {
1134
+ const expr = this._normalizeExpressionString(this.getRight().toString());
1135
+ return {
1136
+ omdType: "table",
1137
+ title: `Function Table: y = ${expr}`,
1138
+ headers: ["x", "y"],
1139
+ equation: `y = ${expr}`,
1140
+ xMin: options.xMin,
1141
+ xMax: options.xMax,
1142
+ stepSize: options.stepSize,
1143
+ // Background customization options
1144
+ backgroundColor: (options.backgroundColor !== undefined) ? options.backgroundColor : undefined,
1145
+ backgroundCornerRadius: (options.backgroundCornerRadius !== undefined) ? options.backgroundCornerRadius : undefined,
1146
+ backgroundOpacity: (options.backgroundOpacity !== undefined) ? options.backgroundOpacity : undefined,
1147
+ showBackground: (options.showBackground !== undefined) ? options.showBackground : undefined,
1148
+ // Alternating row color options
1149
+ alternatingRowColors: (options.alternatingRowColors !== undefined) ? options.alternatingRowColors : undefined,
1150
+ evenRowColor: (options.evenRowColor !== undefined) ? options.evenRowColor : undefined,
1151
+ oddRowColor: (options.oddRowColor !== undefined) ? options.oddRowColor : undefined,
1152
+ alternatingRowOpacity: (options.alternatingRowOpacity !== undefined) ? options.alternatingRowOpacity : undefined
1153
+ };
1154
+ }
1155
+
1156
+ // Both sides: compute data for x, left(x), right(x)
1157
+ const leftSide = this.getLeft();
1158
+ const rightSide = this.getRight();
1159
+ const leftLabel = leftSide.toString();
1160
+ const rightLabel = rightSide.toString();
1161
+
1162
+ const headers = ["x", leftLabel, rightLabel];
1163
+ const data = [];
1164
+ const start = options.xMin;
1165
+ const end = options.xMax;
1166
+ const step = options.stepSize || 1;
1167
+ for (let x = start; x <= end; x += step) {
1168
+ try {
1169
+ const l = leftSide.evaluate({ x });
1170
+ const r = rightSide.evaluate({ x });
1171
+ if (isFinite(l) && isFinite(r)) {
1172
+ data.push([x, Number(l), Number(r)]);
1173
+ }
1174
+ } catch (_) {
1175
+ // Skip points that fail to evaluate
1176
+ }
1177
+ }
1178
+
1179
+ return {
1180
+ omdType: "table",
1181
+ title: `Equation Table: ${this.toString()}`,
1182
+ headers,
1183
+ data,
1184
+ // Background customization options
1185
+ backgroundColor: (options.backgroundColor !== undefined) ? options.backgroundColor : undefined,
1186
+ backgroundCornerRadius: (options.backgroundCornerRadius !== undefined) ? options.backgroundCornerRadius : undefined,
1187
+ backgroundOpacity: (options.backgroundOpacity !== undefined) ? options.backgroundOpacity : undefined,
1188
+ showBackground: (options.showBackground !== undefined) ? options.showBackground : undefined,
1189
+ // Alternating row color options
1190
+ alternatingRowColors: (options.alternatingRowColors !== undefined) ? options.alternatingRowColors : undefined,
1191
+ evenRowColor: (options.evenRowColor !== undefined) ? options.evenRowColor : undefined,
1192
+ oddRowColor: (options.oddRowColor !== undefined) ? options.oddRowColor : undefined,
1193
+ alternatingRowOpacity: (options.alternatingRowOpacity !== undefined) ? options.alternatingRowOpacity : undefined
1194
+ };
1195
+ }
1196
+
1197
+ /**
1198
+ * Generates table for a single side of the equation
1199
+ * @param {omdNode} side - The side to render
1200
+ * @param {string} title - Title for the table
1201
+ * @returns {Object} JSON configuration for omdTable
1202
+ * @private
1203
+ */
1204
+ _renderSingleSideTable(side, title, options = {}) {
1205
+ const expression = this._normalizeExpressionString(side.toString());
1206
+ return {
1207
+ omdType: "table",
1208
+ title: `${title}: ${expression}`,
1209
+ headers: ["x", "y"],
1210
+ equation: `y = ${expression}`,
1211
+ xMin: options.xMin ?? -5,
1212
+ xMax: options.xMax ?? 5,
1213
+ stepSize: options.stepSize ?? 1
1214
+ };
1215
+ }
1216
+
1217
+ /**
1218
+ * Generates JSON configuration for balance hanger visualization
1219
+ * @returns {Object} JSON configuration for omdBalanceHanger
1220
+ * @private
1221
+ */
1222
+ _renderToHanger(options = {}) {
1223
+ // Convert equation sides to hanger representation
1224
+ const leftValues = this._convertToHangerValues(this.getLeft());
1225
+ const rightValues = this._convertToHangerValues(this.getRight());
1226
+
1227
+ return {
1228
+ omdType: "balanceHanger",
1229
+ leftValues: leftValues,
1230
+ rightValues: rightValues,
1231
+ tilt: "none", // Equations should be balanced by definition
1232
+ // Background customization options
1233
+ backgroundColor: (options.backgroundColor !== undefined) ? options.backgroundColor : undefined,
1234
+ backgroundCornerRadius: (options.backgroundCornerRadius !== undefined) ? options.backgroundCornerRadius : undefined,
1235
+ backgroundOpacity: (options.backgroundOpacity !== undefined) ? options.backgroundOpacity : undefined,
1236
+ showBackground: (options.showBackground !== undefined) ? options.showBackground : undefined
1237
+ };
1238
+ }
1239
+
1240
+ /**
1241
+ * Normalizes an expression string for evaluation/graphing
1242
+ * - Inserts '*' between number-variable and variable-number
1243
+ * @param {string} expr
1244
+ * @returns {string}
1245
+ * @private
1246
+ */
1247
+ _normalizeExpressionString(expr) {
1248
+ if (!expr || typeof expr !== 'string') return String(expr || '');
1249
+ return expr
1250
+ .replace(/(\d)([a-zA-Z])/g, '$1*$2')
1251
+ .replace(/([a-zA-Z])(\d)/g, '$1*$2');
1252
+ }
1253
+
1254
+ /**
1255
+ * Converts an equation side to balance hanger values (simple array of values)
1256
+ * @param {omdNode} node - The node to convert
1257
+ * @returns {Array} Array of simple values for the hanger
1258
+ * @private
1259
+ */
1260
+ _convertToHangerValues(node) {
1261
+ const values = [];
1262
+
1263
+ // Handle different node types
1264
+ if (node.type === 'omdConstantNode') {
1265
+ // Add the constant value
1266
+ const value = node.getValue();
1267
+ if (value !== 0) {
1268
+ values.push(value);
1269
+ }
1270
+ } else if (node.type === 'omdVariableNode') {
1271
+ // Add variable name
1272
+ values.push(node.name || "x");
1273
+ } else if (node.type === 'omdBinaryExpressionNode') {
1274
+ // Handle binary expressions by recursively processing operands
1275
+ const leftValues = this._convertToHangerValues(node.left);
1276
+ const rightValues = this._convertToHangerValues(node.right);
1277
+
1278
+ // For addition, combine values
1279
+ if (node.operation === 'add' || node.operation === 'plus') {
1280
+ values.push(...leftValues, ...rightValues);
1281
+ }
1282
+ // For subtraction, add left values and negate right values
1283
+ else if (node.operation === 'subtract' || node.operation === 'minus') {
1284
+ values.push(...leftValues);
1285
+ // For subtraction, we need to represent negative values
1286
+ for (const rightValue of rightValues) {
1287
+ if (typeof rightValue === 'number') {
1288
+ // Negate numeric values
1289
+ values.push(-rightValue);
1290
+ } else {
1291
+ // For variables/expressions, prepend with negative sign
1292
+ values.push(`-${rightValue}`);
1293
+ }
1294
+ }
1295
+ }
1296
+ // For multiplication, handle special cases
1297
+ else if (node.operation === 'multiply') {
1298
+ // Check if one operand is a constant (coefficient)
1299
+ if (node.left.type === 'omdConstantNode' && node.right.type === 'omdVariableNode') {
1300
+ const coefficient = Math.abs(node.left.getValue());
1301
+ const varName = node.right.name || "x";
1302
+ // Add multiple instances of the variable
1303
+ for (let i = 0; i < coefficient; i++) {
1304
+ values.push(varName);
1305
+ }
1306
+ } else if (node.right.type === 'omdConstantNode' && node.left.type === 'omdVariableNode') {
1307
+ const coefficient = Math.abs(node.right.getValue());
1308
+ const varName = node.left.name || "x";
1309
+ // Add multiple instances of the variable
1310
+ for (let i = 0; i < coefficient; i++) {
1311
+ values.push(varName);
1312
+ }
1313
+ } else {
1314
+ // For other multiplications, treat as a single expression
1315
+ values.push(node.toString());
1316
+ }
1317
+ } else {
1318
+ // For other operations, treat as a single expression
1319
+ values.push(node.toString());
1320
+ }
1321
+ } else {
1322
+ // For any other node types, treat as expression string
1323
+ values.push(node.toString());
1324
+ }
1325
+
1326
+ return values;
1327
+ }
1328
1328
  }