UNPKG

@teachinglab/omd

Version:

omd

482 lines (413 loc) 18 kB
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; } }