@teachinglab/omd
Version:
omd
580 lines (503 loc) • 20.2 kB
JavaScript
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");
}
}
}