@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.
- package/README.md +257 -251
- package/README.old.md +137 -137
- package/canvas/core/canvasConfig.js +202 -202
- package/canvas/drawing/segment.js +167 -167
- package/canvas/drawing/stroke.js +385 -385
- package/canvas/events/eventManager.js +444 -444
- package/canvas/events/pointerEventHandler.js +262 -262
- package/canvas/index.js +48 -48
- package/canvas/tools/PointerTool.js +71 -71
- package/canvas/tools/tool.js +222 -222
- package/canvas/utils/boundingBox.js +377 -377
- package/canvas/utils/mathUtils.js +258 -258
- package/docs/api/configuration-options.md +198 -198
- package/docs/api/eventManager.md +82 -82
- package/docs/api/focusFrameManager.md +144 -144
- package/docs/api/index.md +105 -105
- package/docs/api/main.md +62 -62
- package/docs/api/omdBinaryExpressionNode.md +86 -86
- package/docs/api/omdCanvas.md +83 -83
- package/docs/api/omdConfigManager.md +112 -112
- package/docs/api/omdConstantNode.md +52 -52
- package/docs/api/omdDisplay.md +87 -87
- package/docs/api/omdEquationNode.md +174 -174
- package/docs/api/omdEquationSequenceNode.md +258 -258
- package/docs/api/omdEquationStack.md +192 -192
- package/docs/api/omdFunctionNode.md +82 -82
- package/docs/api/omdGroupNode.md +78 -78
- package/docs/api/omdHelpers.md +87 -87
- package/docs/api/omdLeafNode.md +85 -85
- package/docs/api/omdNode.md +201 -201
- package/docs/api/omdOperationDisplayNode.md +117 -117
- package/docs/api/omdOperatorNode.md +91 -91
- package/docs/api/omdParenthesisNode.md +133 -133
- package/docs/api/omdPopup.md +191 -191
- package/docs/api/omdPowerNode.md +131 -131
- package/docs/api/omdRationalNode.md +144 -144
- package/docs/api/omdSequenceNode.md +128 -128
- package/docs/api/omdSimplification.md +78 -78
- package/docs/api/omdSqrtNode.md +144 -144
- package/docs/api/omdStepVisualizer.md +146 -146
- package/docs/api/omdStepVisualizerHighlighting.md +65 -65
- package/docs/api/omdStepVisualizerInteractiveSteps.md +108 -108
- package/docs/api/omdStepVisualizerLayout.md +70 -70
- package/docs/api/omdStepVisualizerNodeUtils.md +140 -140
- package/docs/api/omdStepVisualizerTextBoxes.md +76 -76
- package/docs/api/omdToolbar.md +130 -130
- package/docs/api/omdTranscriptionService.md +95 -95
- package/docs/api/omdTreeDiff.md +169 -169
- package/docs/api/omdUnaryExpressionNode.md +137 -137
- package/docs/api/omdUtilities.md +82 -82
- package/docs/api/omdVariableNode.md +123 -123
- package/docs/api/selectTool.md +74 -74
- package/docs/api/simplificationEngine.md +97 -97
- package/docs/api/simplificationRules.md +76 -76
- package/docs/api/simplificationUtils.md +64 -64
- package/docs/api/transcribe.md +43 -43
- package/docs/api-reference.md +85 -85
- package/docs/index.html +453 -453
- package/docs/index.md +38 -38
- package/docs/omd-objects.md +258 -258
- package/index.js +79 -79
- package/jsvg/index.js +3 -0
- package/jsvg/jsvg.js +898 -898
- package/jsvg/jsvgComponents.js +357 -358
- package/npm-docs/DOCUMENTATION_SUMMARY.md +220 -220
- package/npm-docs/README.md +251 -251
- package/npm-docs/api/api-reference.md +85 -85
- package/npm-docs/api/configuration-options.md +198 -198
- package/npm-docs/api/eventManager.md +82 -82
- package/npm-docs/api/expression-nodes.md +561 -561
- package/npm-docs/api/focusFrameManager.md +144 -144
- package/npm-docs/api/index.md +105 -105
- package/npm-docs/api/main.md +62 -62
- package/npm-docs/api/omdBinaryExpressionNode.md +86 -86
- package/npm-docs/api/omdCanvas.md +83 -83
- package/npm-docs/api/omdConfigManager.md +112 -112
- package/npm-docs/api/omdConstantNode.md +52 -52
- package/npm-docs/api/omdDisplay.md +87 -87
- package/npm-docs/api/omdEquationNode.md +174 -174
- package/npm-docs/api/omdEquationSequenceNode.md +258 -258
- package/npm-docs/api/omdEquationStack.md +192 -192
- package/npm-docs/api/omdFunctionNode.md +82 -82
- package/npm-docs/api/omdGroupNode.md +78 -78
- package/npm-docs/api/omdHelpers.md +87 -87
- package/npm-docs/api/omdLeafNode.md +85 -85
- package/npm-docs/api/omdNode.md +201 -201
- package/npm-docs/api/omdOperationDisplayNode.md +117 -117
- package/npm-docs/api/omdOperatorNode.md +91 -91
- package/npm-docs/api/omdParenthesisNode.md +133 -133
- package/npm-docs/api/omdPopup.md +191 -191
- package/npm-docs/api/omdPowerNode.md +131 -131
- package/npm-docs/api/omdRationalNode.md +144 -144
- package/npm-docs/api/omdSequenceNode.md +128 -128
- package/npm-docs/api/omdSimplification.md +78 -78
- package/npm-docs/api/omdSqrtNode.md +144 -144
- package/npm-docs/api/omdStepVisualizer.md +146 -146
- package/npm-docs/api/omdStepVisualizerHighlighting.md +65 -65
- package/npm-docs/api/omdStepVisualizerInteractiveSteps.md +108 -108
- package/npm-docs/api/omdStepVisualizerLayout.md +70 -70
- package/npm-docs/api/omdStepVisualizerNodeUtils.md +140 -140
- package/npm-docs/api/omdStepVisualizerTextBoxes.md +76 -76
- package/npm-docs/api/omdToolbar.md +130 -130
- package/npm-docs/api/omdTranscriptionService.md +95 -95
- package/npm-docs/api/omdTreeDiff.md +169 -169
- package/npm-docs/api/omdUnaryExpressionNode.md +137 -137
- package/npm-docs/api/omdUtilities.md +82 -82
- package/npm-docs/api/omdVariableNode.md +123 -123
- package/npm-docs/api/selectTool.md +74 -74
- package/npm-docs/api/simplificationEngine.md +97 -97
- package/npm-docs/api/simplificationRules.md +76 -76
- package/npm-docs/api/simplificationUtils.md +64 -64
- package/npm-docs/api/transcribe.md +43 -43
- package/npm-docs/guides/equations.md +854 -854
- package/npm-docs/guides/factory-functions.md +354 -354
- package/npm-docs/guides/getting-started.md +318 -318
- package/npm-docs/guides/quick-examples.md +525 -525
- package/npm-docs/guides/visualizations.md +682 -682
- package/npm-docs/index.html +12 -0
- package/npm-docs/json-schemas.md +826 -826
- package/omd/config/omdConfigManager.js +279 -267
- package/omd/core/index.js +158 -158
- package/omd/core/omdEquationStack.js +546 -546
- package/omd/core/omdUtilities.js +113 -113
- package/omd/display/omdDisplay.js +969 -962
- package/omd/display/omdToolbar.js +501 -501
- package/omd/nodes/omdBinaryExpressionNode.js +459 -459
- package/omd/nodes/omdConstantNode.js +141 -141
- package/omd/nodes/omdEquationNode.js +1327 -1327
- package/omd/nodes/omdFunctionNode.js +351 -351
- package/omd/nodes/omdGroupNode.js +67 -67
- package/omd/nodes/omdLeafNode.js +76 -76
- package/omd/nodes/omdNode.js +556 -556
- package/omd/nodes/omdOperationDisplayNode.js +321 -321
- package/omd/nodes/omdOperatorNode.js +108 -108
- package/omd/nodes/omdParenthesisNode.js +292 -292
- package/omd/nodes/omdPowerNode.js +235 -235
- package/omd/nodes/omdRationalNode.js +295 -295
- package/omd/nodes/omdSqrtNode.js +307 -307
- package/omd/nodes/omdUnaryExpressionNode.js +227 -227
- package/omd/nodes/omdVariableNode.js +122 -122
- package/omd/simplification/omdSimplification.js +140 -140
- package/omd/simplification/omdSimplificationEngine.js +887 -887
- package/omd/simplification/package.json +5 -5
- package/omd/simplification/rules/binaryRules.js +1037 -1037
- package/omd/simplification/rules/functionRules.js +111 -111
- package/omd/simplification/rules/index.js +48 -48
- package/omd/simplification/rules/parenthesisRules.js +19 -19
- package/omd/simplification/rules/powerRules.js +143 -143
- package/omd/simplification/rules/rationalRules.js +725 -725
- package/omd/simplification/rules/sqrtRules.js +48 -48
- package/omd/simplification/rules/unaryRules.js +37 -37
- package/omd/simplification/simplificationRules.js +31 -31
- package/omd/simplification/simplificationUtils.js +1055 -1055
- package/omd/step-visualizer/omdStepVisualizer.js +947 -947
- package/omd/step-visualizer/omdStepVisualizerHighlighting.js +246 -246
- package/omd/step-visualizer/omdStepVisualizerLayout.js +892 -892
- package/omd/step-visualizer/omdStepVisualizerTextBoxes.js +200 -200
- package/omd/utils/aiNextEquationStep.js +106 -106
- package/omd/utils/omdNodeOverlay.js +638 -638
- package/omd/utils/omdPopup.js +1203 -1203
- package/omd/utils/omdStepVisualizerInteractiveSteps.js +684 -684
- package/omd/utils/omdStepVisualizerNodeUtils.js +267 -267
- package/omd/utils/omdTranscriptionService.js +123 -123
- package/omd/utils/omdTreeDiff.js +733 -733
- package/package.json +59 -56
- package/readme.html +184 -120
- package/src/index.js +74 -74
- package/src/json-schemas.md +576 -576
- package/src/omd-json-samples.js +147 -147
- package/src/omdApp.js +391 -391
- package/src/omdAppCanvas.js +335 -335
- package/src/omdBalanceHanger.js +199 -199
- package/src/omdColor.js +13 -13
- package/src/omdCoordinatePlane.js +541 -541
- package/src/omdExpression.js +115 -115
- package/src/omdFactory.js +150 -150
- package/src/omdFunction.js +114 -114
- package/src/omdMetaExpression.js +290 -290
- package/src/omdNaturalExpression.js +563 -563
- package/src/omdNode.js +383 -383
- package/src/omdNumber.js +52 -52
- package/src/omdNumberLine.js +114 -112
- package/src/omdNumberTile.js +118 -118
- package/src/omdOperator.js +72 -72
- package/src/omdPowerExpression.js +91 -91
- package/src/omdProblem.js +259 -259
- package/src/omdRatioChart.js +251 -251
- package/src/omdRationalExpression.js +114 -114
- package/src/omdSampleData.js +215 -215
- package/src/omdShapes.js +512 -512
- package/src/omdSpinner.js +151 -151
- package/src/omdString.js +49 -49
- package/src/omdTable.js +498 -498
- package/src/omdTapeDiagram.js +244 -244
- package/src/omdTerm.js +91 -91
- package/src/omdTileEquation.js +349 -349
- package/src/omdUtils.js +84 -84
- 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
|
}
|