@teachinglab/omd 0.2.4 → 0.2.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/api/omdToolbar.md +130 -130
- package/omd/core/omdEquationStack.js +521 -521
- package/omd/nodes/omdBinaryExpressionNode.js +459 -459
- package/omd/nodes/omdEquationNode.js +1273 -1222
- package/omd/nodes/omdEquationSequenceNode.js +1246 -1246
- package/omd/nodes/omdFunctionNode.js +351 -351
- package/omd/nodes/omdNode.js +556 -556
- package/omd/nodes/omdSqrtNode.js +307 -307
- package/omd/utils/omdStepVisualizerInteractiveSteps.js +5 -1
- package/package.json +1 -1
- package/src/omdMetaExpression.js +1 -1
|
@@ -1,1247 +1,1247 @@
|
|
|
1
|
-
import { omdNode } from "./omdNode.js";
|
|
2
|
-
import { simplifyStep } from "../simplification/omdSimplification.js";
|
|
3
|
-
import { omdEquationNode } from "./omdEquationNode.js";
|
|
4
|
-
import { getNodeForAST } from "../core/omdUtilities.js";
|
|
5
|
-
import { omdMetaExpression } from "../../src/omdMetaExpression.js";
|
|
6
|
-
import { omdOperationDisplayNode } from "./omdOperationDisplayNode.js";
|
|
7
|
-
import { getFontWeight } from "../config/omdConfigManager.js";
|
|
8
|
-
import { jsvgLayoutGroup } from '@teachinglab/jsvg';
|
|
9
|
-
/**
|
|
10
|
-
* Represents a sequence of equations for a step-by-step calculation.
|
|
11
|
-
* This node manages the layout of multiple equations, ensuring their
|
|
12
|
-
* equals signs are vertically aligned for readability.
|
|
13
|
-
* @extends omdNode
|
|
14
|
-
*/
|
|
15
|
-
export class omdEquationSequenceNode extends omdNode {
|
|
16
|
-
static OPERATION_MAP = {
|
|
17
|
-
'add': 'addToBothSides',
|
|
18
|
-
'subtract': 'subtractFromBothSides',
|
|
19
|
-
'multiply': 'multiplyBothSides',
|
|
20
|
-
'divide': 'divideBothSides',
|
|
21
|
-
};
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Sets the filter level for visible steps in the sequence.
|
|
25
|
-
* @param {number} level - The stepMark level to show (e.g., 0 for major steps)
|
|
26
|
-
*/
|
|
27
|
-
setFilterLevel(level = 0) {
|
|
28
|
-
this.currentFilterLevels = [level];
|
|
29
|
-
this.updateStepsVisibility(step => (step.stepMark ?? 0) === level);
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Sets multiple filter levels for visible steps in the sequence.
|
|
34
|
-
* @param {number[]} levels - Array of stepMark levels to show (e.g., [0, 1] for major and intermediate steps)
|
|
35
|
-
*/
|
|
36
|
-
setFilterLevels(levels = [0]) {
|
|
37
|
-
this.currentFilterLevels = [...levels]; // Store a copy of the levels
|
|
38
|
-
this.updateStepsVisibility(step => {
|
|
39
|
-
const stepLevel = step.stepMark ?? 0;
|
|
40
|
-
return levels.includes(stepLevel);
|
|
41
|
-
});
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Reapplies the current filter levels
|
|
46
|
-
* @private
|
|
47
|
-
*/
|
|
48
|
-
_reapplyCurrentFilter() {
|
|
49
|
-
if (this.currentFilterLevels && this.currentFilterLevels.length > 0) {
|
|
50
|
-
this.updateStepsVisibility(step => {
|
|
51
|
-
const stepLevel = step.stepMark ?? 0;
|
|
52
|
-
return this.currentFilterLevels.includes(stepLevel);
|
|
53
|
-
});
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Gets the current filter level (always returns 0 since we default to level 0)
|
|
59
|
-
* @returns {number} The current filter level
|
|
60
|
-
*/
|
|
61
|
-
getFilterLevel() {
|
|
62
|
-
return 0; // Always return 0 since that's our default
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* Creates a calculation node from an array of equation nodes.
|
|
67
|
-
* @param {Array<omdEquationNode>} steps - An array of omdEquationNode objects.
|
|
68
|
-
*/
|
|
69
|
-
constructor(steps) {
|
|
70
|
-
super({}); // No specific AST for the container itself
|
|
71
|
-
this.type = "omdEquationSequenceNode";
|
|
72
|
-
this.steps = steps;
|
|
73
|
-
this.argumentNodeList.steps = this.steps;
|
|
74
|
-
this.steps.forEach(step => this.addChild(step));
|
|
75
|
-
|
|
76
|
-
this._initializeState();
|
|
77
|
-
this._initializeLayout();
|
|
78
|
-
this._initializeNodeMap();
|
|
79
|
-
this._disableContainerInteractions();
|
|
80
|
-
|
|
81
|
-
this._markInitialSteps();
|
|
82
|
-
|
|
83
|
-
// Apply default filter to show only level 0 steps by default
|
|
84
|
-
this._applyDefaultFilter();
|
|
85
|
-
|
|
86
|
-
// Default background style for new equation steps (optional)
|
|
87
|
-
this.defaultEquationBackground = null;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* @private
|
|
92
|
-
*/
|
|
93
|
-
_initializeState() {
|
|
94
|
-
this.currentStepIndex = 0;
|
|
95
|
-
this.stepDescriptions = [];
|
|
96
|
-
this.importanceLevels = [];
|
|
97
|
-
this.simplificationHistory = [];
|
|
98
|
-
this.currentFilterLevels = [0]; // Track current filter state, default to level 0 only
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/**
|
|
102
|
-
* @private
|
|
103
|
-
*/
|
|
104
|
-
_initializeLayout() {
|
|
105
|
-
this.hideBackgroundByDefault();
|
|
106
|
-
this.layoutHelper = new jsvgLayoutGroup();
|
|
107
|
-
this.layoutHelper.setSpacer(15);
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/**
|
|
111
|
-
* @private
|
|
112
|
-
*/
|
|
113
|
-
_initializeNodeMap() {
|
|
114
|
-
this.nodeMap = new Map();
|
|
115
|
-
this.rebuildNodeMap();
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* @private
|
|
120
|
-
*/
|
|
121
|
-
_disableContainerInteractions() {
|
|
122
|
-
this.svgObject.onmouseenter = null;
|
|
123
|
-
this.svgObject.onmouseleave = null;
|
|
124
|
-
this.svgObject.style.cursor = "default";
|
|
125
|
-
this.svgObject.onclick = null;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/**
|
|
129
|
-
* Marks initial steps in the sequence
|
|
130
|
-
* @private
|
|
131
|
-
*/
|
|
132
|
-
_markInitialSteps() {
|
|
133
|
-
if (!this.steps || !Array.isArray(this.steps)) return;
|
|
134
|
-
|
|
135
|
-
this.steps.forEach((step, index) => {
|
|
136
|
-
if (step instanceof omdEquationNode) {
|
|
137
|
-
// Mark property for filtering
|
|
138
|
-
step.stepMark = 0;
|
|
139
|
-
}
|
|
140
|
-
});
|
|
141
|
-
// Don't apply filtering here - let it happen naturally when needed
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/**
|
|
145
|
-
* Applies the default filter (level 0) automatically
|
|
146
|
-
* @private
|
|
147
|
-
*/
|
|
148
|
-
_applyDefaultFilter() {
|
|
149
|
-
// Only apply filter if we have steps and the steps array is properly initialized
|
|
150
|
-
if (this.steps && Array.isArray(this.steps) && this.steps.length > 0) {
|
|
151
|
-
this.updateStepsVisibility(step => (step.stepMark ?? 0) === 0);
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
/**
|
|
156
|
-
* Gets the last equation in the sequence (the current working equation)
|
|
157
|
-
* @returns {omdEquationNode|null} The last equation, or null if no equations exist
|
|
158
|
-
*/
|
|
159
|
-
getCurrentEquation() {
|
|
160
|
-
if (!this.steps || this.steps.length === 0) return null;
|
|
161
|
-
|
|
162
|
-
// Find the last equation in the sequence
|
|
163
|
-
for (let i = this.steps.length - 1; i >= 0; i--) {
|
|
164
|
-
if (this.steps[i] instanceof omdEquationNode) {
|
|
165
|
-
return this.steps[i];
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
return null;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
/**
|
|
172
|
-
* Adds a new step to the sequence.
|
|
173
|
-
* Can be called with multiple signatures:
|
|
174
|
-
* - addStep(omdNode, optionsObject)
|
|
175
|
-
* - addStep(omdNode, description, importance)
|
|
176
|
-
* - addStep(string, ...)
|
|
177
|
-
* @param {omdNode|string} step - The node object or expression string for the step.
|
|
178
|
-
* @param {Object|string} [descriptionOrOptions] - An options object or a description string.
|
|
179
|
-
* @param {number} [importance] - The importance level (0, 1, 2) if using string description.
|
|
180
|
-
* @returns {number} The index of the added step.
|
|
181
|
-
*/
|
|
182
|
-
addStep(step, descriptionOrOptions, importance) {
|
|
183
|
-
let options = {};
|
|
184
|
-
if (typeof descriptionOrOptions === 'string') {
|
|
185
|
-
options = { description: descriptionOrOptions, stepMark: importance ?? 2 };
|
|
186
|
-
} else if (descriptionOrOptions) {
|
|
187
|
-
options = descriptionOrOptions;
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
const stepNode = (typeof step === 'string') ? this._stringToNode(step) : step;
|
|
191
|
-
const stepIndex = this.steps.length;
|
|
192
|
-
|
|
193
|
-
// Store metadata
|
|
194
|
-
if (options.description !== undefined) {
|
|
195
|
-
this.stepDescriptions[stepIndex] = options.description;
|
|
196
|
-
}
|
|
197
|
-
if (options.stepMark !== undefined) {
|
|
198
|
-
this.importanceLevels[stepIndex] = options.stepMark;
|
|
199
|
-
} else {
|
|
200
|
-
this.importanceLevels[stepIndex] = 0;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
// Add node to sequence
|
|
204
|
-
// Apply default equation background styling before initialization so layout includes padding
|
|
205
|
-
if (this.defaultEquationBackground && typeof stepNode?.setBackgroundStyle === 'function') {
|
|
206
|
-
stepNode.setBackgroundStyle(this.defaultEquationBackground);
|
|
207
|
-
}
|
|
208
|
-
stepNode.setFontSize(this.getFontSize());
|
|
209
|
-
stepNode.initialize();
|
|
210
|
-
this.steps.push(stepNode);
|
|
211
|
-
this.addChild(stepNode);
|
|
212
|
-
this.argumentNodeList.steps = this.steps;
|
|
213
|
-
this.rebuildNodeMap();
|
|
214
|
-
|
|
215
|
-
// Persist stepMark on the node for filtering
|
|
216
|
-
if (stepNode instanceof omdEquationNode) {
|
|
217
|
-
stepNode.stepMark = options.stepMark ?? this._determineStepMark(stepNode, options);
|
|
218
|
-
} else if (options.stepMark !== undefined) {
|
|
219
|
-
stepNode.stepMark = options.stepMark;
|
|
220
|
-
} else {
|
|
221
|
-
stepNode.stepMark = 0;
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
// Refresh layout and display
|
|
225
|
-
this.computeDimensions();
|
|
226
|
-
this.updateLayout();
|
|
227
|
-
if (window.refreshDisplayAndFilters) {
|
|
228
|
-
window.refreshDisplayAndFilters();
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
// Reapply current filter to maintain filter state
|
|
232
|
-
this._reapplyCurrentFilter();
|
|
233
|
-
|
|
234
|
-
return stepIndex;
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
/**
|
|
238
|
-
* Sets a default background style to be applied to all equation steps added thereafter.
|
|
239
|
-
* Also applies it to existing steps immediately.
|
|
240
|
-
* @param {{ backgroundColor?: string, cornerRadius?: number, pill?: boolean, padding?: number|{x:number,y:number} }} style
|
|
241
|
-
*/
|
|
242
|
-
setDefaultEquationBackground(style = null) {
|
|
243
|
-
this.defaultEquationBackground = style;
|
|
244
|
-
if (style) {
|
|
245
|
-
(this.steps || []).forEach(step => {
|
|
246
|
-
if (typeof step?.setBackgroundStyle === 'function') {
|
|
247
|
-
step.setBackgroundStyle(style);
|
|
248
|
-
}
|
|
249
|
-
});
|
|
250
|
-
this.computeDimensions();
|
|
251
|
-
this.updateLayout();
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
/**
|
|
256
|
-
* Determines the appropriate step mark for a step
|
|
257
|
-
* @param {omdNode} step - The step being added
|
|
258
|
-
* @param {Object} options - Options passed to addStep
|
|
259
|
-
* @returns {number} The step mark (0, 1, or 2)
|
|
260
|
-
* @private
|
|
261
|
-
*/
|
|
262
|
-
_determineStepMark(step, options) {
|
|
263
|
-
// If this is called from applyEquationOperation, it's already handled there
|
|
264
|
-
// For other cases, we need to determine if it's a simplification step
|
|
265
|
-
if (options.isSimplification) {
|
|
266
|
-
return 2; // Verbose simplification step
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
// Check if this appears to be a final simplified result
|
|
270
|
-
if (this._isFullySimplified(step)) {
|
|
271
|
-
return 0; // Final result
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
// Default to verbose step
|
|
275
|
-
return 2;
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
/**
|
|
281
|
-
* Checks if an equation appears to be fully simplified
|
|
282
|
-
* @param {omdNode} step - The step to check
|
|
283
|
-
* @returns {boolean} Whether the step appears fully simplified
|
|
284
|
-
* @private
|
|
285
|
-
*/
|
|
286
|
-
_isFullySimplified(step) {
|
|
287
|
-
// This is a heuristic - in practice you might want more sophisticated logic
|
|
288
|
-
// For now, we'll consider it simplified if it doesn't contain complex nested operations
|
|
289
|
-
if (!(step instanceof omdEquationNode)) return false;
|
|
290
|
-
|
|
291
|
-
// Simple heuristic: check if both sides are relatively simple
|
|
292
|
-
const leftIsSimple = this._isSimpleExpression(step.left);
|
|
293
|
-
const rightIsSimple = this._isSimpleExpression(step.right);
|
|
294
|
-
|
|
295
|
-
return leftIsSimple && rightIsSimple;
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
/**
|
|
299
|
-
* Checks if an expression is simple (constant, variable, or simple operations)
|
|
300
|
-
* @param {omdNode} node - The node to check
|
|
301
|
-
* @returns {boolean} Whether the expression is simple
|
|
302
|
-
* @private
|
|
303
|
-
*/
|
|
304
|
-
_isSimpleExpression(node) {
|
|
305
|
-
// This is a simplified heuristic
|
|
306
|
-
if (node.isConstant() || node.type === 'omdVariableNode') {
|
|
307
|
-
return true;
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
// Allow simple binary operations with constants/variables
|
|
311
|
-
if (node.type === 'omdBinaryExpressionNode') {
|
|
312
|
-
return this._isSimpleExpression(node.left) && this._isSimpleExpression(node.right);
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
return false;
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
/**
|
|
319
|
-
* Rebuilds the nodeMap to include ALL nodes from ALL steps in the sequence
|
|
320
|
-
* This is crucial for provenance tracking across multiple steps
|
|
321
|
-
*/
|
|
322
|
-
rebuildNodeMap() {
|
|
323
|
-
if (!this.nodeMap) {
|
|
324
|
-
this.nodeMap = new Map();
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
// Don't clear the map yet - first collect all current nodes
|
|
328
|
-
const newNodeMap = new Map();
|
|
329
|
-
|
|
330
|
-
// Add all nodes from all steps to the new nodeMap
|
|
331
|
-
this.steps.forEach((step, stepIndex) => {
|
|
332
|
-
const stepNodes = step.findAllNodes();
|
|
333
|
-
stepNodes.forEach(node => {
|
|
334
|
-
newNodeMap.set(node.id, node);
|
|
335
|
-
});
|
|
336
|
-
});
|
|
337
|
-
|
|
338
|
-
// Also add the sequence itself
|
|
339
|
-
newNodeMap.set(this.id, this);
|
|
340
|
-
|
|
341
|
-
// Now preserve historical nodes that are referenced in provenance chains
|
|
342
|
-
this.preserveProvenanceHistory(newNodeMap);
|
|
343
|
-
|
|
344
|
-
// Replace the old nodeMap with the new one
|
|
345
|
-
this.nodeMap = newNodeMap;
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
/**
|
|
349
|
-
* Preserves historical nodes that are referenced in provenance chains
|
|
350
|
-
* This ensures the highlighting system can find all nodes it needs
|
|
351
|
-
*/
|
|
352
|
-
preserveProvenanceHistory(newNodeMap) {
|
|
353
|
-
const referencedIds = this._collectAllProvenanceIds(newNodeMap);
|
|
354
|
-
this._preserveReferencedNodes(referencedIds, newNodeMap);
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
/** @private */
|
|
358
|
-
_collectAllProvenanceIds(newNodeMap) {
|
|
359
|
-
const referencedIds = new Set();
|
|
360
|
-
const processedNodes = new Set();
|
|
361
|
-
newNodeMap.forEach(node => this._collectNodeProvenanceIds(node, referencedIds, processedNodes));
|
|
362
|
-
return referencedIds;
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
/** @private */
|
|
366
|
-
_collectNodeProvenanceIds(node, referencedIds, processedNodes) {
|
|
367
|
-
if (!node || !node.id || processedNodes.has(node.id)) return;
|
|
368
|
-
processedNodes.add(node.id);
|
|
369
|
-
|
|
370
|
-
if (node.provenance?.length > 0) {
|
|
371
|
-
node.provenance.forEach(id => referencedIds.add(id));
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
if (node.argumentNodeList) {
|
|
375
|
-
Object.values(node.argumentNodeList).flat().forEach(child => {
|
|
376
|
-
this._collectNodeProvenanceIds(child, referencedIds, processedNodes);
|
|
377
|
-
});
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
/** @private */
|
|
382
|
-
_preserveReferencedNodes(referencedIds, newNodeMap) {
|
|
383
|
-
const processedIds = new Set();
|
|
384
|
-
referencedIds.forEach(id => this._preserveNodeAndContext(id, newNodeMap, processedIds));
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
/** @private */
|
|
388
|
-
_preserveNodeAndContext(id, newNodeMap, processedIds) {
|
|
389
|
-
if (processedIds.has(id) || newNodeMap.has(id) || !this.nodeMap?.has(id)) {
|
|
390
|
-
return;
|
|
391
|
-
}
|
|
392
|
-
processedIds.add(id);
|
|
393
|
-
|
|
394
|
-
const historicalNode = this.nodeMap.get(id);
|
|
395
|
-
newNodeMap.set(id, historicalNode);
|
|
396
|
-
|
|
397
|
-
// Preserve this node's own provenance chain
|
|
398
|
-
if (historicalNode.provenance?.length > 0) {
|
|
399
|
-
historicalNode.provenance.forEach(nestedId => {
|
|
400
|
-
this._preserveNodeAndContext(nestedId, newNodeMap, processedIds);
|
|
401
|
-
});
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
// Preserve parent and sibling context
|
|
405
|
-
this._preserveParentContext(historicalNode, newNodeMap);
|
|
406
|
-
this._preserveSiblingContext(historicalNode, newNodeMap);
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
/** @private */
|
|
410
|
-
_preserveParentContext(node, newNodeMap) {
|
|
411
|
-
let parent = node.parent;
|
|
412
|
-
while (parent && parent.id) {
|
|
413
|
-
if (!newNodeMap.has(parent.id) && this.nodeMap.has(parent.id)) {
|
|
414
|
-
newNodeMap.set(parent.id, this.nodeMap.get(parent.id));
|
|
415
|
-
}
|
|
416
|
-
parent = parent.parent;
|
|
417
|
-
}
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
/** @private */
|
|
421
|
-
_preserveSiblingContext(node, newNodeMap) {
|
|
422
|
-
if (!node.parent?.argumentNodeList) return;
|
|
423
|
-
|
|
424
|
-
Object.values(node.parent.argumentNodeList).flat().forEach(sibling => {
|
|
425
|
-
if (sibling && sibling.id && !newNodeMap.has(sibling.id) && this.nodeMap.has(sibling.id)) {
|
|
426
|
-
newNodeMap.set(sibling.id, this.nodeMap.get(sibling.id));
|
|
427
|
-
}
|
|
428
|
-
});
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
/**
|
|
432
|
-
* Records a simplification step in the history
|
|
433
|
-
* @param {string} name - The name of the simplification rule that was applied
|
|
434
|
-
* @param {Array<string>} affectedNodes - Array of node IDs that were affected by the simplification
|
|
435
|
-
* @param {string} message - Human-readable description of what was simplified
|
|
436
|
-
* @param {Object} [metadata={}] - Additional metadata about the simplification
|
|
437
|
-
*/
|
|
438
|
-
recordSimplificationHistory(name, affectedNodes, message, metadata = {}) {
|
|
439
|
-
const historyEntry = {
|
|
440
|
-
name,
|
|
441
|
-
affectedNodes: [...affectedNodes], // Create a copy of the array
|
|
442
|
-
message,
|
|
443
|
-
stepNumber: this.steps.length,
|
|
444
|
-
...metadata
|
|
445
|
-
};
|
|
446
|
-
|
|
447
|
-
this.simplificationHistory.push(historyEntry);
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
/**
|
|
451
|
-
* Gets the complete simplification history for this sequence
|
|
452
|
-
* @returns {Array<Object>} Array of simplification history entries
|
|
453
|
-
*/
|
|
454
|
-
getSimplificationHistory() {
|
|
455
|
-
return [...this.simplificationHistory]; // Return a copy
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
/**
|
|
459
|
-
* Clears the simplification history
|
|
460
|
-
*/
|
|
461
|
-
clearSimplificationHistory() {
|
|
462
|
-
this.simplificationHistory = [];
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
/**
|
|
466
|
-
* Override setFontSize to propagate to all steps
|
|
467
|
-
* @param {number} fontSize - The new font size
|
|
468
|
-
*/
|
|
469
|
-
setFontSize(fontSize) {
|
|
470
|
-
super.setFontSize(fontSize);
|
|
471
|
-
|
|
472
|
-
// Propagate the font size to all existing steps
|
|
473
|
-
this.steps.forEach(step => {
|
|
474
|
-
step.setFontSize(fontSize);
|
|
475
|
-
});
|
|
476
|
-
|
|
477
|
-
// Recompute dimensions and layout with the new font size
|
|
478
|
-
this.computeDimensions();
|
|
479
|
-
this.updateLayout();
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
/**
|
|
483
|
-
* Convenience helper: recompute dimensions, update layout, and optionally render via a renderer.
|
|
484
|
-
* Use this instead of calling computeDimensions/updateLayout everywhere.
|
|
485
|
-
* @param {object} [renderer] - Optional renderer (e.g., an omdDisplay instance) to re-render the sequence
|
|
486
|
-
*/
|
|
487
|
-
refresh(renderer, center=true) {
|
|
488
|
-
this.computeDimensions();
|
|
489
|
-
this.updateLayout();
|
|
490
|
-
renderer.render(this);
|
|
491
|
-
|
|
492
|
-
if (center) {
|
|
493
|
-
renderer.centerNode();
|
|
494
|
-
}
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
/**
|
|
498
|
-
* Applies a specified operation to the current equation in the sequence and adds the result as a new step.
|
|
499
|
-
* @param {number|string} value - The constant value or expression string to apply.
|
|
500
|
-
* @param {string} operation - The operation name ('add', 'subtract', 'multiply', 'divide').
|
|
501
|
-
* @returns {omdEquationSequenceNode} Returns this sequence for chaining.
|
|
502
|
-
*/
|
|
503
|
-
applyEquationOperation(value, operation) {
|
|
504
|
-
if (!omdEquationSequenceNode.OPERATION_MAP[operation]) {
|
|
505
|
-
console.error(`Invalid operation: ${operation}`);
|
|
506
|
-
return this;
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
const currentEquation = this.getCurrentEquation();
|
|
510
|
-
if (!currentEquation) {
|
|
511
|
-
console.error("No equation to apply operation to.");
|
|
512
|
-
return this;
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
let operationValue = value;
|
|
516
|
-
if (typeof value === 'string') {
|
|
517
|
-
if (!window.math) throw new Error("Math.js is required for parsing expressions");
|
|
518
|
-
operationValue = isNaN(value) ? window.math.parse(value) : parseFloat(value);
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
// Step 1: Add visual operation display
|
|
522
|
-
const operationDisplay = new omdOperationDisplayNode(operation, value);
|
|
523
|
-
this.addStep(operationDisplay, { stepMark: 0 });
|
|
524
|
-
|
|
525
|
-
// Step 2: Apply operation to a clone of the equation
|
|
526
|
-
const clonedEquation = currentEquation.clone();
|
|
527
|
-
const equationMethod = omdEquationSequenceNode.OPERATION_MAP[operation];
|
|
528
|
-
const unsimplifiedEquation = clonedEquation[equationMethod](operationValue, operationDisplay.id);
|
|
529
|
-
|
|
530
|
-
// Step 3: Check simplification potential and add the new equation step
|
|
531
|
-
const testClone = unsimplifiedEquation.clone();
|
|
532
|
-
const { foldedCount } = simplifyStep(testClone);
|
|
533
|
-
const isSimplified = foldedCount === 0;
|
|
534
|
-
|
|
535
|
-
this.addStep(unsimplifiedEquation, {
|
|
536
|
-
stepMark: isSimplified ? 0 : 1,
|
|
537
|
-
description: this._getOperationDescription(operation, value, !isSimplified)
|
|
538
|
-
});
|
|
539
|
-
|
|
540
|
-
return this;
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
/**
|
|
544
|
-
* Generates a description for an equation operation.
|
|
545
|
-
* @param {string} operation - The operation name.
|
|
546
|
-
* @param {number|string} value - The value used in the operation.
|
|
547
|
-
* @param {boolean} isUnsimplified - Whether the result is unsimplified.
|
|
548
|
-
* @returns {string} The formatted description.
|
|
549
|
-
* @private
|
|
550
|
-
*/
|
|
551
|
-
_getOperationDescription(operation, value, isUnsimplified) {
|
|
552
|
-
const templates = {
|
|
553
|
-
'add': `Added ${value} to both sides`,
|
|
554
|
-
'subtract': `Subtracted ${value} from both sides`,
|
|
555
|
-
'multiply': `Multiplied both sides by ${value}`,
|
|
556
|
-
'divide': `Divided both sides by ${value}`
|
|
557
|
-
};
|
|
558
|
-
const baseDescription = templates[operation] || `Applied ${operation} with ${value}`;
|
|
559
|
-
return isUnsimplified ? `${baseDescription} (unsimplified)` : baseDescription;
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
/**
|
|
563
|
-
* Applies a function to both sides of the current equation in the sequence and adds the result as a new step.
|
|
564
|
-
* @param {string} functionName - The name of the function to apply.
|
|
565
|
-
* @returns {omdEquationSequenceNode} Returns this sequence for chaining.
|
|
566
|
-
*/
|
|
567
|
-
applyEquationFunction(functionName) {
|
|
568
|
-
const currentEquation = this.getCurrentEquation();
|
|
569
|
-
if (!currentEquation) {
|
|
570
|
-
throw new Error("No equation found in sequence to operate on");
|
|
571
|
-
}
|
|
572
|
-
|
|
573
|
-
// Clone the current equation
|
|
574
|
-
const clonedEquation = currentEquation.clone();
|
|
575
|
-
|
|
576
|
-
// Apply the function to the clone
|
|
577
|
-
const newEquation = clonedEquation.applyFunction(functionName);
|
|
578
|
-
|
|
579
|
-
// Check if any simplifications are possible on this new step
|
|
580
|
-
const testClone = newEquation.clone();
|
|
581
|
-
const { foldedCount } = simplifyStep(testClone);
|
|
582
|
-
|
|
583
|
-
// Determine the appropriate step mark based on simplification potential
|
|
584
|
-
const stepMark = foldedCount === 0 ? 0 : 1;
|
|
585
|
-
const description = `Applied ${functionName} to both sides`;
|
|
586
|
-
|
|
587
|
-
this.addStep(newEquation, {
|
|
588
|
-
stepMark: stepMark,
|
|
589
|
-
description: description
|
|
590
|
-
});
|
|
591
|
-
|
|
592
|
-
return this;
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
/**
|
|
597
|
-
* Simplifies the current step in the sequence by applying one round of simplification rules
|
|
598
|
-
* @returns {Object} Result object containing:
|
|
599
|
-
* @returns {boolean} result.success - Whether any simplification was applied
|
|
600
|
-
* @returns {number} result.foldedCount - Number of simplification operations applied (0 if none)
|
|
601
|
-
* @returns {boolean} result.isFinalSimplification - Whether this represents the final simplified form
|
|
602
|
-
* @returns {string} result.message - Human-readable description of the result
|
|
603
|
-
*/
|
|
604
|
-
simplify() {
|
|
605
|
-
const currentStep = this.steps[this.steps.length - 1];
|
|
606
|
-
if (!currentStep) {
|
|
607
|
-
return { success: false, message: 'No expression found to simplify' };
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
try {
|
|
611
|
-
const stepToSimplify = currentStep.clone();
|
|
612
|
-
const simplificationResult = simplifyStep(stepToSimplify);
|
|
613
|
-
|
|
614
|
-
if (simplificationResult.foldedCount > 0) {
|
|
615
|
-
return this._handleSuccessfulSimplification(currentStep, simplificationResult);
|
|
616
|
-
} else {
|
|
617
|
-
return { success: false, foldedCount: 0, message: 'No simplifications available' };
|
|
618
|
-
}
|
|
619
|
-
} catch (error) {
|
|
620
|
-
console.error(`Error during simplification:`, error);
|
|
621
|
-
return { success: false, message: `Simplification error: ${error.message}` };
|
|
622
|
-
}
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
/** @private */
|
|
626
|
-
_handleSuccessfulSimplification(originalStep, { newRoot, foldedCount, historyEntry }) {
|
|
627
|
-
if (historyEntry) {
|
|
628
|
-
historyEntry.stepNumber = this.steps.length - 1;
|
|
629
|
-
historyEntry.originalStep = originalStep.toString();
|
|
630
|
-
this.simplificationHistory.push(historyEntry);
|
|
631
|
-
}
|
|
632
|
-
|
|
633
|
-
const testClone = newRoot.clone();
|
|
634
|
-
const { foldedCount: moreFolds } = simplifyStep(testClone);
|
|
635
|
-
const isFinal = moreFolds === 0;
|
|
636
|
-
|
|
637
|
-
const description = isFinal
|
|
638
|
-
? `Fully simplified result (${foldedCount} operation${foldedCount > 1 ? 's' : ''} applied)`
|
|
639
|
-
: `Simplification step (${foldedCount} operation${foldedCount > 1 ? 's' : ''} applied)`;
|
|
640
|
-
|
|
641
|
-
this.addStep(newRoot, {
|
|
642
|
-
stepMark: isFinal ? 0 : 2,
|
|
643
|
-
description: description,
|
|
644
|
-
isSimplification: true,
|
|
645
|
-
});
|
|
646
|
-
|
|
647
|
-
const message = isFinal
|
|
648
|
-
? `Fully simplified! Applied ${foldedCount} simplification step(s).`
|
|
649
|
-
: `Simplified! Applied ${foldedCount} simplification step(s), more are available.`;
|
|
650
|
-
|
|
651
|
-
return { success: true, foldedCount, isFinalSimplification: isFinal, message };
|
|
652
|
-
}
|
|
653
|
-
|
|
654
|
-
/**
|
|
655
|
-
* Simplifies all possible expressions until no more simplifications can be applied
|
|
656
|
-
* Repeatedly calls simplify() until no further simplifications are possible
|
|
657
|
-
* @param {number} [maxIterations=50] - Maximum number of iterations to prevent infinite loops
|
|
658
|
-
* @returns {Object} Result object containing:
|
|
659
|
-
* @returns {boolean} result.success - Whether the operation completed successfully (false if stopped due to max iterations)
|
|
660
|
-
* @returns {number} result.totalSteps - Number of simplification steps that were added to the sequence
|
|
661
|
-
* @returns {number} result.iterations - Number of simplify() calls made during the process
|
|
662
|
-
* @returns {string} result.message - Human-readable description of the final result
|
|
663
|
-
*/
|
|
664
|
-
simplifyAll(maxIterations = 50) {
|
|
665
|
-
let iteration = 0;
|
|
666
|
-
let stepsBefore;
|
|
667
|
-
let totalSteps = 0;
|
|
668
|
-
|
|
669
|
-
do {
|
|
670
|
-
stepsBefore = this.steps.length;
|
|
671
|
-
const result = this.simplify();
|
|
672
|
-
|
|
673
|
-
if (result.success) {
|
|
674
|
-
totalSteps++;
|
|
675
|
-
}
|
|
676
|
-
|
|
677
|
-
iteration++;
|
|
678
|
-
} while (this.steps.length > stepsBefore && iteration < maxIterations);
|
|
679
|
-
|
|
680
|
-
if (iteration >= maxIterations) {
|
|
681
|
-
return {
|
|
682
|
-
success: false,
|
|
683
|
-
totalSteps,
|
|
684
|
-
iterations: iteration,
|
|
685
|
-
message: `Stopped after ${maxIterations} iterations to avoid an infinite loop.`
|
|
686
|
-
};
|
|
687
|
-
} else {
|
|
688
|
-
return {
|
|
689
|
-
success: true,
|
|
690
|
-
totalSteps,
|
|
691
|
-
iterations: iteration,
|
|
692
|
-
message: `All possible simplifications completed. Added ${totalSteps} simplification steps.`
|
|
693
|
-
};
|
|
694
|
-
}
|
|
695
|
-
}
|
|
696
|
-
|
|
697
|
-
/**
|
|
698
|
-
* Evaluates the current step in the sequence with the given variables.
|
|
699
|
-
* Logs the result to the console.
|
|
700
|
-
* @param {Object} variables - A map of variable names to their numeric values.
|
|
701
|
-
*/
|
|
702
|
-
evaluate(variables = {}) {
|
|
703
|
-
const targetNode = this.getCurrentStep();
|
|
704
|
-
if (!targetNode || typeof targetNode.evaluate !== 'function') {
|
|
705
|
-
console.warn("Evaluation not supported for the current step.");
|
|
706
|
-
return;
|
|
707
|
-
}
|
|
708
|
-
|
|
709
|
-
try {
|
|
710
|
-
const result = targetNode.evaluate(variables);
|
|
711
|
-
|
|
712
|
-
if (typeof result === 'object' && result.left !== undefined && result.right !== undefined) {
|
|
713
|
-
const { left, right } = result;
|
|
714
|
-
const isEqual = Math.abs(left - right) < 1e-9;
|
|
715
|
-
|
|
716
|
-
} else {
|
|
717
|
-
|
|
718
|
-
}
|
|
719
|
-
} catch (error) {
|
|
720
|
-
console.error("Evaluation failed:", error.message);
|
|
721
|
-
}
|
|
722
|
-
}
|
|
723
|
-
|
|
724
|
-
/**
|
|
725
|
-
* Validates the provenance integrity across all steps in the sequence
|
|
726
|
-
* @returns {Array} Array of validation issues found
|
|
727
|
-
*/
|
|
728
|
-
validateSequenceProvenance() {
|
|
729
|
-
const issues = [];
|
|
730
|
-
this._validateStepsProvenance(issues);
|
|
731
|
-
this._findOrphanedNodes(issues);
|
|
732
|
-
return issues;
|
|
733
|
-
}
|
|
734
|
-
|
|
735
|
-
/** @private */
|
|
736
|
-
_validateStepsProvenance(issues) {
|
|
737
|
-
this.steps.forEach((step, index) => {
|
|
738
|
-
const stepIssues = step.validateProvenance(this.nodeMap);
|
|
739
|
-
stepIssues.forEach(issue => issues.push({ ...issue, stepIndex: index }));
|
|
740
|
-
});
|
|
741
|
-
}
|
|
742
|
-
|
|
743
|
-
/** @private */
|
|
744
|
-
_findOrphanedNodes(issues) {
|
|
745
|
-
const currentNodeIds = new Set(this.steps.flatMap(step => step.findAllNodes().map(n => n.id)));
|
|
746
|
-
const allProvenanceIds = this._collectAllProvenanceIds(this.nodeMap);
|
|
747
|
-
|
|
748
|
-
this.nodeMap.forEach((node, id) => {
|
|
749
|
-
if (!currentNodeIds.has(id) && !allProvenanceIds.has(id)) {
|
|
750
|
-
issues.push({
|
|
751
|
-
type: 'orphaned_node',
|
|
752
|
-
nodeId: id,
|
|
753
|
-
nodeType: node.type,
|
|
754
|
-
});
|
|
755
|
-
}
|
|
756
|
-
});
|
|
757
|
-
}
|
|
758
|
-
|
|
759
|
-
/**
|
|
760
|
-
* Overrides the default select behavior to prevent the container from highlighting.
|
|
761
|
-
* This container should be inert and not react to selection events.
|
|
762
|
-
*/
|
|
763
|
-
select() {
|
|
764
|
-
}
|
|
765
|
-
|
|
766
|
-
/**
|
|
767
|
-
* Overrides the default deselect behavior to prevent the container from highlighting.
|
|
768
|
-
*/
|
|
769
|
-
deselect() {
|
|
770
|
-
}
|
|
771
|
-
|
|
772
|
-
/**
|
|
773
|
-
* Override highlight to prevent the sequence container itself from highlighting
|
|
774
|
-
* but still allow children to be highlighted
|
|
775
|
-
*/
|
|
776
|
-
highlight(color) {
|
|
777
|
-
// Don't highlight the sequence container itself
|
|
778
|
-
// Just propagate to children (but not the backRect)
|
|
779
|
-
this.childList.forEach((child) => {
|
|
780
|
-
if (child instanceof omdMetaExpression && child !== this.backRect) {
|
|
781
|
-
child.highlight(color);
|
|
782
|
-
}
|
|
783
|
-
});
|
|
784
|
-
}
|
|
785
|
-
|
|
786
|
-
/**
|
|
787
|
-
* Override clearProvenanceHighlights to work with the sequence
|
|
788
|
-
*/
|
|
789
|
-
clearProvenanceHighlights() {
|
|
790
|
-
// Don't change the sequence container's background
|
|
791
|
-
// Just clear highlights from children
|
|
792
|
-
this.childList.forEach((child) => {
|
|
793
|
-
if (child instanceof omdMetaExpression && typeof child.clearProvenanceHighlights === 'function' && child !== this.backRect) {
|
|
794
|
-
child.clearProvenanceHighlights();
|
|
795
|
-
}
|
|
796
|
-
});
|
|
797
|
-
}
|
|
798
|
-
|
|
799
|
-
/**
|
|
800
|
-
* Calculates the dimensions of the entire calculation block.
|
|
801
|
-
* It determines the correct alignment for all equals signs and calculates
|
|
802
|
-
* the total width and height required.
|
|
803
|
-
* @override
|
|
804
|
-
*/
|
|
805
|
-
computeDimensions() {
|
|
806
|
-
const visibleSteps = this.steps.filter(s => s.visible !== false);
|
|
807
|
-
if (visibleSteps.length === 0) {
|
|
808
|
-
this.setWidthAndHeight(0, 0);
|
|
809
|
-
return;
|
|
810
|
-
}
|
|
811
|
-
|
|
812
|
-
visibleSteps.forEach(step => step.computeDimensions());
|
|
813
|
-
|
|
814
|
-
this.alignPointX = this._calculateAlignmentPoint(visibleSteps);
|
|
815
|
-
|
|
816
|
-
const { maxWidth, totalHeight } = this._calculateTotalDimensions(visibleSteps);
|
|
817
|
-
this.setWidthAndHeight(maxWidth, totalHeight);
|
|
818
|
-
}
|
|
819
|
-
|
|
820
|
-
/** @private */
|
|
821
|
-
_calculateAlignmentPoint(visibleSteps) {
|
|
822
|
-
const equalsCenters = [];
|
|
823
|
-
visibleSteps.forEach(step => {
|
|
824
|
-
if (step instanceof omdEquationNode) {
|
|
825
|
-
if (typeof step.getEqualsAnchorX === 'function') {
|
|
826
|
-
equalsCenters.push(step.getEqualsAnchorX());
|
|
827
|
-
} else if (step.equalsSign && step.left) {
|
|
828
|
-
const spacing = 8 * step.getFontSize() / step.getRootFontSize();
|
|
829
|
-
equalsCenters.push(step.left.width + spacing + (step.equalsSign.width / 2));
|
|
830
|
-
}
|
|
831
|
-
}
|
|
832
|
-
});
|
|
833
|
-
return equalsCenters.length > 0 ? Math.max(...equalsCenters) : 0;
|
|
834
|
-
}
|
|
835
|
-
|
|
836
|
-
/** @private */
|
|
837
|
-
_calculateTotalDimensions(visibleSteps) {
|
|
838
|
-
let maxWidth = 0;
|
|
839
|
-
let totalHeight = 0;
|
|
840
|
-
const verticalPadding = 15 * this.getFontSize() / this.getRootFontSize();
|
|
841
|
-
|
|
842
|
-
visibleSteps.forEach((step, index) => {
|
|
843
|
-
let stepWidth = 0;
|
|
844
|
-
if (step instanceof omdEquationNode) {
|
|
845
|
-
stepWidth = this.alignPointX + step.equalsSign.width + step.right.width;
|
|
846
|
-
} else {
|
|
847
|
-
stepWidth = step.width;
|
|
848
|
-
}
|
|
849
|
-
|
|
850
|
-
maxWidth = Math.max(maxWidth, stepWidth);
|
|
851
|
-
totalHeight += step.height;
|
|
852
|
-
if (index < visibleSteps.length - 1) {
|
|
853
|
-
totalHeight += verticalPadding;
|
|
854
|
-
}
|
|
855
|
-
});
|
|
856
|
-
|
|
857
|
-
return { maxWidth, totalHeight };
|
|
858
|
-
}
|
|
859
|
-
|
|
860
|
-
/**
|
|
861
|
-
* Computes the horizontal offset needed to align a step with the master equals anchor.
|
|
862
|
-
* Equations align their equals sign center to alignPointX; operation displays align their
|
|
863
|
-
* virtual equals (middle of the gap); other steps are centered within the sequence width.
|
|
864
|
-
* @param {omdNode} step
|
|
865
|
-
* @returns {number} x offset in local coordinates
|
|
866
|
-
* @private
|
|
867
|
-
*/
|
|
868
|
-
_computeStepXOffset(step) {
|
|
869
|
-
if (step instanceof omdEquationNode) {
|
|
870
|
-
const equalsAnchorX = (typeof step.getEqualsAnchorX === 'function') ? step.getEqualsAnchorX() : step.left.width;
|
|
871
|
-
return this.alignPointX - equalsAnchorX;
|
|
872
|
-
}
|
|
873
|
-
if (step instanceof omdOperationDisplayNode) {
|
|
874
|
-
const leftWidth = (typeof step.getLeftWidthForAlignment === 'function')
|
|
875
|
-
? step.getLeftWidthForAlignment()
|
|
876
|
-
: step.width / 2;
|
|
877
|
-
const halfGap = (typeof step.gap === 'number' ? step.gap : 0) / 2;
|
|
878
|
-
return this.alignPointX - (leftWidth + halfGap);
|
|
879
|
-
}
|
|
880
|
-
return (this.width - step.width) / 2;
|
|
881
|
-
}
|
|
882
|
-
|
|
883
|
-
/**
|
|
884
|
-
* Updates the layout of the calculation block.
|
|
885
|
-
* This method positions each equation vertically and aligns their
|
|
886
|
-
* equals signs to the calculated alignment point.
|
|
887
|
-
* @override
|
|
888
|
-
*/
|
|
889
|
-
updateLayout() {
|
|
890
|
-
const verticalPadding = 15 * this.getFontSize() / this.getRootFontSize();
|
|
891
|
-
const visibleSteps = this.steps.filter(s => s.visible !== false);
|
|
892
|
-
|
|
893
|
-
visibleSteps.forEach(step => step.updateLayout());
|
|
894
|
-
|
|
895
|
-
this.alignPointX = this._calculateAlignmentPoint(visibleSteps);
|
|
896
|
-
|
|
897
|
-
let yCurrent = 0;
|
|
898
|
-
visibleSteps.forEach((step, index) => {
|
|
899
|
-
const xOffset = this._computeStepXOffset(step);
|
|
900
|
-
step.setPosition(xOffset, yCurrent);
|
|
901
|
-
|
|
902
|
-
yCurrent += step.height;
|
|
903
|
-
if (index < visibleSteps.length - 1) yCurrent += verticalPadding;
|
|
904
|
-
});
|
|
905
|
-
}
|
|
906
|
-
|
|
907
|
-
/**
|
|
908
|
-
* Creates an omdEquationSequenceNode instance from an array of strings.
|
|
909
|
-
* @param {Array<string>} stepStrings - An array of strings, each representing a calculation step.
|
|
910
|
-
* @returns {omdEquationSequenceNode} A new instance of omdEquationSequenceNode.
|
|
911
|
-
*/
|
|
912
|
-
static fromStringArray(stepStrings) {
|
|
913
|
-
const stepNodes = stepStrings.map(str => {
|
|
914
|
-
const trimmedStr = str.trim();
|
|
915
|
-
// If the string contains an equals sign, parse it as a full equation.
|
|
916
|
-
if (trimmedStr.includes('=')) {
|
|
917
|
-
return omdEquationNode.fromString(trimmedStr);
|
|
918
|
-
}
|
|
919
|
-
|
|
920
|
-
// If it doesn't contain an equals sign, it's not a valid equation step for a sequence.
|
|
921
|
-
throw new Error(`Step string "${trimmedStr}" is not a valid equation for omdEquationSequenceNode.`);
|
|
922
|
-
});
|
|
923
|
-
return new omdEquationSequenceNode(stepNodes);
|
|
924
|
-
}
|
|
925
|
-
|
|
926
|
-
clone() {
|
|
927
|
-
const clonedSteps = this.steps.map(step => step.clone());
|
|
928
|
-
const clone = new omdEquationSequenceNode(clonedSteps);
|
|
929
|
-
|
|
930
|
-
// The crucial step: link the clone to its origin (following the pattern from omdNode)
|
|
931
|
-
clone.provenance.push(this.id);
|
|
932
|
-
|
|
933
|
-
// The clone gets a fresh nodeMap, as its history is self-contained
|
|
934
|
-
clone.nodeMap = new Map();
|
|
935
|
-
clone.findAllNodes().forEach(node => clone.nodeMap.set(node.id, node));
|
|
936
|
-
return clone;
|
|
937
|
-
}
|
|
938
|
-
|
|
939
|
-
/**
|
|
940
|
-
* Converts the omdEquationSequenceNode to a math.js AST node.
|
|
941
|
-
* Since sequences are containers, we return a custom representation.
|
|
942
|
-
* @returns {Object} A custom AST node representing the sequence.
|
|
943
|
-
*/
|
|
944
|
-
toMathJSNode() {
|
|
945
|
-
const astNode = this.steps[this.steps.length-1].toMathJSNode();
|
|
946
|
-
|
|
947
|
-
return astNode;
|
|
948
|
-
}
|
|
949
|
-
|
|
950
|
-
/**
|
|
951
|
-
* Get the current step node
|
|
952
|
-
* @returns {omdNode} The current step
|
|
953
|
-
*/
|
|
954
|
-
getCurrentStep() {
|
|
955
|
-
// No steps → no current step
|
|
956
|
-
if (!this.steps || this.steps.length === 0) return null;
|
|
957
|
-
|
|
958
|
-
// Prefer the bottom-most VISIBLE equation step, falling back gracefully
|
|
959
|
-
let chosenIndex = -1;
|
|
960
|
-
for (let i = this.steps.length - 1; i >= 0; i--) {
|
|
961
|
-
const step = this.steps[i];
|
|
962
|
-
if (!step) continue;
|
|
963
|
-
// If visibility is explicitly false, skip
|
|
964
|
-
if (step.visible === false) continue;
|
|
965
|
-
// Prefer equation nodes when present
|
|
966
|
-
if (step.constructor?.name === 'omdEquationNode') {
|
|
967
|
-
chosenIndex = i;
|
|
968
|
-
break;
|
|
969
|
-
}
|
|
970
|
-
// Remember last visible non-equation as a fallback if no equation exists
|
|
971
|
-
if (chosenIndex === -1) chosenIndex = i;
|
|
972
|
-
}
|
|
973
|
-
|
|
974
|
-
if (chosenIndex === -1) {
|
|
975
|
-
// If everything is hidden or invalid, fall back to the last step
|
|
976
|
-
chosenIndex = this.steps.length - 1;
|
|
977
|
-
}
|
|
978
|
-
|
|
979
|
-
// Clamp and store
|
|
980
|
-
if (chosenIndex < 0) chosenIndex = 0;
|
|
981
|
-
if (chosenIndex >= this.steps.length) chosenIndex = this.steps.length - 1;
|
|
982
|
-
this.currentStepIndex = chosenIndex;
|
|
983
|
-
return this.steps[chosenIndex];
|
|
984
|
-
}
|
|
985
|
-
|
|
986
|
-
/**
|
|
987
|
-
* Navigate to a specific step
|
|
988
|
-
* @param {number} index - The step index to navigate to
|
|
989
|
-
* @returns {boolean} Whether navigation was successful
|
|
990
|
-
*/
|
|
991
|
-
navigateToStep(index) {
|
|
992
|
-
if (index < 0 || index >= this.steps.length) {
|
|
993
|
-
return false;
|
|
994
|
-
}
|
|
995
|
-
this.currentStepIndex = index;
|
|
996
|
-
|
|
997
|
-
// Trigger any UI updates if needed
|
|
998
|
-
if (window.refreshDisplayAndFilters) {
|
|
999
|
-
window.refreshDisplayAndFilters();
|
|
1000
|
-
}
|
|
1001
|
-
|
|
1002
|
-
return true;
|
|
1003
|
-
}
|
|
1004
|
-
|
|
1005
|
-
/**
|
|
1006
|
-
* Navigate to the next step
|
|
1007
|
-
* @returns {boolean} Whether there was a next step
|
|
1008
|
-
*/
|
|
1009
|
-
nextStep() {
|
|
1010
|
-
if (this.currentStepIndex < this.steps.length - 1) {
|
|
1011
|
-
this.currentStepIndex++;
|
|
1012
|
-
|
|
1013
|
-
// Trigger any UI updates if needed
|
|
1014
|
-
if (window.refreshDisplayAndFilters) {
|
|
1015
|
-
window.refreshDisplayAndFilters();
|
|
1016
|
-
}
|
|
1017
|
-
|
|
1018
|
-
return true;
|
|
1019
|
-
}
|
|
1020
|
-
return false;
|
|
1021
|
-
}
|
|
1022
|
-
|
|
1023
|
-
/**
|
|
1024
|
-
* Navigate to the previous step
|
|
1025
|
-
* @returns {boolean} Whether there was a previous step
|
|
1026
|
-
*/
|
|
1027
|
-
previousStep() {
|
|
1028
|
-
if (this.currentStepIndex > 0) {
|
|
1029
|
-
this.currentStepIndex--;
|
|
1030
|
-
|
|
1031
|
-
// Trigger any UI updates if needed
|
|
1032
|
-
if (window.refreshDisplayAndFilters) {
|
|
1033
|
-
window.refreshDisplayAndFilters();
|
|
1034
|
-
}
|
|
1035
|
-
|
|
1036
|
-
return true;
|
|
1037
|
-
}
|
|
1038
|
-
return false;
|
|
1039
|
-
}
|
|
1040
|
-
|
|
1041
|
-
/**
|
|
1042
|
-
* Get steps filtered by importance level
|
|
1043
|
-
* @param {number} maxImportance - Maximum importance level to include (0, 1, or 2)
|
|
1044
|
-
* @returns {Object[]} Array of objects containing step, description, importance, and index
|
|
1045
|
-
*/
|
|
1046
|
-
getFilteredSteps(maxImportance) {
|
|
1047
|
-
const filteredSteps = [];
|
|
1048
|
-
|
|
1049
|
-
this.steps.forEach((step, index) => {
|
|
1050
|
-
const importance = this.importanceLevels[index] !== undefined ? this.importanceLevels[index] :
|
|
1051
|
-
(step.stepMark !== undefined ? step.stepMark : 0);
|
|
1052
|
-
|
|
1053
|
-
if (importance <= maxImportance) {
|
|
1054
|
-
filteredSteps.push({
|
|
1055
|
-
step: step,
|
|
1056
|
-
description: this.stepDescriptions[index] || '',
|
|
1057
|
-
importance: importance,
|
|
1058
|
-
index: index
|
|
1059
|
-
});
|
|
1060
|
-
}
|
|
1061
|
-
});
|
|
1062
|
-
|
|
1063
|
-
return filteredSteps;
|
|
1064
|
-
}
|
|
1065
|
-
|
|
1066
|
-
/**
|
|
1067
|
-
* Renders only the current step
|
|
1068
|
-
* @returns {SVGElement} The current step's rendering
|
|
1069
|
-
*/
|
|
1070
|
-
renderCurrentStep() {
|
|
1071
|
-
const currentStep = this.getCurrentStep();
|
|
1072
|
-
if (!currentStep) {
|
|
1073
|
-
// Return empty SVG group if no current step
|
|
1074
|
-
const emptyGroup = new jsvgGroup();
|
|
1075
|
-
return emptyGroup.svgObject;
|
|
1076
|
-
}
|
|
1077
|
-
|
|
1078
|
-
// Create a temporary container to render just the current step
|
|
1079
|
-
const tempContainer = new jsvgGroup();
|
|
1080
|
-
|
|
1081
|
-
// Compute dimensions and render the current step
|
|
1082
|
-
currentStep.computeDimensions();
|
|
1083
|
-
currentStep.updateLayout();
|
|
1084
|
-
const stepRendering = currentStep.render();
|
|
1085
|
-
|
|
1086
|
-
tempContainer.addChild(stepRendering);
|
|
1087
|
-
return tempContainer.svgObject;
|
|
1088
|
-
}
|
|
1089
|
-
|
|
1090
|
-
/**
|
|
1091
|
-
* Convert the entire sequence to a string
|
|
1092
|
-
* @returns {string} Multi-line string of all steps
|
|
1093
|
-
*/
|
|
1094
|
-
toString() {
|
|
1095
|
-
if (this.steps.length === 0) {
|
|
1096
|
-
return '';
|
|
1097
|
-
}
|
|
1098
|
-
return this.steps.map((step, index) => {
|
|
1099
|
-
const description = this.stepDescriptions[index] ? ` (${this.stepDescriptions[index]})` : '';
|
|
1100
|
-
return `Step ${index + 1}: ${step.toString()}${description}`;
|
|
1101
|
-
}).join('\\n');
|
|
1102
|
-
}
|
|
1103
|
-
|
|
1104
|
-
/**
|
|
1105
|
-
* Clear all steps from the sequence
|
|
1106
|
-
*/
|
|
1107
|
-
clear() {
|
|
1108
|
-
// Remove all children
|
|
1109
|
-
this.steps.forEach(step => {
|
|
1110
|
-
this.removeChild(step);
|
|
1111
|
-
});
|
|
1112
|
-
|
|
1113
|
-
// Clear arrays
|
|
1114
|
-
this.steps = [];
|
|
1115
|
-
this.stepDescriptions = [];
|
|
1116
|
-
this.importanceLevels = [];
|
|
1117
|
-
this.argumentNodeList.steps = [];
|
|
1118
|
-
this.currentStepIndex = 0;
|
|
1119
|
-
|
|
1120
|
-
// Clear history
|
|
1121
|
-
this.clearSimplificationHistory();
|
|
1122
|
-
|
|
1123
|
-
// Rebuild node map
|
|
1124
|
-
this.rebuildNodeMap();
|
|
1125
|
-
|
|
1126
|
-
// Update dimensions
|
|
1127
|
-
this.computeDimensions();
|
|
1128
|
-
this.updateLayout();
|
|
1129
|
-
|
|
1130
|
-
// Trigger any UI updates if needed
|
|
1131
|
-
if (window.refreshDisplayAndFilters) {
|
|
1132
|
-
window.refreshDisplayAndFilters();
|
|
1133
|
-
}
|
|
1134
|
-
}
|
|
1135
|
-
|
|
1136
|
-
/**
|
|
1137
|
-
* Create a sequence from an array of expressions
|
|
1138
|
-
* @param {string[]} stepsArray - Array of expression strings
|
|
1139
|
-
* @returns {omdEquationSequenceNode} A new sequence node
|
|
1140
|
-
* @static
|
|
1141
|
-
*/
|
|
1142
|
-
static fromSteps(stepsArray) {
|
|
1143
|
-
if (!Array.isArray(stepsArray)) {
|
|
1144
|
-
throw new Error('fromSteps requires an array of expression strings');
|
|
1145
|
-
}
|
|
1146
|
-
|
|
1147
|
-
const sequence = new omdEquationSequenceNode([]);
|
|
1148
|
-
|
|
1149
|
-
stepsArray.forEach((stepStr, index) => {
|
|
1150
|
-
const trimmedStr = stepStr.trim();
|
|
1151
|
-
let stepNode;
|
|
1152
|
-
|
|
1153
|
-
// If the string contains an equals sign, parse it as an equation
|
|
1154
|
-
if (trimmedStr.includes('=')) {
|
|
1155
|
-
stepNode = omdEquationNode.fromString(trimmedStr);
|
|
1156
|
-
} else {
|
|
1157
|
-
// Otherwise, parse it as a general expression
|
|
1158
|
-
if (!window.math) {
|
|
1159
|
-
throw new Error("Math.js is required for parsing expressions");
|
|
1160
|
-
}
|
|
1161
|
-
|
|
1162
|
-
const ast = window.math.parse(trimmedStr);
|
|
1163
|
-
const NodeType = getNodeForAST(ast);
|
|
1164
|
-
stepNode = new NodeType(ast);
|
|
1165
|
-
}
|
|
1166
|
-
|
|
1167
|
-
// Add the step with default importance
|
|
1168
|
-
sequence.addStep(stepNode, {
|
|
1169
|
-
stepMark: 0, // Default to major step
|
|
1170
|
-
description: ''
|
|
1171
|
-
});
|
|
1172
|
-
});
|
|
1173
|
-
|
|
1174
|
-
return sequence;
|
|
1175
|
-
}
|
|
1176
|
-
|
|
1177
|
-
/**
|
|
1178
|
-
* Converts an expression string into a proper omdNode.
|
|
1179
|
-
* @param {string} str - The expression string.
|
|
1180
|
-
* @returns {omdNode} The corresponding node.
|
|
1181
|
-
* @private
|
|
1182
|
-
*/
|
|
1183
|
-
_stringToNode(str) {
|
|
1184
|
-
const trimmedStr = str.trim();
|
|
1185
|
-
if (trimmedStr.includes('=')) {
|
|
1186
|
-
return omdEquationNode.fromString(trimmedStr);
|
|
1187
|
-
}
|
|
1188
|
-
|
|
1189
|
-
if (!window.math) {
|
|
1190
|
-
throw new Error("Math.js is required for parsing expressions");
|
|
1191
|
-
}
|
|
1192
|
-
|
|
1193
|
-
const ast = window.math.parse(trimmedStr);
|
|
1194
|
-
const NodeType = getNodeForAST(ast);
|
|
1195
|
-
return new NodeType(ast);
|
|
1196
|
-
}
|
|
1197
|
-
|
|
1198
|
-
show() {
|
|
1199
|
-
super.show();
|
|
1200
|
-
if (this.layoutManager) {
|
|
1201
|
-
this.layoutManager.updateVisualVisibility();
|
|
1202
|
-
}
|
|
1203
|
-
}
|
|
1204
|
-
|
|
1205
|
-
hide() {
|
|
1206
|
-
super.hide();
|
|
1207
|
-
if (this.layoutManager) {
|
|
1208
|
-
this.layoutManager.updateVisualVisibility();
|
|
1209
|
-
}
|
|
1210
|
-
}
|
|
1211
|
-
|
|
1212
|
-
/**
|
|
1213
|
-
* Updates visibility of multiple steps at once
|
|
1214
|
-
* @param {Function} visibilityPredicate Function that takes a step and returns true if it should be visible
|
|
1215
|
-
*/
|
|
1216
|
-
updateStepsVisibility(visibilityPredicate) {
|
|
1217
|
-
// Safety check - ensure steps array exists and is properly initialized
|
|
1218
|
-
if (!this.steps || !Array.isArray(this.steps)) {
|
|
1219
|
-
return;
|
|
1220
|
-
}
|
|
1221
|
-
|
|
1222
|
-
this.steps.forEach(step => {
|
|
1223
|
-
if (!step) return; // Skip null/undefined steps
|
|
1224
|
-
|
|
1225
|
-
if (visibilityPredicate(step)) {
|
|
1226
|
-
step.visible = true;
|
|
1227
|
-
if (step.svgObject) step.svgObject.style.display = '';
|
|
1228
|
-
} else {
|
|
1229
|
-
step.visible = false;
|
|
1230
|
-
if (step.svgObject) step.svgObject.style.display = 'none';
|
|
1231
|
-
}
|
|
1232
|
-
|
|
1233
|
-
// Apply font weight based on stepMark
|
|
1234
|
-
const weight = getFontWeight(step.stepMark ?? 0);
|
|
1235
|
-
if (step.svgObject) {
|
|
1236
|
-
step.svgObject.style.fontWeight = weight.toString();
|
|
1237
|
-
}
|
|
1238
|
-
});
|
|
1239
|
-
|
|
1240
|
-
if (this.layoutManager) {
|
|
1241
|
-
this.layoutManager.updateVisualVisibility();
|
|
1242
|
-
}
|
|
1243
|
-
|
|
1244
|
-
this.computeDimensions();
|
|
1245
|
-
this.updateLayout();
|
|
1246
|
-
}
|
|
1
|
+
import { omdNode } from "./omdNode.js";
|
|
2
|
+
import { simplifyStep } from "../simplification/omdSimplification.js";
|
|
3
|
+
import { omdEquationNode } from "./omdEquationNode.js";
|
|
4
|
+
import { getNodeForAST } from "../core/omdUtilities.js";
|
|
5
|
+
import { omdMetaExpression } from "../../src/omdMetaExpression.js";
|
|
6
|
+
import { omdOperationDisplayNode } from "./omdOperationDisplayNode.js";
|
|
7
|
+
import { getFontWeight } from "../config/omdConfigManager.js";
|
|
8
|
+
import { jsvgLayoutGroup } from '@teachinglab/jsvg';
|
|
9
|
+
/**
|
|
10
|
+
* Represents a sequence of equations for a step-by-step calculation.
|
|
11
|
+
* This node manages the layout of multiple equations, ensuring their
|
|
12
|
+
* equals signs are vertically aligned for readability.
|
|
13
|
+
* @extends omdNode
|
|
14
|
+
*/
|
|
15
|
+
export class omdEquationSequenceNode extends omdNode {
|
|
16
|
+
static OPERATION_MAP = {
|
|
17
|
+
'add': 'addToBothSides',
|
|
18
|
+
'subtract': 'subtractFromBothSides',
|
|
19
|
+
'multiply': 'multiplyBothSides',
|
|
20
|
+
'divide': 'divideBothSides',
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Sets the filter level for visible steps in the sequence.
|
|
25
|
+
* @param {number} level - The stepMark level to show (e.g., 0 for major steps)
|
|
26
|
+
*/
|
|
27
|
+
setFilterLevel(level = 0) {
|
|
28
|
+
this.currentFilterLevels = [level];
|
|
29
|
+
this.updateStepsVisibility(step => (step.stepMark ?? 0) === level);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Sets multiple filter levels for visible steps in the sequence.
|
|
34
|
+
* @param {number[]} levels - Array of stepMark levels to show (e.g., [0, 1] for major and intermediate steps)
|
|
35
|
+
*/
|
|
36
|
+
setFilterLevels(levels = [0]) {
|
|
37
|
+
this.currentFilterLevels = [...levels]; // Store a copy of the levels
|
|
38
|
+
this.updateStepsVisibility(step => {
|
|
39
|
+
const stepLevel = step.stepMark ?? 0;
|
|
40
|
+
return levels.includes(stepLevel);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Reapplies the current filter levels
|
|
46
|
+
* @private
|
|
47
|
+
*/
|
|
48
|
+
_reapplyCurrentFilter() {
|
|
49
|
+
if (this.currentFilterLevels && this.currentFilterLevels.length > 0) {
|
|
50
|
+
this.updateStepsVisibility(step => {
|
|
51
|
+
const stepLevel = step.stepMark ?? 0;
|
|
52
|
+
return this.currentFilterLevels.includes(stepLevel);
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Gets the current filter level (always returns 0 since we default to level 0)
|
|
59
|
+
* @returns {number} The current filter level
|
|
60
|
+
*/
|
|
61
|
+
getFilterLevel() {
|
|
62
|
+
return 0; // Always return 0 since that's our default
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Creates a calculation node from an array of equation nodes.
|
|
67
|
+
* @param {Array<omdEquationNode>} steps - An array of omdEquationNode objects.
|
|
68
|
+
*/
|
|
69
|
+
constructor(steps) {
|
|
70
|
+
super({}); // No specific AST for the container itself
|
|
71
|
+
this.type = "omdEquationSequenceNode";
|
|
72
|
+
this.steps = steps;
|
|
73
|
+
this.argumentNodeList.steps = this.steps;
|
|
74
|
+
this.steps.forEach(step => this.addChild(step));
|
|
75
|
+
|
|
76
|
+
this._initializeState();
|
|
77
|
+
this._initializeLayout();
|
|
78
|
+
this._initializeNodeMap();
|
|
79
|
+
this._disableContainerInteractions();
|
|
80
|
+
|
|
81
|
+
this._markInitialSteps();
|
|
82
|
+
|
|
83
|
+
// Apply default filter to show only level 0 steps by default
|
|
84
|
+
this._applyDefaultFilter();
|
|
85
|
+
|
|
86
|
+
// Default background style for new equation steps (optional)
|
|
87
|
+
this.defaultEquationBackground = null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* @private
|
|
92
|
+
*/
|
|
93
|
+
_initializeState() {
|
|
94
|
+
this.currentStepIndex = 0;
|
|
95
|
+
this.stepDescriptions = [];
|
|
96
|
+
this.importanceLevels = [];
|
|
97
|
+
this.simplificationHistory = [];
|
|
98
|
+
this.currentFilterLevels = [0]; // Track current filter state, default to level 0 only
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* @private
|
|
103
|
+
*/
|
|
104
|
+
_initializeLayout() {
|
|
105
|
+
this.hideBackgroundByDefault();
|
|
106
|
+
this.layoutHelper = new jsvgLayoutGroup();
|
|
107
|
+
this.layoutHelper.setSpacer(15);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* @private
|
|
112
|
+
*/
|
|
113
|
+
_initializeNodeMap() {
|
|
114
|
+
this.nodeMap = new Map();
|
|
115
|
+
this.rebuildNodeMap();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* @private
|
|
120
|
+
*/
|
|
121
|
+
_disableContainerInteractions() {
|
|
122
|
+
this.svgObject.onmouseenter = null;
|
|
123
|
+
this.svgObject.onmouseleave = null;
|
|
124
|
+
this.svgObject.style.cursor = "default";
|
|
125
|
+
this.svgObject.onclick = null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Marks initial steps in the sequence
|
|
130
|
+
* @private
|
|
131
|
+
*/
|
|
132
|
+
_markInitialSteps() {
|
|
133
|
+
if (!this.steps || !Array.isArray(this.steps)) return;
|
|
134
|
+
|
|
135
|
+
this.steps.forEach((step, index) => {
|
|
136
|
+
if (step instanceof omdEquationNode) {
|
|
137
|
+
// Mark property for filtering
|
|
138
|
+
step.stepMark = 0;
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
// Don't apply filtering here - let it happen naturally when needed
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Applies the default filter (level 0) automatically
|
|
146
|
+
* @private
|
|
147
|
+
*/
|
|
148
|
+
_applyDefaultFilter() {
|
|
149
|
+
// Only apply filter if we have steps and the steps array is properly initialized
|
|
150
|
+
if (this.steps && Array.isArray(this.steps) && this.steps.length > 0) {
|
|
151
|
+
this.updateStepsVisibility(step => (step.stepMark ?? 0) === 0);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Gets the last equation in the sequence (the current working equation)
|
|
157
|
+
* @returns {omdEquationNode|null} The last equation, or null if no equations exist
|
|
158
|
+
*/
|
|
159
|
+
getCurrentEquation() {
|
|
160
|
+
if (!this.steps || this.steps.length === 0) return null;
|
|
161
|
+
|
|
162
|
+
// Find the last equation in the sequence
|
|
163
|
+
for (let i = this.steps.length - 1; i >= 0; i--) {
|
|
164
|
+
if (this.steps[i] instanceof omdEquationNode) {
|
|
165
|
+
return this.steps[i];
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Adds a new step to the sequence.
|
|
173
|
+
* Can be called with multiple signatures:
|
|
174
|
+
* - addStep(omdNode, optionsObject)
|
|
175
|
+
* - addStep(omdNode, description, importance)
|
|
176
|
+
* - addStep(string, ...)
|
|
177
|
+
* @param {omdNode|string} step - The node object or expression string for the step.
|
|
178
|
+
* @param {Object|string} [descriptionOrOptions] - An options object or a description string.
|
|
179
|
+
* @param {number} [importance] - The importance level (0, 1, 2) if using string description.
|
|
180
|
+
* @returns {number} The index of the added step.
|
|
181
|
+
*/
|
|
182
|
+
addStep(step, descriptionOrOptions, importance) {
|
|
183
|
+
let options = {};
|
|
184
|
+
if (typeof descriptionOrOptions === 'string') {
|
|
185
|
+
options = { description: descriptionOrOptions, stepMark: importance ?? 2 };
|
|
186
|
+
} else if (descriptionOrOptions) {
|
|
187
|
+
options = descriptionOrOptions;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const stepNode = (typeof step === 'string') ? this._stringToNode(step) : step;
|
|
191
|
+
const stepIndex = this.steps.length;
|
|
192
|
+
|
|
193
|
+
// Store metadata
|
|
194
|
+
if (options.description !== undefined) {
|
|
195
|
+
this.stepDescriptions[stepIndex] = options.description;
|
|
196
|
+
}
|
|
197
|
+
if (options.stepMark !== undefined) {
|
|
198
|
+
this.importanceLevels[stepIndex] = options.stepMark;
|
|
199
|
+
} else {
|
|
200
|
+
this.importanceLevels[stepIndex] = 0;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Add node to sequence
|
|
204
|
+
// Apply default equation background styling before initialization so layout includes padding
|
|
205
|
+
if (this.defaultEquationBackground && typeof stepNode?.setBackgroundStyle === 'function') {
|
|
206
|
+
stepNode.setBackgroundStyle(this.defaultEquationBackground);
|
|
207
|
+
}
|
|
208
|
+
stepNode.setFontSize(this.getFontSize());
|
|
209
|
+
stepNode.initialize();
|
|
210
|
+
this.steps.push(stepNode);
|
|
211
|
+
this.addChild(stepNode);
|
|
212
|
+
this.argumentNodeList.steps = this.steps;
|
|
213
|
+
this.rebuildNodeMap();
|
|
214
|
+
|
|
215
|
+
// Persist stepMark on the node for filtering
|
|
216
|
+
if (stepNode instanceof omdEquationNode) {
|
|
217
|
+
stepNode.stepMark = options.stepMark ?? this._determineStepMark(stepNode, options);
|
|
218
|
+
} else if (options.stepMark !== undefined) {
|
|
219
|
+
stepNode.stepMark = options.stepMark;
|
|
220
|
+
} else {
|
|
221
|
+
stepNode.stepMark = 0;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Refresh layout and display
|
|
225
|
+
this.computeDimensions();
|
|
226
|
+
this.updateLayout();
|
|
227
|
+
if (window.refreshDisplayAndFilters) {
|
|
228
|
+
window.refreshDisplayAndFilters();
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Reapply current filter to maintain filter state
|
|
232
|
+
this._reapplyCurrentFilter();
|
|
233
|
+
|
|
234
|
+
return stepIndex;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Sets a default background style to be applied to all equation steps added thereafter.
|
|
239
|
+
* Also applies it to existing steps immediately.
|
|
240
|
+
* @param {{ backgroundColor?: string, cornerRadius?: number, pill?: boolean, padding?: number|{x:number,y:number} }} style
|
|
241
|
+
*/
|
|
242
|
+
setDefaultEquationBackground(style = null) {
|
|
243
|
+
this.defaultEquationBackground = style;
|
|
244
|
+
if (style) {
|
|
245
|
+
(this.steps || []).forEach(step => {
|
|
246
|
+
if (typeof step?.setBackgroundStyle === 'function') {
|
|
247
|
+
step.setBackgroundStyle(style);
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
this.computeDimensions();
|
|
251
|
+
this.updateLayout();
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Determines the appropriate step mark for a step
|
|
257
|
+
* @param {omdNode} step - The step being added
|
|
258
|
+
* @param {Object} options - Options passed to addStep
|
|
259
|
+
* @returns {number} The step mark (0, 1, or 2)
|
|
260
|
+
* @private
|
|
261
|
+
*/
|
|
262
|
+
_determineStepMark(step, options) {
|
|
263
|
+
// If this is called from applyEquationOperation, it's already handled there
|
|
264
|
+
// For other cases, we need to determine if it's a simplification step
|
|
265
|
+
if (options.isSimplification) {
|
|
266
|
+
return 2; // Verbose simplification step
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Check if this appears to be a final simplified result
|
|
270
|
+
if (this._isFullySimplified(step)) {
|
|
271
|
+
return 0; // Final result
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Default to verbose step
|
|
275
|
+
return 2;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Checks if an equation appears to be fully simplified
|
|
282
|
+
* @param {omdNode} step - The step to check
|
|
283
|
+
* @returns {boolean} Whether the step appears fully simplified
|
|
284
|
+
* @private
|
|
285
|
+
*/
|
|
286
|
+
_isFullySimplified(step) {
|
|
287
|
+
// This is a heuristic - in practice you might want more sophisticated logic
|
|
288
|
+
// For now, we'll consider it simplified if it doesn't contain complex nested operations
|
|
289
|
+
if (!(step instanceof omdEquationNode)) return false;
|
|
290
|
+
|
|
291
|
+
// Simple heuristic: check if both sides are relatively simple
|
|
292
|
+
const leftIsSimple = this._isSimpleExpression(step.left);
|
|
293
|
+
const rightIsSimple = this._isSimpleExpression(step.right);
|
|
294
|
+
|
|
295
|
+
return leftIsSimple && rightIsSimple;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Checks if an expression is simple (constant, variable, or simple operations)
|
|
300
|
+
* @param {omdNode} node - The node to check
|
|
301
|
+
* @returns {boolean} Whether the expression is simple
|
|
302
|
+
* @private
|
|
303
|
+
*/
|
|
304
|
+
_isSimpleExpression(node) {
|
|
305
|
+
// This is a simplified heuristic
|
|
306
|
+
if (node.isConstant() || node.type === 'omdVariableNode') {
|
|
307
|
+
return true;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Allow simple binary operations with constants/variables
|
|
311
|
+
if (node.type === 'omdBinaryExpressionNode') {
|
|
312
|
+
return this._isSimpleExpression(node.left) && this._isSimpleExpression(node.right);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return false;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Rebuilds the nodeMap to include ALL nodes from ALL steps in the sequence
|
|
320
|
+
* This is crucial for provenance tracking across multiple steps
|
|
321
|
+
*/
|
|
322
|
+
rebuildNodeMap() {
|
|
323
|
+
if (!this.nodeMap) {
|
|
324
|
+
this.nodeMap = new Map();
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Don't clear the map yet - first collect all current nodes
|
|
328
|
+
const newNodeMap = new Map();
|
|
329
|
+
|
|
330
|
+
// Add all nodes from all steps to the new nodeMap
|
|
331
|
+
this.steps.forEach((step, stepIndex) => {
|
|
332
|
+
const stepNodes = step.findAllNodes();
|
|
333
|
+
stepNodes.forEach(node => {
|
|
334
|
+
newNodeMap.set(node.id, node);
|
|
335
|
+
});
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
// Also add the sequence itself
|
|
339
|
+
newNodeMap.set(this.id, this);
|
|
340
|
+
|
|
341
|
+
// Now preserve historical nodes that are referenced in provenance chains
|
|
342
|
+
this.preserveProvenanceHistory(newNodeMap);
|
|
343
|
+
|
|
344
|
+
// Replace the old nodeMap with the new one
|
|
345
|
+
this.nodeMap = newNodeMap;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Preserves historical nodes that are referenced in provenance chains
|
|
350
|
+
* This ensures the highlighting system can find all nodes it needs
|
|
351
|
+
*/
|
|
352
|
+
preserveProvenanceHistory(newNodeMap) {
|
|
353
|
+
const referencedIds = this._collectAllProvenanceIds(newNodeMap);
|
|
354
|
+
this._preserveReferencedNodes(referencedIds, newNodeMap);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** @private */
|
|
358
|
+
_collectAllProvenanceIds(newNodeMap) {
|
|
359
|
+
const referencedIds = new Set();
|
|
360
|
+
const processedNodes = new Set();
|
|
361
|
+
newNodeMap.forEach(node => this._collectNodeProvenanceIds(node, referencedIds, processedNodes));
|
|
362
|
+
return referencedIds;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** @private */
|
|
366
|
+
_collectNodeProvenanceIds(node, referencedIds, processedNodes) {
|
|
367
|
+
if (!node || !node.id || processedNodes.has(node.id)) return;
|
|
368
|
+
processedNodes.add(node.id);
|
|
369
|
+
|
|
370
|
+
if (node.provenance?.length > 0) {
|
|
371
|
+
node.provenance.forEach(id => referencedIds.add(id));
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (node.argumentNodeList) {
|
|
375
|
+
Object.values(node.argumentNodeList).flat().forEach(child => {
|
|
376
|
+
this._collectNodeProvenanceIds(child, referencedIds, processedNodes);
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** @private */
|
|
382
|
+
_preserveReferencedNodes(referencedIds, newNodeMap) {
|
|
383
|
+
const processedIds = new Set();
|
|
384
|
+
referencedIds.forEach(id => this._preserveNodeAndContext(id, newNodeMap, processedIds));
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** @private */
|
|
388
|
+
_preserveNodeAndContext(id, newNodeMap, processedIds) {
|
|
389
|
+
if (processedIds.has(id) || newNodeMap.has(id) || !this.nodeMap?.has(id)) {
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
processedIds.add(id);
|
|
393
|
+
|
|
394
|
+
const historicalNode = this.nodeMap.get(id);
|
|
395
|
+
newNodeMap.set(id, historicalNode);
|
|
396
|
+
|
|
397
|
+
// Preserve this node's own provenance chain
|
|
398
|
+
if (historicalNode.provenance?.length > 0) {
|
|
399
|
+
historicalNode.provenance.forEach(nestedId => {
|
|
400
|
+
this._preserveNodeAndContext(nestedId, newNodeMap, processedIds);
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Preserve parent and sibling context
|
|
405
|
+
this._preserveParentContext(historicalNode, newNodeMap);
|
|
406
|
+
this._preserveSiblingContext(historicalNode, newNodeMap);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** @private */
|
|
410
|
+
_preserveParentContext(node, newNodeMap) {
|
|
411
|
+
let parent = node.parent;
|
|
412
|
+
while (parent && parent.id) {
|
|
413
|
+
if (!newNodeMap.has(parent.id) && this.nodeMap.has(parent.id)) {
|
|
414
|
+
newNodeMap.set(parent.id, this.nodeMap.get(parent.id));
|
|
415
|
+
}
|
|
416
|
+
parent = parent.parent;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** @private */
|
|
421
|
+
_preserveSiblingContext(node, newNodeMap) {
|
|
422
|
+
if (!node.parent?.argumentNodeList) return;
|
|
423
|
+
|
|
424
|
+
Object.values(node.parent.argumentNodeList).flat().forEach(sibling => {
|
|
425
|
+
if (sibling && sibling.id && !newNodeMap.has(sibling.id) && this.nodeMap.has(sibling.id)) {
|
|
426
|
+
newNodeMap.set(sibling.id, this.nodeMap.get(sibling.id));
|
|
427
|
+
}
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Records a simplification step in the history
|
|
433
|
+
* @param {string} name - The name of the simplification rule that was applied
|
|
434
|
+
* @param {Array<string>} affectedNodes - Array of node IDs that were affected by the simplification
|
|
435
|
+
* @param {string} message - Human-readable description of what was simplified
|
|
436
|
+
* @param {Object} [metadata={}] - Additional metadata about the simplification
|
|
437
|
+
*/
|
|
438
|
+
recordSimplificationHistory(name, affectedNodes, message, metadata = {}) {
|
|
439
|
+
const historyEntry = {
|
|
440
|
+
name,
|
|
441
|
+
affectedNodes: [...affectedNodes], // Create a copy of the array
|
|
442
|
+
message,
|
|
443
|
+
stepNumber: this.steps.length,
|
|
444
|
+
...metadata
|
|
445
|
+
};
|
|
446
|
+
|
|
447
|
+
this.simplificationHistory.push(historyEntry);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Gets the complete simplification history for this sequence
|
|
452
|
+
* @returns {Array<Object>} Array of simplification history entries
|
|
453
|
+
*/
|
|
454
|
+
getSimplificationHistory() {
|
|
455
|
+
return [...this.simplificationHistory]; // Return a copy
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Clears the simplification history
|
|
460
|
+
*/
|
|
461
|
+
clearSimplificationHistory() {
|
|
462
|
+
this.simplificationHistory = [];
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Override setFontSize to propagate to all steps
|
|
467
|
+
* @param {number} fontSize - The new font size
|
|
468
|
+
*/
|
|
469
|
+
setFontSize(fontSize) {
|
|
470
|
+
super.setFontSize(fontSize);
|
|
471
|
+
|
|
472
|
+
// Propagate the font size to all existing steps
|
|
473
|
+
this.steps.forEach(step => {
|
|
474
|
+
step.setFontSize(fontSize);
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
// Recompute dimensions and layout with the new font size
|
|
478
|
+
this.computeDimensions();
|
|
479
|
+
this.updateLayout();
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Convenience helper: recompute dimensions, update layout, and optionally render via a renderer.
|
|
484
|
+
* Use this instead of calling computeDimensions/updateLayout everywhere.
|
|
485
|
+
* @param {object} [renderer] - Optional renderer (e.g., an omdDisplay instance) to re-render the sequence
|
|
486
|
+
*/
|
|
487
|
+
refresh(renderer, center=true) {
|
|
488
|
+
this.computeDimensions();
|
|
489
|
+
this.updateLayout();
|
|
490
|
+
renderer.render(this);
|
|
491
|
+
|
|
492
|
+
if (center) {
|
|
493
|
+
renderer.centerNode();
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Applies a specified operation to the current equation in the sequence and adds the result as a new step.
|
|
499
|
+
* @param {number|string} value - The constant value or expression string to apply.
|
|
500
|
+
* @param {string} operation - The operation name ('add', 'subtract', 'multiply', 'divide').
|
|
501
|
+
* @returns {omdEquationSequenceNode} Returns this sequence for chaining.
|
|
502
|
+
*/
|
|
503
|
+
applyEquationOperation(value, operation) {
|
|
504
|
+
if (!omdEquationSequenceNode.OPERATION_MAP[operation]) {
|
|
505
|
+
console.error(`Invalid operation: ${operation}`);
|
|
506
|
+
return this;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const currentEquation = this.getCurrentEquation();
|
|
510
|
+
if (!currentEquation) {
|
|
511
|
+
console.error("No equation to apply operation to.");
|
|
512
|
+
return this;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
let operationValue = value;
|
|
516
|
+
if (typeof value === 'string') {
|
|
517
|
+
if (!window.math) throw new Error("Math.js is required for parsing expressions");
|
|
518
|
+
operationValue = isNaN(value) ? window.math.parse(value) : parseFloat(value);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// Step 1: Add visual operation display
|
|
522
|
+
const operationDisplay = new omdOperationDisplayNode(operation, value);
|
|
523
|
+
this.addStep(operationDisplay, { stepMark: 0 });
|
|
524
|
+
|
|
525
|
+
// Step 2: Apply operation to a clone of the equation
|
|
526
|
+
const clonedEquation = currentEquation.clone();
|
|
527
|
+
const equationMethod = omdEquationSequenceNode.OPERATION_MAP[operation];
|
|
528
|
+
const unsimplifiedEquation = clonedEquation[equationMethod](operationValue, operationDisplay.id);
|
|
529
|
+
|
|
530
|
+
// Step 3: Check simplification potential and add the new equation step
|
|
531
|
+
const testClone = unsimplifiedEquation.clone();
|
|
532
|
+
const { foldedCount } = simplifyStep(testClone);
|
|
533
|
+
const isSimplified = foldedCount === 0;
|
|
534
|
+
|
|
535
|
+
this.addStep(unsimplifiedEquation, {
|
|
536
|
+
stepMark: isSimplified ? 0 : 1,
|
|
537
|
+
description: this._getOperationDescription(operation, value, !isSimplified)
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
return this;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Generates a description for an equation operation.
|
|
545
|
+
* @param {string} operation - The operation name.
|
|
546
|
+
* @param {number|string} value - The value used in the operation.
|
|
547
|
+
* @param {boolean} isUnsimplified - Whether the result is unsimplified.
|
|
548
|
+
* @returns {string} The formatted description.
|
|
549
|
+
* @private
|
|
550
|
+
*/
|
|
551
|
+
_getOperationDescription(operation, value, isUnsimplified) {
|
|
552
|
+
const templates = {
|
|
553
|
+
'add': `Added ${value} to both sides`,
|
|
554
|
+
'subtract': `Subtracted ${value} from both sides`,
|
|
555
|
+
'multiply': `Multiplied both sides by ${value}`,
|
|
556
|
+
'divide': `Divided both sides by ${value}`
|
|
557
|
+
};
|
|
558
|
+
const baseDescription = templates[operation] || `Applied ${operation} with ${value}`;
|
|
559
|
+
return isUnsimplified ? `${baseDescription} (unsimplified)` : baseDescription;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Applies a function to both sides of the current equation in the sequence and adds the result as a new step.
|
|
564
|
+
* @param {string} functionName - The name of the function to apply.
|
|
565
|
+
* @returns {omdEquationSequenceNode} Returns this sequence for chaining.
|
|
566
|
+
*/
|
|
567
|
+
applyEquationFunction(functionName) {
|
|
568
|
+
const currentEquation = this.getCurrentEquation();
|
|
569
|
+
if (!currentEquation) {
|
|
570
|
+
throw new Error("No equation found in sequence to operate on");
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// Clone the current equation
|
|
574
|
+
const clonedEquation = currentEquation.clone();
|
|
575
|
+
|
|
576
|
+
// Apply the function to the clone
|
|
577
|
+
const newEquation = clonedEquation.applyFunction(functionName);
|
|
578
|
+
|
|
579
|
+
// Check if any simplifications are possible on this new step
|
|
580
|
+
const testClone = newEquation.clone();
|
|
581
|
+
const { foldedCount } = simplifyStep(testClone);
|
|
582
|
+
|
|
583
|
+
// Determine the appropriate step mark based on simplification potential
|
|
584
|
+
const stepMark = foldedCount === 0 ? 0 : 1;
|
|
585
|
+
const description = `Applied ${functionName} to both sides`;
|
|
586
|
+
|
|
587
|
+
this.addStep(newEquation, {
|
|
588
|
+
stepMark: stepMark,
|
|
589
|
+
description: description
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
return this;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* Simplifies the current step in the sequence by applying one round of simplification rules
|
|
598
|
+
* @returns {Object} Result object containing:
|
|
599
|
+
* @returns {boolean} result.success - Whether any simplification was applied
|
|
600
|
+
* @returns {number} result.foldedCount - Number of simplification operations applied (0 if none)
|
|
601
|
+
* @returns {boolean} result.isFinalSimplification - Whether this represents the final simplified form
|
|
602
|
+
* @returns {string} result.message - Human-readable description of the result
|
|
603
|
+
*/
|
|
604
|
+
simplify() {
|
|
605
|
+
const currentStep = this.steps[this.steps.length - 1];
|
|
606
|
+
if (!currentStep) {
|
|
607
|
+
return { success: false, message: 'No expression found to simplify' };
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
try {
|
|
611
|
+
const stepToSimplify = currentStep.clone();
|
|
612
|
+
const simplificationResult = simplifyStep(stepToSimplify);
|
|
613
|
+
|
|
614
|
+
if (simplificationResult.foldedCount > 0) {
|
|
615
|
+
return this._handleSuccessfulSimplification(currentStep, simplificationResult);
|
|
616
|
+
} else {
|
|
617
|
+
return { success: false, foldedCount: 0, message: 'No simplifications available' };
|
|
618
|
+
}
|
|
619
|
+
} catch (error) {
|
|
620
|
+
console.error(`Error during simplification:`, error);
|
|
621
|
+
return { success: false, message: `Simplification error: ${error.message}` };
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/** @private */
|
|
626
|
+
_handleSuccessfulSimplification(originalStep, { newRoot, foldedCount, historyEntry }) {
|
|
627
|
+
if (historyEntry) {
|
|
628
|
+
historyEntry.stepNumber = this.steps.length - 1;
|
|
629
|
+
historyEntry.originalStep = originalStep.toString();
|
|
630
|
+
this.simplificationHistory.push(historyEntry);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
const testClone = newRoot.clone();
|
|
634
|
+
const { foldedCount: moreFolds } = simplifyStep(testClone);
|
|
635
|
+
const isFinal = moreFolds === 0;
|
|
636
|
+
|
|
637
|
+
const description = isFinal
|
|
638
|
+
? `Fully simplified result (${foldedCount} operation${foldedCount > 1 ? 's' : ''} applied)`
|
|
639
|
+
: `Simplification step (${foldedCount} operation${foldedCount > 1 ? 's' : ''} applied)`;
|
|
640
|
+
|
|
641
|
+
this.addStep(newRoot, {
|
|
642
|
+
stepMark: isFinal ? 0 : 2,
|
|
643
|
+
description: description,
|
|
644
|
+
isSimplification: true,
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
const message = isFinal
|
|
648
|
+
? `Fully simplified! Applied ${foldedCount} simplification step(s).`
|
|
649
|
+
: `Simplified! Applied ${foldedCount} simplification step(s), more are available.`;
|
|
650
|
+
|
|
651
|
+
return { success: true, foldedCount, isFinalSimplification: isFinal, message };
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* Simplifies all possible expressions until no more simplifications can be applied
|
|
656
|
+
* Repeatedly calls simplify() until no further simplifications are possible
|
|
657
|
+
* @param {number} [maxIterations=50] - Maximum number of iterations to prevent infinite loops
|
|
658
|
+
* @returns {Object} Result object containing:
|
|
659
|
+
* @returns {boolean} result.success - Whether the operation completed successfully (false if stopped due to max iterations)
|
|
660
|
+
* @returns {number} result.totalSteps - Number of simplification steps that were added to the sequence
|
|
661
|
+
* @returns {number} result.iterations - Number of simplify() calls made during the process
|
|
662
|
+
* @returns {string} result.message - Human-readable description of the final result
|
|
663
|
+
*/
|
|
664
|
+
simplifyAll(maxIterations = 50) {
|
|
665
|
+
let iteration = 0;
|
|
666
|
+
let stepsBefore;
|
|
667
|
+
let totalSteps = 0;
|
|
668
|
+
|
|
669
|
+
do {
|
|
670
|
+
stepsBefore = this.steps.length;
|
|
671
|
+
const result = this.simplify();
|
|
672
|
+
|
|
673
|
+
if (result.success) {
|
|
674
|
+
totalSteps++;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
iteration++;
|
|
678
|
+
} while (this.steps.length > stepsBefore && iteration < maxIterations);
|
|
679
|
+
|
|
680
|
+
if (iteration >= maxIterations) {
|
|
681
|
+
return {
|
|
682
|
+
success: false,
|
|
683
|
+
totalSteps,
|
|
684
|
+
iterations: iteration,
|
|
685
|
+
message: `Stopped after ${maxIterations} iterations to avoid an infinite loop.`
|
|
686
|
+
};
|
|
687
|
+
} else {
|
|
688
|
+
return {
|
|
689
|
+
success: true,
|
|
690
|
+
totalSteps,
|
|
691
|
+
iterations: iteration,
|
|
692
|
+
message: `All possible simplifications completed. Added ${totalSteps} simplification steps.`
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* Evaluates the current step in the sequence with the given variables.
|
|
699
|
+
* Logs the result to the console.
|
|
700
|
+
* @param {Object} variables - A map of variable names to their numeric values.
|
|
701
|
+
*/
|
|
702
|
+
evaluate(variables = {}) {
|
|
703
|
+
const targetNode = this.getCurrentStep();
|
|
704
|
+
if (!targetNode || typeof targetNode.evaluate !== 'function') {
|
|
705
|
+
console.warn("Evaluation not supported for the current step.");
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
try {
|
|
710
|
+
const result = targetNode.evaluate(variables);
|
|
711
|
+
|
|
712
|
+
if (typeof result === 'object' && result.left !== undefined && result.right !== undefined) {
|
|
713
|
+
const { left, right } = result;
|
|
714
|
+
const isEqual = Math.abs(left - right) < 1e-9;
|
|
715
|
+
|
|
716
|
+
} else {
|
|
717
|
+
|
|
718
|
+
}
|
|
719
|
+
} catch (error) {
|
|
720
|
+
console.error("Evaluation failed:", error.message);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/**
|
|
725
|
+
* Validates the provenance integrity across all steps in the sequence
|
|
726
|
+
* @returns {Array} Array of validation issues found
|
|
727
|
+
*/
|
|
728
|
+
validateSequenceProvenance() {
|
|
729
|
+
const issues = [];
|
|
730
|
+
this._validateStepsProvenance(issues);
|
|
731
|
+
this._findOrphanedNodes(issues);
|
|
732
|
+
return issues;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/** @private */
|
|
736
|
+
_validateStepsProvenance(issues) {
|
|
737
|
+
this.steps.forEach((step, index) => {
|
|
738
|
+
const stepIssues = step.validateProvenance(this.nodeMap);
|
|
739
|
+
stepIssues.forEach(issue => issues.push({ ...issue, stepIndex: index }));
|
|
740
|
+
});
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/** @private */
|
|
744
|
+
_findOrphanedNodes(issues) {
|
|
745
|
+
const currentNodeIds = new Set(this.steps.flatMap(step => step.findAllNodes().map(n => n.id)));
|
|
746
|
+
const allProvenanceIds = this._collectAllProvenanceIds(this.nodeMap);
|
|
747
|
+
|
|
748
|
+
this.nodeMap.forEach((node, id) => {
|
|
749
|
+
if (!currentNodeIds.has(id) && !allProvenanceIds.has(id)) {
|
|
750
|
+
issues.push({
|
|
751
|
+
type: 'orphaned_node',
|
|
752
|
+
nodeId: id,
|
|
753
|
+
nodeType: node.type,
|
|
754
|
+
});
|
|
755
|
+
}
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* Overrides the default select behavior to prevent the container from highlighting.
|
|
761
|
+
* This container should be inert and not react to selection events.
|
|
762
|
+
*/
|
|
763
|
+
select() {
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
/**
|
|
767
|
+
* Overrides the default deselect behavior to prevent the container from highlighting.
|
|
768
|
+
*/
|
|
769
|
+
deselect() {
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* Override highlight to prevent the sequence container itself from highlighting
|
|
774
|
+
* but still allow children to be highlighted
|
|
775
|
+
*/
|
|
776
|
+
highlight(color) {
|
|
777
|
+
// Don't highlight the sequence container itself
|
|
778
|
+
// Just propagate to children (but not the backRect)
|
|
779
|
+
this.childList.forEach((child) => {
|
|
780
|
+
if (child instanceof omdMetaExpression && child !== this.backRect) {
|
|
781
|
+
child.highlight(color);
|
|
782
|
+
}
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* Override clearProvenanceHighlights to work with the sequence
|
|
788
|
+
*/
|
|
789
|
+
clearProvenanceHighlights() {
|
|
790
|
+
// Don't change the sequence container's background
|
|
791
|
+
// Just clear highlights from children
|
|
792
|
+
this.childList.forEach((child) => {
|
|
793
|
+
if (child instanceof omdMetaExpression && typeof child.clearProvenanceHighlights === 'function' && child !== this.backRect) {
|
|
794
|
+
child.clearProvenanceHighlights();
|
|
795
|
+
}
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/**
|
|
800
|
+
* Calculates the dimensions of the entire calculation block.
|
|
801
|
+
* It determines the correct alignment for all equals signs and calculates
|
|
802
|
+
* the total width and height required.
|
|
803
|
+
* @override
|
|
804
|
+
*/
|
|
805
|
+
computeDimensions() {
|
|
806
|
+
const visibleSteps = this.steps.filter(s => s.visible !== false);
|
|
807
|
+
if (visibleSteps.length === 0) {
|
|
808
|
+
this.setWidthAndHeight(0, 0);
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
visibleSteps.forEach(step => step.computeDimensions());
|
|
813
|
+
|
|
814
|
+
this.alignPointX = this._calculateAlignmentPoint(visibleSteps);
|
|
815
|
+
|
|
816
|
+
const { maxWidth, totalHeight } = this._calculateTotalDimensions(visibleSteps);
|
|
817
|
+
this.setWidthAndHeight(maxWidth, totalHeight);
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/** @private */
|
|
821
|
+
_calculateAlignmentPoint(visibleSteps) {
|
|
822
|
+
const equalsCenters = [];
|
|
823
|
+
visibleSteps.forEach(step => {
|
|
824
|
+
if (step instanceof omdEquationNode) {
|
|
825
|
+
if (typeof step.getEqualsAnchorX === 'function') {
|
|
826
|
+
equalsCenters.push(step.getEqualsAnchorX());
|
|
827
|
+
} else if (step.equalsSign && step.left) {
|
|
828
|
+
const spacing = 8 * step.getFontSize() / step.getRootFontSize();
|
|
829
|
+
equalsCenters.push(step.left.width + spacing + (step.equalsSign.width / 2));
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
});
|
|
833
|
+
return equalsCenters.length > 0 ? Math.max(...equalsCenters) : 0;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/** @private */
|
|
837
|
+
_calculateTotalDimensions(visibleSteps) {
|
|
838
|
+
let maxWidth = 0;
|
|
839
|
+
let totalHeight = 0;
|
|
840
|
+
const verticalPadding = 15 * this.getFontSize() / this.getRootFontSize();
|
|
841
|
+
|
|
842
|
+
visibleSteps.forEach((step, index) => {
|
|
843
|
+
let stepWidth = 0;
|
|
844
|
+
if (step instanceof omdEquationNode) {
|
|
845
|
+
stepWidth = this.alignPointX + step.equalsSign.width + step.right.width;
|
|
846
|
+
} else {
|
|
847
|
+
stepWidth = step.width;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
maxWidth = Math.max(maxWidth, stepWidth);
|
|
851
|
+
totalHeight += step.height;
|
|
852
|
+
if (index < visibleSteps.length - 1) {
|
|
853
|
+
totalHeight += verticalPadding;
|
|
854
|
+
}
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
return { maxWidth, totalHeight };
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
/**
|
|
861
|
+
* Computes the horizontal offset needed to align a step with the master equals anchor.
|
|
862
|
+
* Equations align their equals sign center to alignPointX; operation displays align their
|
|
863
|
+
* virtual equals (middle of the gap); other steps are centered within the sequence width.
|
|
864
|
+
* @param {omdNode} step
|
|
865
|
+
* @returns {number} x offset in local coordinates
|
|
866
|
+
* @private
|
|
867
|
+
*/
|
|
868
|
+
_computeStepXOffset(step) {
|
|
869
|
+
if (step instanceof omdEquationNode) {
|
|
870
|
+
const equalsAnchorX = (typeof step.getEqualsAnchorX === 'function') ? step.getEqualsAnchorX() : step.left.width;
|
|
871
|
+
return this.alignPointX - equalsAnchorX;
|
|
872
|
+
}
|
|
873
|
+
if (step instanceof omdOperationDisplayNode) {
|
|
874
|
+
const leftWidth = (typeof step.getLeftWidthForAlignment === 'function')
|
|
875
|
+
? step.getLeftWidthForAlignment()
|
|
876
|
+
: step.width / 2;
|
|
877
|
+
const halfGap = (typeof step.gap === 'number' ? step.gap : 0) / 2;
|
|
878
|
+
return this.alignPointX - (leftWidth + halfGap);
|
|
879
|
+
}
|
|
880
|
+
return (this.width - step.width) / 2;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
/**
|
|
884
|
+
* Updates the layout of the calculation block.
|
|
885
|
+
* This method positions each equation vertically and aligns their
|
|
886
|
+
* equals signs to the calculated alignment point.
|
|
887
|
+
* @override
|
|
888
|
+
*/
|
|
889
|
+
updateLayout() {
|
|
890
|
+
const verticalPadding = 15 * this.getFontSize() / this.getRootFontSize();
|
|
891
|
+
const visibleSteps = this.steps.filter(s => s.visible !== false);
|
|
892
|
+
|
|
893
|
+
visibleSteps.forEach(step => step.updateLayout());
|
|
894
|
+
|
|
895
|
+
this.alignPointX = this._calculateAlignmentPoint(visibleSteps);
|
|
896
|
+
|
|
897
|
+
let yCurrent = 0;
|
|
898
|
+
visibleSteps.forEach((step, index) => {
|
|
899
|
+
const xOffset = this._computeStepXOffset(step);
|
|
900
|
+
step.setPosition(xOffset, yCurrent);
|
|
901
|
+
|
|
902
|
+
yCurrent += step.height;
|
|
903
|
+
if (index < visibleSteps.length - 1) yCurrent += verticalPadding;
|
|
904
|
+
});
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/**
|
|
908
|
+
* Creates an omdEquationSequenceNode instance from an array of strings.
|
|
909
|
+
* @param {Array<string>} stepStrings - An array of strings, each representing a calculation step.
|
|
910
|
+
* @returns {omdEquationSequenceNode} A new instance of omdEquationSequenceNode.
|
|
911
|
+
*/
|
|
912
|
+
static fromStringArray(stepStrings) {
|
|
913
|
+
const stepNodes = stepStrings.map(str => {
|
|
914
|
+
const trimmedStr = str.trim();
|
|
915
|
+
// If the string contains an equals sign, parse it as a full equation.
|
|
916
|
+
if (trimmedStr.includes('=')) {
|
|
917
|
+
return omdEquationNode.fromString(trimmedStr);
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
// If it doesn't contain an equals sign, it's not a valid equation step for a sequence.
|
|
921
|
+
throw new Error(`Step string "${trimmedStr}" is not a valid equation for omdEquationSequenceNode.`);
|
|
922
|
+
});
|
|
923
|
+
return new omdEquationSequenceNode(stepNodes);
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
clone() {
|
|
927
|
+
const clonedSteps = this.steps.map(step => step.clone());
|
|
928
|
+
const clone = new omdEquationSequenceNode(clonedSteps);
|
|
929
|
+
|
|
930
|
+
// The crucial step: link the clone to its origin (following the pattern from omdNode)
|
|
931
|
+
clone.provenance.push(this.id);
|
|
932
|
+
|
|
933
|
+
// The clone gets a fresh nodeMap, as its history is self-contained
|
|
934
|
+
clone.nodeMap = new Map();
|
|
935
|
+
clone.findAllNodes().forEach(node => clone.nodeMap.set(node.id, node));
|
|
936
|
+
return clone;
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
/**
|
|
940
|
+
* Converts the omdEquationSequenceNode to a math.js AST node.
|
|
941
|
+
* Since sequences are containers, we return a custom representation.
|
|
942
|
+
* @returns {Object} A custom AST node representing the sequence.
|
|
943
|
+
*/
|
|
944
|
+
toMathJSNode() {
|
|
945
|
+
const astNode = this.steps[this.steps.length-1].toMathJSNode();
|
|
946
|
+
|
|
947
|
+
return astNode;
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
/**
|
|
951
|
+
* Get the current step node
|
|
952
|
+
* @returns {omdNode} The current step
|
|
953
|
+
*/
|
|
954
|
+
getCurrentStep() {
|
|
955
|
+
// No steps → no current step
|
|
956
|
+
if (!this.steps || this.steps.length === 0) return null;
|
|
957
|
+
|
|
958
|
+
// Prefer the bottom-most VISIBLE equation step, falling back gracefully
|
|
959
|
+
let chosenIndex = -1;
|
|
960
|
+
for (let i = this.steps.length - 1; i >= 0; i--) {
|
|
961
|
+
const step = this.steps[i];
|
|
962
|
+
if (!step) continue;
|
|
963
|
+
// If visibility is explicitly false, skip
|
|
964
|
+
if (step.visible === false) continue;
|
|
965
|
+
// Prefer equation nodes when present
|
|
966
|
+
if (step.constructor?.name === 'omdEquationNode') {
|
|
967
|
+
chosenIndex = i;
|
|
968
|
+
break;
|
|
969
|
+
}
|
|
970
|
+
// Remember last visible non-equation as a fallback if no equation exists
|
|
971
|
+
if (chosenIndex === -1) chosenIndex = i;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
if (chosenIndex === -1) {
|
|
975
|
+
// If everything is hidden or invalid, fall back to the last step
|
|
976
|
+
chosenIndex = this.steps.length - 1;
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
// Clamp and store
|
|
980
|
+
if (chosenIndex < 0) chosenIndex = 0;
|
|
981
|
+
if (chosenIndex >= this.steps.length) chosenIndex = this.steps.length - 1;
|
|
982
|
+
this.currentStepIndex = chosenIndex;
|
|
983
|
+
return this.steps[chosenIndex];
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
/**
|
|
987
|
+
* Navigate to a specific step
|
|
988
|
+
* @param {number} index - The step index to navigate to
|
|
989
|
+
* @returns {boolean} Whether navigation was successful
|
|
990
|
+
*/
|
|
991
|
+
navigateToStep(index) {
|
|
992
|
+
if (index < 0 || index >= this.steps.length) {
|
|
993
|
+
return false;
|
|
994
|
+
}
|
|
995
|
+
this.currentStepIndex = index;
|
|
996
|
+
|
|
997
|
+
// Trigger any UI updates if needed
|
|
998
|
+
if (window.refreshDisplayAndFilters) {
|
|
999
|
+
window.refreshDisplayAndFilters();
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
return true;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
/**
|
|
1006
|
+
* Navigate to the next step
|
|
1007
|
+
* @returns {boolean} Whether there was a next step
|
|
1008
|
+
*/
|
|
1009
|
+
nextStep() {
|
|
1010
|
+
if (this.currentStepIndex < this.steps.length - 1) {
|
|
1011
|
+
this.currentStepIndex++;
|
|
1012
|
+
|
|
1013
|
+
// Trigger any UI updates if needed
|
|
1014
|
+
if (window.refreshDisplayAndFilters) {
|
|
1015
|
+
window.refreshDisplayAndFilters();
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
return true;
|
|
1019
|
+
}
|
|
1020
|
+
return false;
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
/**
|
|
1024
|
+
* Navigate to the previous step
|
|
1025
|
+
* @returns {boolean} Whether there was a previous step
|
|
1026
|
+
*/
|
|
1027
|
+
previousStep() {
|
|
1028
|
+
if (this.currentStepIndex > 0) {
|
|
1029
|
+
this.currentStepIndex--;
|
|
1030
|
+
|
|
1031
|
+
// Trigger any UI updates if needed
|
|
1032
|
+
if (window.refreshDisplayAndFilters) {
|
|
1033
|
+
window.refreshDisplayAndFilters();
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
return true;
|
|
1037
|
+
}
|
|
1038
|
+
return false;
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
/**
|
|
1042
|
+
* Get steps filtered by importance level
|
|
1043
|
+
* @param {number} maxImportance - Maximum importance level to include (0, 1, or 2)
|
|
1044
|
+
* @returns {Object[]} Array of objects containing step, description, importance, and index
|
|
1045
|
+
*/
|
|
1046
|
+
getFilteredSteps(maxImportance) {
|
|
1047
|
+
const filteredSteps = [];
|
|
1048
|
+
|
|
1049
|
+
this.steps.forEach((step, index) => {
|
|
1050
|
+
const importance = this.importanceLevels[index] !== undefined ? this.importanceLevels[index] :
|
|
1051
|
+
(step.stepMark !== undefined ? step.stepMark : 0);
|
|
1052
|
+
|
|
1053
|
+
if (importance <= maxImportance) {
|
|
1054
|
+
filteredSteps.push({
|
|
1055
|
+
step: step,
|
|
1056
|
+
description: this.stepDescriptions[index] || '',
|
|
1057
|
+
importance: importance,
|
|
1058
|
+
index: index
|
|
1059
|
+
});
|
|
1060
|
+
}
|
|
1061
|
+
});
|
|
1062
|
+
|
|
1063
|
+
return filteredSteps;
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
/**
|
|
1067
|
+
* Renders only the current step
|
|
1068
|
+
* @returns {SVGElement} The current step's rendering
|
|
1069
|
+
*/
|
|
1070
|
+
renderCurrentStep() {
|
|
1071
|
+
const currentStep = this.getCurrentStep();
|
|
1072
|
+
if (!currentStep) {
|
|
1073
|
+
// Return empty SVG group if no current step
|
|
1074
|
+
const emptyGroup = new jsvgGroup();
|
|
1075
|
+
return emptyGroup.svgObject;
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
// Create a temporary container to render just the current step
|
|
1079
|
+
const tempContainer = new jsvgGroup();
|
|
1080
|
+
|
|
1081
|
+
// Compute dimensions and render the current step
|
|
1082
|
+
currentStep.computeDimensions();
|
|
1083
|
+
currentStep.updateLayout();
|
|
1084
|
+
const stepRendering = currentStep.render();
|
|
1085
|
+
|
|
1086
|
+
tempContainer.addChild(stepRendering);
|
|
1087
|
+
return tempContainer.svgObject;
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
/**
|
|
1091
|
+
* Convert the entire sequence to a string
|
|
1092
|
+
* @returns {string} Multi-line string of all steps
|
|
1093
|
+
*/
|
|
1094
|
+
toString() {
|
|
1095
|
+
if (this.steps.length === 0) {
|
|
1096
|
+
return '';
|
|
1097
|
+
}
|
|
1098
|
+
return this.steps.map((step, index) => {
|
|
1099
|
+
const description = this.stepDescriptions[index] ? ` (${this.stepDescriptions[index]})` : '';
|
|
1100
|
+
return `Step ${index + 1}: ${step.toString()}${description}`;
|
|
1101
|
+
}).join('\\n');
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
/**
|
|
1105
|
+
* Clear all steps from the sequence
|
|
1106
|
+
*/
|
|
1107
|
+
clear() {
|
|
1108
|
+
// Remove all children
|
|
1109
|
+
this.steps.forEach(step => {
|
|
1110
|
+
this.removeChild(step);
|
|
1111
|
+
});
|
|
1112
|
+
|
|
1113
|
+
// Clear arrays
|
|
1114
|
+
this.steps = [];
|
|
1115
|
+
this.stepDescriptions = [];
|
|
1116
|
+
this.importanceLevels = [];
|
|
1117
|
+
this.argumentNodeList.steps = [];
|
|
1118
|
+
this.currentStepIndex = 0;
|
|
1119
|
+
|
|
1120
|
+
// Clear history
|
|
1121
|
+
this.clearSimplificationHistory();
|
|
1122
|
+
|
|
1123
|
+
// Rebuild node map
|
|
1124
|
+
this.rebuildNodeMap();
|
|
1125
|
+
|
|
1126
|
+
// Update dimensions
|
|
1127
|
+
this.computeDimensions();
|
|
1128
|
+
this.updateLayout();
|
|
1129
|
+
|
|
1130
|
+
// Trigger any UI updates if needed
|
|
1131
|
+
if (window.refreshDisplayAndFilters) {
|
|
1132
|
+
window.refreshDisplayAndFilters();
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
/**
|
|
1137
|
+
* Create a sequence from an array of expressions
|
|
1138
|
+
* @param {string[]} stepsArray - Array of expression strings
|
|
1139
|
+
* @returns {omdEquationSequenceNode} A new sequence node
|
|
1140
|
+
* @static
|
|
1141
|
+
*/
|
|
1142
|
+
static fromSteps(stepsArray) {
|
|
1143
|
+
if (!Array.isArray(stepsArray)) {
|
|
1144
|
+
throw new Error('fromSteps requires an array of expression strings');
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
const sequence = new omdEquationSequenceNode([]);
|
|
1148
|
+
|
|
1149
|
+
stepsArray.forEach((stepStr, index) => {
|
|
1150
|
+
const trimmedStr = stepStr.trim();
|
|
1151
|
+
let stepNode;
|
|
1152
|
+
|
|
1153
|
+
// If the string contains an equals sign, parse it as an equation
|
|
1154
|
+
if (trimmedStr.includes('=')) {
|
|
1155
|
+
stepNode = omdEquationNode.fromString(trimmedStr);
|
|
1156
|
+
} else {
|
|
1157
|
+
// Otherwise, parse it as a general expression
|
|
1158
|
+
if (!window.math) {
|
|
1159
|
+
throw new Error("Math.js is required for parsing expressions");
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
const ast = window.math.parse(trimmedStr);
|
|
1163
|
+
const NodeType = getNodeForAST(ast);
|
|
1164
|
+
stepNode = new NodeType(ast);
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
// Add the step with default importance
|
|
1168
|
+
sequence.addStep(stepNode, {
|
|
1169
|
+
stepMark: 0, // Default to major step
|
|
1170
|
+
description: ''
|
|
1171
|
+
});
|
|
1172
|
+
});
|
|
1173
|
+
|
|
1174
|
+
return sequence;
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
/**
|
|
1178
|
+
* Converts an expression string into a proper omdNode.
|
|
1179
|
+
* @param {string} str - The expression string.
|
|
1180
|
+
* @returns {omdNode} The corresponding node.
|
|
1181
|
+
* @private
|
|
1182
|
+
*/
|
|
1183
|
+
_stringToNode(str) {
|
|
1184
|
+
const trimmedStr = str.trim();
|
|
1185
|
+
if (trimmedStr.includes('=')) {
|
|
1186
|
+
return omdEquationNode.fromString(trimmedStr);
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
if (!window.math) {
|
|
1190
|
+
throw new Error("Math.js is required for parsing expressions");
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
const ast = window.math.parse(trimmedStr);
|
|
1194
|
+
const NodeType = getNodeForAST(ast);
|
|
1195
|
+
return new NodeType(ast);
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
show() {
|
|
1199
|
+
super.show();
|
|
1200
|
+
if (this.layoutManager) {
|
|
1201
|
+
this.layoutManager.updateVisualVisibility();
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
hide() {
|
|
1206
|
+
super.hide();
|
|
1207
|
+
if (this.layoutManager) {
|
|
1208
|
+
this.layoutManager.updateVisualVisibility();
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
/**
|
|
1213
|
+
* Updates visibility of multiple steps at once
|
|
1214
|
+
* @param {Function} visibilityPredicate Function that takes a step and returns true if it should be visible
|
|
1215
|
+
*/
|
|
1216
|
+
updateStepsVisibility(visibilityPredicate) {
|
|
1217
|
+
// Safety check - ensure steps array exists and is properly initialized
|
|
1218
|
+
if (!this.steps || !Array.isArray(this.steps)) {
|
|
1219
|
+
return;
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
this.steps.forEach(step => {
|
|
1223
|
+
if (!step) return; // Skip null/undefined steps
|
|
1224
|
+
|
|
1225
|
+
if (visibilityPredicate(step)) {
|
|
1226
|
+
step.visible = true;
|
|
1227
|
+
if (step.svgObject) step.svgObject.style.display = '';
|
|
1228
|
+
} else {
|
|
1229
|
+
step.visible = false;
|
|
1230
|
+
if (step.svgObject) step.svgObject.style.display = 'none';
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
// Apply font weight based on stepMark
|
|
1234
|
+
const weight = getFontWeight(step.stepMark ?? 0);
|
|
1235
|
+
if (step.svgObject) {
|
|
1236
|
+
step.svgObject.style.fontWeight = weight.toString();
|
|
1237
|
+
}
|
|
1238
|
+
});
|
|
1239
|
+
|
|
1240
|
+
if (this.layoutManager) {
|
|
1241
|
+
this.layoutManager.updateVisualVisibility();
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
this.computeDimensions();
|
|
1245
|
+
this.updateLayout();
|
|
1246
|
+
}
|
|
1247
1247
|
}
|