@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.
@@ -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
  }