UNPKG

@teachinglab/omd

Version:

omd

580 lines (503 loc) 20.2 kB
import { omdEquationSequenceNode } from "../nodes/omdEquationSequenceNode.js"; import { omdEquationNode } from "../nodes/omdEquationNode.js"; import { omdOperationDisplayNode } from "../nodes/omdOperationDisplayNode.js"; import { omdColor } from "../../src/omdColor.js"; import { omdStepVisualizerHighlighting } from "./omdStepVisualizerHighlighting.js"; import { omdStepVisualizerTextBoxes } from "./omdStepVisualizerTextBoxes.js"; import { omdStepVisualizerLayout } from "./omdStepVisualizerLayout.js"; import { getDotRadius } from "../config/omdConfigManager.js"; import { jsvgLayoutGroup, jsvgEllipse, jsvgLine } from '@teachinglab/jsvg'; /** * A visual step tracker that extends omdEquationSequenceNode to show step progression * with dots and connecting lines to the right of the sequence. * @extends omdEquationSequenceNode */ export class omdStepVisualizer extends omdEquationSequenceNode { constructor(steps) { super(steps); // Visual elements for step tracking this.stepDots = []; this.stepLines = []; this.visualContainer = new jsvgLayoutGroup(); this.dotRadius = getDotRadius(0); this.lineWidth = 2; this.visualSpacing = 30; this.activeDotIndex = -1; this.dotsClickable = true; this.nodeToStepMap = new Map(); // Highlighting system this.stepVisualizerHighlights = new Set(); this.highlighting = new omdStepVisualizerHighlighting(this); this.textBoxManager = new omdStepVisualizerTextBoxes(this, this.highlighting); this.layoutManager = new omdStepVisualizerLayout(this); this.addChild(this.visualContainer); this._initializeVisualElements(); this.computeDimensions(); this.updateLayout(); } /** * Force rebuild visual container (dots/lines) from scratch */ rebuildVisualizer() { if (this.visualContainer) { this.removeChild(this.visualContainer); } this.visualContainer = new jsvgLayoutGroup(); this.addChild(this.visualContainer); this._initializeVisualElements(); this.computeDimensions(); this.updateLayout(); } /** * Initializes visual elements (dots and lines) for all existing steps * @private */ _initializeVisualElements() { this._clearVisualElements(); this.nodeToStepMap.clear(); const equations = this.steps.filter(step => step instanceof omdEquationNode); equations.forEach((equation, index) => { this._createStepDot(equation, index); equation.findAllNodes().forEach(node => { this.nodeToStepMap.set(node.id, index); }); if (index > 0) { this._createStepLine(index - 1, index); } }); this.layoutManager.updateVisualZOrder(); this.layoutManager.updateVisualLayout(); } /** * Creates a visual dot for a step * @private */ _createStepDot(equation, index) { const radius = getDotRadius(equation.stepMark ?? 0); const dot = new jsvgEllipse(); dot.setWidthAndHeight(radius * 2, radius * 2); dot.setFillColor(omdColor.stepColor); dot.setStrokeColor(omdColor.stepColor); dot.setStrokeWidth(this.lineWidth); dot.radius = radius; dot.equationRef = equation; dot.stepIndex = index; if (equation.visible === false) { dot.hide(); } this.layoutManager.updateDotClickability(dot); this.stepDots.push(dot); this.visualContainer.addChild(dot); return dot; } /** * Creates a connecting line between two step dots * @private */ _createStepLine(fromIndex, toIndex) { const line = new jsvgLine(); line.setStrokeColor(omdColor.stepColor); line.setStrokeWidth(this.lineWidth); line.fromDotIndex = fromIndex; line.toDotIndex = toIndex; const fromEquation = this.stepDots[fromIndex]?.equationRef; const toEquation = this.stepDots[toIndex]?.equationRef; if (fromEquation?.visible === false || toEquation?.visible === false) { line.hide(); } this.stepLines.push(line); this.visualContainer.addChild(line); return line; } /** * Clears all visual elements * @private */ _clearVisualElements() { this.stepDots.forEach(dot => this.visualContainer.removeChild(dot)); this.stepLines.forEach(line => this.visualContainer.removeChild(line)); this.textBoxManager.clearAllTextBoxes(); this.stepDots = []; this.stepLines = []; this.activeDotIndex = -1; } /** * Override addStep to update visual elements when new steps are added */ addStep(step, options = {}) { let createdDot=null; if (step instanceof omdEquationNode) { this.steps.push(step); const equations = this.steps.filter(s => s instanceof omdEquationNode); const newIndex = equations.length - 1; this.steps.pop(); createdDot = this._createStepDot(step, newIndex); step.findAllNodes().forEach(node => { this.nodeToStepMap.set(node.id, newIndex); }); if (newIndex > 0) { this._createStepLine(newIndex - 1, newIndex); } } super.addStep(step, options); // after stepMark is set, adjust dot radius if(createdDot){ const radius=getDotRadius(step.stepMark??0); createdDot.setWidthAndHeight(radius*2,radius*2); createdDot.radius=radius; } } /** * Gets the step number for a given node ID. */ getNodeStepNumber(nodeId) { return this.nodeToStepMap.get(nodeId); } /** * Override computeDimensions to account for visual elements */ computeDimensions() { super.computeDimensions(); this.sequenceWidth = this.width; // Ensure stepDots is initialized before accessing its length if (this.stepDots && this.stepDots.length > 0) { const containerWidth = this.dotRadius * 3; const visualWidth = this.visualSpacing + containerWidth; this.setWidthAndHeight(this.width + visualWidth, this.height); } } /** * Override updateLayout to update visual elements as well */ updateLayout() { super.updateLayout(); // Only update visual layout if layoutManager is initialized if (this.layoutManager) { this.layoutManager.updateVisualLayout(); this.layoutManager.updateVisualVisibility(); this.layoutManager.updateAllLinePositions(); } } /** * Removes the most recent operation and refreshes visual dots/lines accordingly. * @returns {boolean} Whether an operation was undone */ undoLastOperation() { // Remove bottom-most equation and its preceding operation display const beforeCount = this.steps.length; const removed = super.undoLastOperation ? super.undoLastOperation() : false; if (removed || this.steps.length < beforeCount) { // Hard rebuild the visual container to avoid stale dots/lines this.rebuildVisualizer(); return true; } return false; } /** * Sets the color of a specific dot and its associated lines */ setDotColor(dotIndex, color) { if (this.stepDots && dotIndex >= 0 && dotIndex < this.stepDots.length) { const dot = this.stepDots[dotIndex]; dot.setFillColor(color); dot.setStrokeColor(color); } } /** * Sets the color of the line above a specific dot */ setLineAboveColor(dotIndex, color) { let targetLine = this.stepLines.find(line => line.toDotIndex === dotIndex && line.isTemporary && line.svgObject && line.svgObject.style.display !== 'none' ); if (!targetLine) { targetLine = this.stepLines.find(line => line.toDotIndex === dotIndex && !line.isTemporary && line.svgObject && line.svgObject.style.display !== 'none' ); } if (targetLine) { targetLine.setStrokeColor(color); } } /** * Enables or disables dot clicking functionality */ setDotsClickable(enabled) { this.dotsClickable = enabled; this.stepDots.forEach(dot => { this.layoutManager.updateDotClickability(dot); }); } /** * Handles clicking on a step dot * @private */ _handleDotClick(dot, dotIndex) { if (!this.dotsClickable) return; // Guard against stale dot references if (dotIndex < 0 || dotIndex >= this.stepDots.length) return; if (this.stepDots[dotIndex] !== dot) { // try to resolve current index const idx = this.stepDots.indexOf(dot); if (idx === -1) return; dotIndex = idx; } try { if (this.activeDotIndex === dotIndex) { this._clearActiveDot(); } else { if (this.activeDotIndex !== -1) { this._clearActiveDot(); } this.setActiveDot(dotIndex); const equation = this.stepDots[dotIndex].equationRef; const equationIndex = this.steps.indexOf(equation); const isOperation = this._checkForOperationStep(equationIndex); this.highlighting.highlightAffectedNodes(dotIndex, isOperation); } } catch (error) { console.error('Error handling dot click:', error); } } /** * Sets a dot to be the visually active one. * @private */ setActiveDot(dotIndex) { if (!this.stepDots || dotIndex < 0 || dotIndex >= this.stepDots.length) return; this.activeDotIndex = dotIndex; this.activeDot = this.stepDots[dotIndex]; const dot = this.stepDots[dotIndex]; dot.setFillColor(omdColor.explainColor); dot.setStrokeColor(omdColor.explainColor); this.setLineAboveColor(dotIndex, omdColor.explainColor); this.textBoxManager.createTextBoxForDot(dotIndex); this.layoutManager.updateVisualZOrder(); } /** * Clears the currently active dot * @private */ _clearActiveDot() { try { if (this.activeDotIndex !== -1) { const dot = this.stepDots[this.activeDotIndex]; dot.setFillColor(omdColor.stepColor); dot.setStrokeColor(omdColor.stepColor); this.setLineAboveColor(this.activeDotIndex, omdColor.stepColor); this.textBoxManager.removeTextBoxForDot(this.activeDotIndex); this.highlighting.clearHighlights(); this.layoutManager.updateVisualZOrder(); this.activeDot = null; this.activeDotIndex = -1; } } catch (error) { console.error('Error clearing active dot:', error); } } /** * Gets simplification data for a specific dot * @private */ _getSimplificationDataForDot(dotIndex) { try { const dot = this.stepDots[dotIndex]; if (!dot || !dot.equationRef) { return this._createDefaultSimplificationData("No equation found for this step"); } const equationIndex = this.steps.indexOf(dot.equationRef); if (equationIndex === -1) { return this._createDefaultSimplificationData("Step not found in sequence"); } // Find the previous visible equation const previousVisibleIndex = this._findPreviousVisibleEquationIndex(equationIndex); // Get all steps between previous visible equation and current const allSteps = []; // Get simplifications const simplificationHistory = this.getSimplificationHistory(); const relevantSimplifications = this._getRelevantSimplifications( simplificationHistory, previousVisibleIndex, equationIndex ); allSteps.push(...relevantSimplifications); // Get operations for (let i = previousVisibleIndex + 1; i <= equationIndex; i++) { const operationData = this._checkForOperationStep(i); if (operationData) { allSteps.push({ message: operationData.message, affectedNodes: operationData.affectedNodes, stepNumber: i - 1 }); } } // Sort steps by step number allSteps.sort((a, b) => a.stepNumber - b.stepNumber); if (allSteps.length > 0) { return this._createMultipleSimplificationsData(allSteps); } // Check for single simplification const singleSimplificationData = this._checkForSingleSimplification( simplificationHistory, equationIndex ); if (singleSimplificationData) { return singleSimplificationData; } // Fallback cases return this._getFallbackSimplificationData(equationIndex); } catch (error) { console.error('Error getting simplification data for dot:', error); return this._createDefaultSimplificationData("Error retrieving step data"); } } /** * Gets the step text boxes */ getStepTextBoxes() { return this.textBoxManager.getStepTextBoxes(); } // ===== SIMPLIFICATION DATA METHODS ===== /** * Creates default simplification data * @param {string} message - The message to display * @returns {Object} Default data object * @private */ _createDefaultSimplificationData(message) { return { message: message, rawMessages: [message], ruleNames: ['Step Description'], affectedNodes: [], resultNodeIds: [], resultProvSources: [], multipleSimplifications: false }; } /** * Finds the index of the previous visible equation * @param {number} currentIndex - Current equation index * @returns {number} Index of previous visible equation, or -1 if none found * @private */ _findPreviousVisibleEquationIndex(currentIndex) { for (let i = currentIndex - 1; i >= 0; i--) { if (this.steps[i] instanceof omdEquationNode && this.steps[i].visible !== false) { return i; } } return -1; } /** * Gets relevant simplifications between two step indices * @param {Array} simplificationHistory - Full simplification history * @param {number} startIndex - Starting step index * @param {number} endIndex - Ending step index * @returns {Array} Array of relevant simplification entries * @private */ _getRelevantSimplifications(simplificationHistory, startIndex, endIndex) { const relevantSimplifications = []; for (let stepNum = startIndex; stepNum < endIndex; stepNum++) { const entries = simplificationHistory.filter(entry => entry.stepNumber === stepNum); if (entries.length > 0) { relevantSimplifications.push(...entries); } } return relevantSimplifications; } /** * Creates data object for multiple simplifications * @param {Array} simplifications - Array of simplification entries * @returns {Object} Data object for multiple simplifications * @private */ _createMultipleSimplificationsData(simplifications) { const messages = simplifications.map(s => s.message); const ruleNames = simplifications.map(s => s.name || 'Operation').filter(Boolean); const allAffectedNodes = []; const allResultNodeIds = []; const allResultProvSources = []; simplifications.forEach(entry => { if (entry.affectedNodes) { allAffectedNodes.push(...entry.affectedNodes); } if (entry.resultNodeId) { allResultNodeIds.push(entry.resultNodeId); } if (entry.resultProvSources) { allResultProvSources.push(...entry.resultProvSources); } }); return { message: messages.join('. '), rawMessages: messages, ruleNames: ruleNames, affectedNodes: allAffectedNodes, resultNodeIds: allResultNodeIds, resultProvSources: allResultProvSources, multipleSimplifications: true }; } /** * Checks for operation step data * @param {number} equationIndex - Current equation index * @returns {Object|null} Operation data object or null * @private */ _checkForOperationStep(equationIndex) { if (equationIndex > 0) { const step = this.steps[equationIndex - 1]; if (step instanceof omdOperationDisplayNode) { return { message: `Applied ${step.operation} ${step.value} to both sides`, affectedNodes: [step.operatorLeft, step.valueLeft, step.operatorRight, step.valueRight] }; } } return null; } /** * Checks for single simplification data * @param {Array} simplificationHistory - Full simplification history * @param {number} equationIndex - Current equation index * @returns {Object|null} Single simplification data or null * @private */ _checkForSingleSimplification(simplificationHistory, equationIndex) { const relevantSimplification = simplificationHistory.find(entry => entry.stepNumber === equationIndex - 1 ); if (relevantSimplification) { return { message: relevantSimplification.message, rawMessages: [relevantSimplification.message], ruleNames: [relevantSimplification.name || 'Operation'], affectedNodes: relevantSimplification.affectedNodes || [], resultNodeIds: relevantSimplification.resultNodeId ? [relevantSimplification.resultNodeId] : [], resultProvSources: relevantSimplification.resultProvSources || [], multipleSimplifications: false }; } return null; } /** * Gets fallback data for special cases * @param {number} equationIndex - Current equation index * @returns {Object} Fallback data object * @private */ _getFallbackSimplificationData(equationIndex) { const currentStep = this.steps[equationIndex]; if (equationIndex === 0 && currentStep.stepMark === 0) { const equationStr = currentStep.toString(); return this._createDefaultSimplificationData(`Starting with equation: ${equationStr}`); } else if (currentStep && currentStep.description) { return this._createDefaultSimplificationData(currentStep.description); } else { return this._createDefaultSimplificationData("Step explanation not available"); } } }