@teachinglab/omd
Version:
omd
482 lines (413 loc) • 18 kB
JavaScript
import { omdColor } from '../../src/omdColor.js';
import { jsvgLayoutGroup, jsvgTextBox, jsvgRect } from '@teachinglab/jsvg';
/**
* Creates interactive step elements using jsvgLayoutGroup for multiple simplification steps
* Each step is a separate jsvgTextBox that can have hover interactions with the omdSequence
*/
export class omdStepVisualizerInteractiveSteps {
constructor(stepVisualizer, simplificationData) {
this.stepVisualizer = stepVisualizer;
this.simplificationData = simplificationData || {};
this.messages = this._extractMessages(simplificationData);
this.ruleNames = this._extractRuleNames(simplificationData);
this.stepElements = [];
this.layoutGroup = new jsvgLayoutGroup();
this.layoutGroup.setSpacer(20); // Much larger spacing to prevent clipping
// Styling configuration
this.stepWidth = 380; // Increased width to prevent text cutoff
this.baseStepHeight = 40; // Increased base height
this.headerHeight = 40;
this.fontSize = 14;
this.smallFontSize = 12;
this.setupLayoutGroup();
this.createStepElements();
}
/**
* Extracts messages from simplification data
* @param {Object} data - Simplification data
* @returns {Array} Array of clean messages
* @private
*/
_extractMessages(data) {
if (!data) return [];
let messages = [];
if (data.rawMessages && Array.isArray(data.rawMessages)) {
messages = data.rawMessages;
} else if (data.message) {
messages = [data.message];
}
// Clean up messages - remove HTML tags and bullet points
return messages.map(msg => {
let clean = msg.replace(/<[^>]*>/g, ''); // Strip HTML tags
clean = clean.replace(/^[•·◦▪▫‣⁃]\s*/, ''); // Strip bullet points
return clean.trim();
});
}
/**
* Extracts rule names from simplification data
* @param {Object} data - Simplification data
* @returns {Array} Array of rule names
* @private
*/
_extractRuleNames(data) {
if (!data) return ['Operation'];
if (data.ruleNames && Array.isArray(data.ruleNames)) {
return data.ruleNames;
}
// Default based on data type
if (data.multipleSimplifications) {
return ['Multiple Rules'];
} else {
return ['Operation'];
}
}
/**
* Sets up the main layout group properties
* @private
*/
setupLayoutGroup() {
// Add background using explainColor for the entire step group
this.backgroundRect = new jsvgRect();
this.backgroundRect.setWidthAndHeight(this.stepWidth + 24, 100); // Height will be updated, wider for increased padding
this.backgroundRect.setFillColor(omdColor.lightGray);
this.backgroundRect.setStrokeColor('#e0e0e0');
this.backgroundRect.setStrokeWidth(1);
this.backgroundRect.setCornerRadius(6);
this.backgroundRect.setPosition(0, 0); // Start at origin, not negative offset
this.layoutGroup.addChild(this.backgroundRect);
}
/**
* Creates individual step elements from the messages array
* @private
*/
createStepElements() {
if (!this.messages || this.messages.length === 0) return;
// Create content container to separate from background
this.contentGroup = new jsvgLayoutGroup();
this.contentGroup.setSpacer(8); // Larger spacing between elements
this.contentGroup.setPosition(12, 12); // More offset from background edge
if (this.messages.length === 1) {
this.createSingleStepElement(this.messages[0], 0);
} else {
this.createMultipleStepElements();
}
this.contentGroup.doVerticalLayout();
this.layoutGroup.addChild(this.contentGroup);
this.updateBackgroundSize();
// Debug logging
}
/**
* Creates a single step element with header
* @param {string} message - The step message
* @param {number} index - Step index
* @private
*/
createSingleStepElement(message, index) {
// Create header for single step using rule name
const ruleName = this.ruleNames[0] || 'Operation';
const headerBox = this.createHeaderBox(ruleName + ':');
this.contentGroup.addChild(headerBox);
// Create the step box
const stepBox = this.createStepTextBox(message, index, false);
this.stepElements.push(stepBox);
this.contentGroup.addChild(stepBox);
}
/**
* Creates multiple step elements with header
* @private
*/
createMultipleStepElements() {
// Only create header for truly multiple steps (more than 1)
if (this.messages.length > 1) {
// Create header showing rule names
let headerText;
if (this.ruleNames.length === 1) {
headerText = this.ruleNames[0] + ':';
} else if (this.ruleNames.length <= 3) {
headerText = this.ruleNames.join(' + ') + ':';
} else {
headerText = `${this.ruleNames.length} Rules Applied:`;
}
const headerBox = this.createHeaderBox(headerText);
this.contentGroup.addChild(headerBox);
}
// Create individual step elements
this.messages.forEach((message, index) => {
const stepBox = this.createStepTextBox(message, index, this.messages.length > 1);
this.stepElements.push(stepBox);
this.contentGroup.addChild(stepBox);
});
}
/**
* Creates a header box with custom text
* @param {string} headerText - Text to display in header
* @returns {jsvgTextBox} Header text box
* @private
*/
createHeaderBox(headerText = 'Operation:') {
const headerBox = new jsvgTextBox();
const headerHeight = Math.max(this.headerHeight, 40); // Ensure minimum height
headerBox.setWidthAndHeight(this.stepWidth, headerHeight);
headerBox.setText(headerText);
headerBox.setFontSize(this.fontSize);
headerBox.setFontWeight('600');
headerBox.setFontColor('#2c3e50');
// Style the header with border
if (headerBox.div) {
Object.assign(headerBox.div.style, {
borderBottom: '1px solid #e0e0e0',
padding: '8px 12px 6px 12px',
margin: '0',
boxSizing: 'border-box',
minHeight: `${headerHeight}px`,
overflow: 'visible',
whiteSpace: 'normal',
wordWrap: 'break-word',
overflowWrap: 'break-word',
width: '100%'
});
}
return headerBox;
}
/**
* Creates an individual step text box
* @param {string} message - Step message
* @param {number} index - Step index
* @param {boolean} isMultiple - Whether this is part of multiple steps
* @returns {jsvgTextBox} Step text box
* @private
*/
createStepTextBox(message, index, isMultiple) {
const stepBox = new jsvgTextBox();
const height = this.calculateStepHeight(message);
stepBox.setWidthAndHeight(this.stepWidth, height);
stepBox.setFontSize(this.fontSize);
stepBox.setFontColor('#2c3e50');
// Store step data for interactions
stepBox.stepIndex = index;
stepBox.stepMessage = message;
stepBox.isMultiple = isMultiple;
// Format the step content
const formattedContent = this.formatStepContent(message, index, isMultiple);
// Apply styling and content
if (stepBox.div) {
this.applyStepStyling(stepBox, formattedContent, isMultiple);
this.setupStepInteractions(stepBox);
}
return stepBox;
}
/**
* Formats the content for a step
* @param {string} message - Raw message
* @param {number} index - Step index
* @param {boolean} isMultiple - Whether part of multiple steps
* @returns {string} Formatted content
* @private
*/
formatStepContent(message, index, isMultiple) {
const cleanMessage = message.trim();
let content = '';
// Only show step numbers for multiple steps
if (isMultiple && this.messages.length > 1) {
content += `<div class="step-number" style="color: #666; font-size: ${this.smallFontSize}px; margin: 0 0 2px 0; font-weight: 500;">Step ${index + 1}</div>`;
}
content += '<div class="step-content" style="display: flex; align-items: flex-start; gap: 8px; margin: 0; width: 100%;">';
content += '<span class="bullet" style="color: #666; font-weight: bold; flex-shrink: 0; margin-top: 2px;">•</span>';
content += '<div class="step-text" style="margin: 0; flex: 1; min-width: 0; word-wrap: break-word; overflow-wrap: break-word;">';
// Parse operation details
if (this.isOperationMessage(cleanMessage)) {
const action = this.extractOperationAction(cleanMessage);
const value = this.extractOperationValue(cleanMessage);
const valueNode = this.extractOperationValueNode(cleanMessage);
if (action && (value || valueNode)) {
content += `<span style="font-weight: 600; color: #2c3e50;">${action}</span> `;
const displayValue = valueNode ? valueNode.toString() : value;
content += `<span style="background: #f5f5f5; padding: 2px 6px; border-radius: 3px; font-family: 'Courier New', monospace; color: #d63384;">${displayValue}</span>`;
content += `<span style="color: #666; font-size: ${this.smallFontSize}px;"> to both sides</span>`;
} else {
content += `<span>${cleanMessage}</span>`;
}
} else {
content += `<span style="font-weight: 500;">${cleanMessage}</span>`;
}
content += '</div></div>';
return content;
}
/**
* Applies styling to a step text box
* @param {jsvgTextBox} stepBox - The step box
* @param {string} content - Formatted content
* @param {boolean} isMultiple - Whether part of multiple steps
* @private
*/
applyStepStyling(stepBox, content, isMultiple) {
const baseStyles = {
padding: '8px 12px',
borderRadius: '4px',
transition: 'all 0.2s ease',
cursor: 'pointer',
lineHeight: '1.5',
backgroundColor: 'transparent',
margin: '0',
boxSizing: 'border-box',
overflow: 'visible',
minHeight: `${this.baseStepHeight}px`,
width: '100%',
whiteSpace: 'normal', // Allow text wrapping
wordWrap: 'break-word', // Break long words if necessary
overflowWrap: 'break-word', // Modern standard for word breaking
maxWidth: '100%' // Ensure content doesn't exceed container
};
Object.assign(stepBox.div.style, baseStyles);
stepBox.div.innerHTML = content;
// Force a reflow to ensure proper sizing
stepBox.div.offsetHeight;
}
/**
* Sets up hover and click interactions for a step
* @param {jsvgTextBox} stepBox - The step box
* @private
*/
setupStepInteractions(stepBox) {
// Hover effects
stepBox.div.addEventListener('mouseenter', () => {
stepBox.div.style.backgroundColor = omdColor.mediumGray; // Slightly darker version of explainColor
stepBox.div.style.transform = 'translateX(2px)';
// Call hover callback if provided
this.onStepHover?.(stepBox.stepIndex, stepBox.stepMessage, true);
});
stepBox.div.addEventListener('mouseleave', () => {
stepBox.div.style.backgroundColor = 'transparent';
stepBox.div.style.transform = 'translateX(0)';
// Call hover callback if provided
this.onStepHover?.(stepBox.stepIndex, stepBox.stepMessage, false);
});
// Click interactions
stepBox.div.addEventListener('click', () => {
this.onStepClick?.(stepBox.stepIndex, stepBox.stepMessage);
});
}
/**
* Calculates the height needed for a step box
* @param {string} message - Step message
* @returns {number} Height in pixels
* @private
*/
calculateStepHeight(message) {
// Create a temporary element to measure actual text height
const tempDiv = document.createElement('div');
tempDiv.style.position = 'absolute';
tempDiv.style.visibility = 'hidden';
tempDiv.style.width = `${this.stepWidth - 24}px`; // Account for padding
tempDiv.style.fontSize = `${this.fontSize}px`;
tempDiv.style.lineHeight = '1.5';
tempDiv.style.fontFamily = 'inherit';
tempDiv.style.whiteSpace = 'normal';
tempDiv.style.wordWrap = 'break-word';
tempDiv.style.overflowWrap = 'break-word';
tempDiv.style.padding = '8px 12px';
tempDiv.style.boxSizing = 'border-box';
// Use actual formatted content for measurement
const isMultiple = this.messages && this.messages.length > 1;
const formattedContent = this.formatStepContent(message, 0, isMultiple);
tempDiv.innerHTML = formattedContent;
// Append to document to measure
document.body.appendChild(tempDiv);
const measuredHeight = tempDiv.offsetHeight;
document.body.removeChild(tempDiv);
// Ensure minimum height and add buffer for interactions
const finalHeight = Math.max(this.baseStepHeight, measuredHeight + 8);
return finalHeight;
}
/**
* Updates the background rectangle size after layout
* @private
*/
updateBackgroundSize() {
if (this.backgroundRect && this.contentGroup) {
const totalHeight = this.contentGroup.height + 24; // More padding for larger offset
const totalWidth = this.stepWidth + 24; // More padding for larger offset
this.backgroundRect.setWidthAndHeight(totalWidth, totalHeight);
}
}
/**
* Sets callback for step hover events
* @param {Function} callback - Function called with (stepIndex, message, isEntering)
*/
setOnStepHover(callback) {
this.onStepHover = callback;
}
/**
* Sets callback for step click events
* @param {Function} callback - Function called with (stepIndex, message)
*/
setOnStepClick(callback) {
this.onStepClick = callback;
}
/**
* Gets the main layout group for adding to parent containers
* @returns {jsvgLayoutGroup} The layout group
*/
getLayoutGroup() {
return this.layoutGroup;
}
/**
* Sets the position of the entire step group
* @param {number} x - X position
* @param {number} y - Y position
*/
setPosition(x, y) {
this.layoutGroup.setPosition(x, y);
}
/**
* Gets the dimensions of the step group
* @returns {Object} Width and height
*/
getDimensions() {
return {
width: this.backgroundRect ? this.backgroundRect.width : this.stepWidth + 16,
height: this.backgroundRect ? this.backgroundRect.height : 100
};
}
// Helper methods for message parsing (same as in formatter)
isOperationMessage(message) {
const operationKeywords = ['Applied', 'added', 'subtracted', 'multiplied', 'divided', 'both sides'];
return operationKeywords.some(keyword =>
message.toLowerCase().includes(keyword.toLowerCase())
);
}
extractOperationAction(message) {
const match = message.match(/^(Added|Subtracted|Multiplied|Divided)/i);
return match ? match[0] : null;
}
extractOperationValue(message) {
// Updated regex to handle simple values and expressions
const match = message.match(/(?:Added|Subtracted|Multiplied|Divided)\s(.*?)\s(?:to|by)/i);
if (match && match[1]) {
// Avoid returning "[object Object]"
if (match[1].includes('[object Object]')) {
return null;
}
return match[1];
}
return null;
}
extractOperationValueNode(message) {
if (this.simplificationData && this.simplificationData.operationValueNode) {
return this.simplificationData.operationValueNode;
}
return null;
}
/**
* Destroys the step group and cleans up resources
*/
destroy() {
this.stepElements = [];
if (this.contentGroup) {
this.contentGroup.removeAllChildren();
}
this.layoutGroup.removeAllChildren();
this.onStepHover = null;
this.onStepClick = null;
}
}