UNPKG

@teachinglab/omd

Version:

omd

948 lines (804 loc) 35.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, styling = {}) { super(steps); // Store styling options with defaults this.styling = this._mergeWithDefaults(styling || {}); // Visual elements for step tracking this.stepDots = []; this.stepLines = []; this.visualContainer = new jsvgLayoutGroup(); // Use styling values for these properties this.dotRadius = this.styling.dotRadius; this.lineWidth = this.styling.lineWidth; this.visualSpacing = this.styling.visualSpacing; this.activeDotIndex = -1; this.dotsClickable = true; this.nodeToStepMap = new Map(); // Highlighting system this.stepVisualizerHighlights = new Set(); this.highlighting = new omdStepVisualizerHighlighting(this); // Pass textbox options through styling parameter const textBoxOptions = this.styling.textBoxOptions || {}; this.textBoxManager = new omdStepVisualizerTextBoxes(this, this.highlighting, textBoxOptions); this.layoutManager = new omdStepVisualizerLayout(this); this.addChild(this.visualContainer); this._initializeVisualElements(); // Set default filter level to show only major steps (stepMark = 0) // This ensures intermediate steps are hidden and expansion dots can be created if (this.setFilterLevel && typeof this.setFilterLevel === 'function') { this.setFilterLevel(0); } else { } this.computeDimensions(); this.updateLayout(); } /** * Public: programmatically toggle a dot (simulate user click behavior) * @param {number} dotIndex */ toggleDot(dotIndex) { if (typeof dotIndex !== 'number') return; if (dotIndex < 0 || dotIndex >= this.stepDots.length) return; const dot = this.stepDots[dotIndex]; this._handleDotClick(dot, dotIndex); } /** * Public: close currently active dot textbox if any */ closeActiveDot() { // Always clear all boxes to be safe (even if activeDotIndex already reset) try { const before = this.textBoxManager?.stepTextBoxes?.length || 0; this._clearActiveDot(); if (this.textBoxManager && typeof this.textBoxManager.clearAllTextBoxes === 'function') { this.textBoxManager.clearAllTextBoxes(); } const after = this.textBoxManager?.stepTextBoxes?.length || 0; } catch (e) { /* no-op */ } } /** * Public: close all text boxes (future-proof; currently only one can be active) */ closeAllTextBoxes() { this.closeActiveDot(); } /** * Public: force close EVERYTHING related to active explanation UI */ forceCloseAll() { this.closeActiveDot(); } /** * Merges user styling with default styling options * @param {Object} userStyling - User-provided styling options * @returns {Object} Merged styling object with defaults * @private */ _mergeWithDefaults(userStyling) { const defaults = { // Dot styling dotColor: omdColor.stepColor, dotRadius: getDotRadius(0), dotStrokeWidth: 2, activeDotColor: omdColor.explainColor, expansionDotScale: 0.4, // Line styling lineColor: omdColor.stepColor, lineWidth: 2, activeLineColor: omdColor.explainColor, // Colors explainColor: omdColor.explainColor, highlightColor: omdColor.hiliteColor, // Layout visualSpacing: 30, fixedVisualizerPosition: 250, dotVerticalOffset: 15, // Text boxes textBoxOptions: { backgroundColor: omdColor.white, borderColor: 'none', borderWidth: 1, borderRadius: 5, padding: 8, // Minimal padding for tight fit fontSize: 14, fontFamily: 'Albert Sans, Arial, sans-serif', maxWidth: 300, // More reasonable width for compact layout dropShadow: true // Removed zIndex and position from defaults - these should only apply to container }, // Visual effects enableAnimations: true, hoverEffects: true, // Background styling (inherited from equation styling) backgroundColor: null, cornerRadius: null, pill: null }; return this._deepMerge(defaults, userStyling); } /** * Deep merge two objects * @param {Object} target - Target object * @param {Object} source - Source object * @returns {Object} Merged object * @private */ _deepMerge(target, source) { const result = { ...target }; for (const key in source) { if (source.hasOwnProperty(key)) { if (typeof source[key] === 'object' && source[key] !== null && !Array.isArray(source[key])) { result[key] = this._deepMerge(result[key] || {}, source[key]); } else { result[key] = source[key]; } } } return result; } /** * Updates the styling options and applies them to existing visual elements * @param {Object} newStyling - New styling options to apply */ setStyling(newStyling) { this.styling = this._mergeWithDefaults({ ...this.styling, ...newStyling }); // Update instance properties that are used elsewhere this.dotRadius = this.styling.dotRadius; this.lineWidth = this.styling.lineWidth; this.visualSpacing = this.styling.visualSpacing; this._applyStylingToExistingElements(); // Update layout spacing if changed if (newStyling.visualSpacing !== undefined) { this.visualSpacing = this.styling.visualSpacing; } if (newStyling.fixedVisualizerPosition !== undefined && this.layoutManager) { this.layoutManager.setFixedVisualizerPosition(this.styling.fixedVisualizerPosition); } // Refresh layout and visual elements this.updateLayout(); } /** * Gets the current styling options * @returns {Object} Current styling configuration */ getStyling() { return { ...this.styling }; } /** * Sets a specific styling property * @param {string} property - The property to set (supports dot notation like 'textBoxOptions.backgroundColor') * @param {any} value - The value to set */ setStyleProperty(property, value) { const keys = property.split('.'); const lastKey = keys.pop(); const target = keys.reduce((obj, key) => { if (!obj[key]) obj[key] = {}; return obj[key]; }, this.styling); target[lastKey] = value; // Update instance properties if they were changed if (property === 'dotRadius') this.dotRadius = value; if (property === 'lineWidth') this.lineWidth = value; if (property === 'visualSpacing') this.visualSpacing = value; if (property === 'fixedVisualizerPosition' && this.layoutManager) { this.layoutManager.setFixedVisualizerPosition(value); } this._applyStylingToExistingElements(); this.updateLayout(); } /** * Gets a specific styling property * @param {string} property - The property to get (supports dot notation) * @returns {any} The property value */ getStyleProperty(property) { return property.split('.').reduce((obj, key) => obj?.[key], this.styling); } /** * Applies current styling to all existing visual elements * @private */ _applyStylingToExistingElements() { // Update dots this.stepDots.forEach((dot, index) => { if (dot && dot.equationRef) { const isActive = this.activeDotIndex === index; const color = isActive ? this.styling.activeDotColor : this.styling.dotColor; dot.setFillColor(color); dot.setStrokeColor(color); dot.setStrokeWidth(this.styling.dotStrokeWidth); // Update radius based on step mark const stepMark = dot.equationRef.stepMark ?? 0; const radius = this.styling.dotRadius || getDotRadius(stepMark); dot.setWidthAndHeight(radius * 2, radius * 2); dot.radius = radius; } }); // Update lines this.stepLines.forEach((line, index) => { if (line) { const isActive = this.activeDotIndex >= 0 && (line.toDotIndex === this.activeDotIndex || line.fromDotIndex === this.activeDotIndex); const color = isActive ? this.styling.activeLineColor : this.styling.lineColor; line.setStrokeColor(color); line.setStrokeWidth(this.styling.lineWidth); } }); // Update expansion dots if (this.layoutManager && this.layoutManager.expansionDots) { this.layoutManager.expansionDots.forEach(expansionDot => { if (expansionDot) { const baseRadius = this.styling.dotRadius || getDotRadius(0); const radius = Math.max(3, baseRadius * this.styling.expansionDotScale); expansionDot.setWidthAndHeight(radius * 2, radius * 2); expansionDot.radius = radius; expansionDot.setFillColor(this.styling.dotColor); expansionDot.setStrokeColor(this.styling.dotColor); } }); } // Update text box styling if manager exists if (this.textBoxManager && typeof this.textBoxManager.updateStyling === 'function') { this.textBoxManager.updateStyling(this.styling.textBoxOptions); } } /** * Sets the visual background style (inherits from equation styling) * @param {Object} style - Background style options */ setBackgroundStyle(style = {}) { this.styling.backgroundColor = style.backgroundColor || this.styling.backgroundColor; this.styling.cornerRadius = style.cornerRadius || this.styling.cornerRadius; this.styling.pill = style.pill !== undefined ? style.pill : this.styling.pill; // Apply to equation background if this step visualizer has equation styling if (typeof this.setDefaultEquationBackground === 'function') { this.setDefaultEquationBackground(style); } } /** * Gets the current background style * @returns {Object} Current background style */ getBackgroundStyle() { return { backgroundColor: this.styling.backgroundColor, cornerRadius: this.styling.cornerRadius, pill: this.styling.pill }; } /** * Sets the fixed position for the step visualizer * @param {number} position - The x position from the left edge where the visualizer should be positioned */ setFixedVisualizerPosition(position) { if (this.layoutManager) { this.layoutManager.setFixedVisualizerPosition(position); } } /** * Force rebuild visual container (dots/lines) from scratch */ rebuildVisualizer() { // Clear all step visualizer highlights before rebuilding if (this.highlighting && typeof this.highlighting.clearAllExplainHighlights === 'function') { this.highlighting.clearAllExplainHighlights(); } 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(true); } /** * Creates a visual dot for a step * @private */ _createStepDot(equation, index) { const stepMark = equation.stepMark ?? 0; const radius = this.styling.dotRadius || getDotRadius(stepMark); const dot = new jsvgEllipse(); dot.setWidthAndHeight(radius * 2, radius * 2); const dotColor = this.styling.dotColor; dot.setFillColor(dotColor); dot.setStrokeColor(dotColor); dot.setStrokeWidth(this.styling.dotStrokeWidth); 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(); const lineColor = this.styling.lineColor; line.setStrokeColor(lineColor); line.setStrokeWidth(this.styling.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 = {}) { // Clear all step visualizer highlights when adding new steps (stack expansion) if (this.highlighting && typeof this.highlighting.clearAllExplainHighlights === 'function') { this.highlighting.clearAllExplainHighlights(); } // Call parent first to add the step properly super.addStep(step, options); // Now create visual elements for equation nodes only if (step instanceof omdEquationNode) { // Find the actual index of this equation in the steps array const equationIndex = this.steps.filter(s => s instanceof omdEquationNode).indexOf(step); if (equationIndex >= 0) { const createdDot = this._createStepDot(step, equationIndex); // Update the node to step mapping step.findAllNodes().forEach(node => { this.nodeToStepMap.set(node.id, equationIndex); }); // Create connecting line if this isn't the first equation if (equationIndex > 0) { this._createStepLine(equationIndex - 1, equationIndex); } // After stepMark is set, adjust dot radius if (createdDot) { const radius = getDotRadius(step.stepMark ?? 0); createdDot.setWidthAndHeight(radius * 2, radius * 2); createdDot.radius = radius; } } } // Update layout after adding the step this.computeDimensions(); this.updateLayout(); } /** * 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(); // Store original dimensions before visualizer expansion this.sequenceWidth = this.width; this.sequenceHeight = this.height; // Set width to include the fixed visualizer position plus visualizer width if (this.stepDots && this.stepDots.length > 0 && this.layoutManager) { const containerWidth = this.dotRadius * 3; const fixedVisualizerPosition = this.layoutManager.fixedVisualizerPosition || 250; const totalWidth = fixedVisualizerPosition + this.visualSpacing + containerWidth; this.setWidthAndHeight(totalWidth, 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(true); // Allow repositioning for main layout updates this.layoutManager.updateVisualVisibility(); this.layoutManager.updateAllLinePositions(); } else { } } /** * Removes the most recent operation and refreshes visual dots/lines accordingly. * @returns {boolean} Whether an operation was undone */ undoLastOperation() { // Clear all step visualizer highlights before undoing if (this.highlighting && typeof this.highlighting.clearAllExplainHighlights === 'function') { this.highlighting.clearAllExplainHighlights(); } // 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; // If disabling, clear any active highlights and dots if (!enabled) { this._clearActiveDot(); // Use the more thorough clearing to ensure no stale highlights remain this.highlighting.clearAllExplainHighlights(); } 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]; const explainColor = this.styling.activeDotColor; dot.setFillColor(explainColor); dot.setStrokeColor(explainColor); this.setLineAboveColor(dotIndex, this.styling.activeLineColor); this.textBoxManager.createTextBoxForDot(dotIndex); // Temporarily disable equation repositioning for simple dot state changes const originalRepositioning = this.layoutManager.allowEquationRepositioning; this.layoutManager.allowEquationRepositioning = false; this.layoutManager.updateVisualZOrder(); this.layoutManager.allowEquationRepositioning = originalRepositioning; } /** * Clears the currently active dot * @private */ /** * Clears the currently active dot * @private */ _clearActiveDot() { try { if (this.activeDotIndex !== -1) { const dot = this.stepDots[this.activeDotIndex]; const dotColor = this.styling.dotColor; dot.setFillColor(dotColor); dot.setStrokeColor(dotColor); this.setLineAboveColor(this.activeDotIndex, this.styling.lineColor); this.textBoxManager.removeTextBoxForDot(this.activeDotIndex); // Use thorough clearing to ensure no stale highlights remain this.highlighting.clearAllExplainHighlights(); // Temporarily disable equation repositioning for simple dot state changes const originalRepositioning = this.layoutManager.allowEquationRepositioning; this.layoutManager.allowEquationRepositioning = false; this.layoutManager.updateVisualZOrder(); this.layoutManager.allowEquationRepositioning = originalRepositioning; 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"); } } }