UNPKG

@teachinglab/omd

Version:

omd

893 lines (664 loc) 33.6 kB
import { omdEquationNode } from '../nodes/omdEquationNode.js'; import { omdColor } from '../../src/omdColor.js'; import { jsvgLine, jsvgEllipse } from '@teachinglab/jsvg'; import { getDotRadius } from '../config/omdConfigManager.js'; /** * Handles visual layout, positioning, and visibility management for step visualizations */ export class omdStepVisualizerLayout { constructor(stepVisualizer) { this.stepVisualizer = stepVisualizer; this.expansionDots = []; // Small dots that show/hide hidden steps this.fixedVisualizerPosition = 250; // Fixed position for the step visualizer from left edge this.allowEquationRepositioning = true; // Flag to control when equations can be repositioned } /** * 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) { // Only update if position actually changes if (this.fixedVisualizerPosition !== position) { this.fixedVisualizerPosition = position; // Trigger a layout update if the visualizer is already initialized if (this.stepVisualizer && this.stepVisualizer.stepDots.length > 0) { this.updateVisualLayout(true); // Allow repositioning for position changes } } } /** * Updates the layout of visual elements relative to the sequence * @param {boolean} allowRepositioning - Whether to allow equation repositioning (default: false) */ updateVisualLayout(allowRepositioning = false) { if (this.stepVisualizer.stepDots.length === 0) return; // Calculate the total width needed for equations (including any padding) const baseEquationWidth = (this.stepVisualizer.sequenceWidth || this.stepVisualizer.width); const extraPaddingX = this._getMaxEquationEffectivePaddingX(); const totalEquationWidth = baseEquationWidth + extraPaddingX; // Position visual container at a fixed position const visualX = this.fixedVisualizerPosition; this.stepVisualizer.visualContainer.setPosition(visualX, 0); // Only reposition equations if explicitly allowed (not during simple dot clicks) if (this.allowEquationRepositioning && allowRepositioning) { // Calculate how much space is available for equations before the visualizer const availableEquationSpace = this.fixedVisualizerPosition - this.stepVisualizer.visualSpacing; // If equations are too wide, shift them left to fit let equationOffsetX = 0; if (totalEquationWidth > availableEquationSpace) { equationOffsetX = availableEquationSpace - totalEquationWidth; } // Apply the offset to equation positioning this._adjustEquationPositions(equationOffsetX); } else { } // Position dots based on visible equations const visibleSteps = this.stepVisualizer.steps.filter(s => s.visible !== false); let currentY = 0; const verticalPadding = 15 * this.stepVisualizer.getFontSize() / this.stepVisualizer.getRootFontSize(); visibleSteps.forEach((step, visIndex) => { if (step instanceof omdEquationNode) { const dotIndex = this.findDotIndexForEquation(step); if (dotIndex >= 0 && dotIndex < this.stepVisualizer.stepDots.length) { const dot = this.stepVisualizer.stepDots[dotIndex]; // Center dot vertically with the equation let equationCenter; if (step.equalsSign && step.equalsSign.ypos !== undefined) { equationCenter = step.equalsSign.ypos + (step.equalsSign.height / 2); } else { equationCenter = step.getAlignmentBaseline ? step.getAlignmentBaseline() : step.height / 2; } const dotY = currentY + equationCenter; const dotX = (this.stepVisualizer.dotRadius * 3) / 2; dot.setPosition(dotX, dotY); } } currentY += step.height; if (visIndex < visibleSteps.length - 1) { currentY += verticalPadding; } }); this.updateAllLinePositions(); // Update container dimensions let containerWidth = this.stepVisualizer.dotRadius * 3; let containerHeight = this.stepVisualizer.height; // Store the original height before expansion for autoscale calculations if (!this.stepVisualizer.sequenceHeight) { this.stepVisualizer.sequenceHeight = containerHeight; } const textBoxes = this.stepVisualizer.textBoxManager.getStepTextBoxes(); if (textBoxes.length > 0) { const textBoxWidth = 280; containerWidth = Math.max(containerWidth, textBoxWidth + this.stepVisualizer.dotRadius * 2 + 10 + 20); // Calculate the maximum extent of any text box to prevent clipping textBoxes.forEach(textBox => { if (textBox.interactiveSteps) { const dimensions = textBox.interactiveSteps.getDimensions(); const layoutGroup = textBox.interactiveSteps.getLayoutGroup(); // Calculate the bottom of this text box const textBoxBottom = layoutGroup.ypos + dimensions.height; containerHeight = Math.max(containerHeight, textBoxBottom + 20); // Add some buffer } }); } if (this.stepVisualizer.stepDots.length > 0) { const maxRadius = Math.max(...this.stepVisualizer.stepDots.map(d=>d.radius||this.stepVisualizer.dotRadius)); const containerWidth = maxRadius * 3; const maxDotY = Math.max(...this.stepVisualizer.stepDots.map(dot => dot.ypos + this.stepVisualizer.dotRadius)); containerHeight = Math.max(containerHeight, maxDotY); } this.stepVisualizer.visualContainer.setWidthAndHeight(containerWidth, containerHeight); this.updateVisualZOrder(); // Position expansion dots after main dots are positioned this._positionExpansionDots(); } /** * Adjusts the horizontal position of all equations by the specified offset * @private */ _adjustEquationPositions(offsetX) { if (offsetX === 0) return; // No adjustment needed const sv = this.stepVisualizer; // Adjust position of all steps (equations and operation display nodes) sv.steps.forEach(step => { if (step && step.setPosition) { const currentX = step.xpos || 0; const currentY = step.ypos || 0; step.setPosition(currentX + offsetX, currentY); // Also adjust operation display nodes if they exist if (step.operationDisplayNode && step.operationDisplayNode.setPosition) { const opCurrentX = step.operationDisplayNode.xpos || 0; const opCurrentY = step.operationDisplayNode.ypos || 0; step.operationDisplayNode.setPosition(opCurrentX + offsetX, opCurrentY); } } }); } /** * Computes the maximum horizontal padding (x) among visible equations, if configured. * This allows dots to shift further right when pill background padding is added. * @returns {number} * @private */ _getMaxEquationEffectivePaddingX() { try { const steps = this.stepVisualizer.steps || []; let maxPadX = 0; steps.forEach(step => { if (step instanceof omdEquationNode && step.visible !== false) { if (typeof step.getEffectiveBackgroundPaddingX === 'function') { const px = Number(step.getEffectiveBackgroundPaddingX()); maxPadX = Math.max(maxPadX, isNaN(px) ? 0 : px); } } }); return maxPadX; } catch (_) { return 0; } } /** * Finds the dot index for a given equation */ findDotIndexForEquation(equation) { return this.stepVisualizer.stepDots.findIndex(dot => dot.equationRef === equation); } /** * Updates the z-order of visual elements */ updateVisualZOrder() { if (!this.stepVisualizer.visualContainer) return; // Lines behind (z-index 1) this.stepVisualizer.stepLines.forEach(line => { if (line && line.svgObject) { line.svgObject.style.zIndex = '1'; if (line.parentNode !== this.stepVisualizer.visualContainer) { this.stepVisualizer.visualContainer.addChild(line); } } }); // Dots in front (z-index 2) this.stepVisualizer.stepDots.forEach(dot => { if (dot && dot.svgObject) { dot.svgObject.style.zIndex = '2'; if (dot.parentNode !== this.stepVisualizer.visualContainer) { this.stepVisualizer.visualContainer.addChild(dot); } } }); // Text boxes on top (z-index 3) const textBoxes = this.stepVisualizer.textBoxManager.getStepTextBoxes(); textBoxes.forEach(textBox => { if (textBox && textBox.svgObject) { textBox.svgObject.style.zIndex = '3'; if (textBox.parentNode !== this.stepVisualizer.visualContainer) { this.stepVisualizer.visualContainer.addChild(textBox); } } }); // Expansion dots on top of regular dots (z-index 4) this.expansionDots.forEach(dot => { if (dot && dot.svgObject) { dot.svgObject.style.zIndex = '4'; if (dot.parentNode !== this.stepVisualizer.visualContainer) { this.stepVisualizer.visualContainer.addChild(dot); } } }); } /** * Updates all line positions to connect dot centers */ updateAllLinePositions() { this.stepVisualizer.stepLines.forEach(line => { const fromDot = this.stepVisualizer.stepDots[line.fromDotIndex]; const toDot = this.stepVisualizer.stepDots[line.toDotIndex]; if (fromDot && toDot) { line.setEndpoints(fromDot.xpos, fromDot.ypos, toDot.xpos, toDot.ypos); } }); } /** * Updates visibility of visual elements based on equation visibility */ updateVisualVisibility() { const sv = this.stepVisualizer; // Update dot visibility and color first, which is the source of truth const dotColor = sv.styling?.dotColor || omdColor.stepColor; sv.stepDots.forEach((dot, index) => { if (dot.equationRef && dot.equationRef.visible !== false) { dot.setFillColor(dotColor); dot.setStrokeColor(dotColor); dot.show(); dot.visible = true; // Use the dot's own visibility property } else { dot.hide(); dot.visible = false; } }); // Clear existing expansion dots this._clearExpansionDots(); // Remove all old lines from the container and the array sv.stepLines.forEach(line => { // Remove the line if it is currently a child of the visualContainer if (line.parent === sv.visualContainer) { sv.visualContainer.removeChild(line); } }); sv.stepLines = []; // Get the dots that are currently visible const visibleDots = sv.stepDots.filter(dot => dot.visible); // Re-create connecting lines only between the visible dots for (let i = 0; i < visibleDots.length - 1; i++) { const fromDot = visibleDots[i]; const toDot = visibleDots[i + 1]; const line = new jsvgLine(); const lineColor = sv.styling?.lineColor || omdColor.stepColor; line.setStrokeColor(lineColor); line.setStrokeWidth(sv.styling?.lineWidth || sv.lineWidth); line.fromDotIndex = sv.stepDots.indexOf(fromDot); line.toDotIndex = sv.stepDots.indexOf(toDot); sv.visualContainer.addChild(line); sv.stepLines.push(line); } // After creating the lines, update their positions this.updateAllLinePositions(); // Create expansion dots for dots that have hidden steps before them this._createExpansionDots(); this._positionExpansionDots(); } /** * Updates the clickability of a dot */ updateDotClickability(dot) { if (this.stepVisualizer.dotsClickable) { dot.svgObject.style.cursor = "pointer"; dot.svgObject.onclick = (event) => { try { const idx = this.stepVisualizer.stepDots.indexOf(dot); if (idx < 0) return; // orphan dot, ignore this.stepVisualizer._handleDotClick(dot, idx); event.stopPropagation(); } catch (error) { console.error('Error in dot click handler:', error); } }; } else { dot.svgObject.style.cursor = "default"; dot.svgObject.onclick = null; } } /** * Clears all expansion dots * @private */ _clearExpansionDots() { this.expansionDots.forEach(dot => { if (dot.parentNode === this.stepVisualizer.visualContainer) { this.stepVisualizer.visualContainer.removeChild(dot); } }); this.expansionDots = []; } /** * Creates expansion dots for visible dots that have hidden steps before them * @private */ _createExpansionDots() { const sv = this.stepVisualizer; const allDots = sv.stepDots; const visibleDots = sv.stepDots.filter(dot => dot.visible); // Debug all steps and their properties sv.steps.forEach((step, i) => { if (step && (step instanceof omdEquationNode || step.constructor.name === 'omdEquationNode')) { } else { } }); // Debug all dots and their properties allDots.forEach((dot, i) => { if (dot && dot.equationRef) { } else { } }); // Check for hidden intermediate steps between consecutive visible major steps (stepMark = 0) const visibleMajorSteps = []; sv.steps.forEach((step, stepIndex) => { if (step && (step instanceof omdEquationNode || step.constructor.name === 'omdEquationNode')) { if (step.stepMark === 0 && step.visible === true) { visibleMajorSteps.push(stepIndex); } } }); // Check between consecutive visible major steps for hidden intermediate steps for (let i = 1; i < visibleMajorSteps.length; i++) { const previousMajorStepIndex = visibleMajorSteps[i - 1]; const currentMajorStepIndex = visibleMajorSteps[i]; // Count hidden intermediate steps between these major steps let hiddenIntermediateCount = 0; for (let j = previousMajorStepIndex + 1; j < currentMajorStepIndex; j++) { const step = sv.steps[j]; if (step && (step instanceof omdEquationNode || step.constructor.name === 'omdEquationNode')) { if (step.stepMark > 0 && step.visible === false) { hiddenIntermediateCount++; } } } if (hiddenIntermediateCount > 0) { // Find the dot for the current major step to position the expansion dot above it const currentMajorStep = sv.steps[currentMajorStepIndex]; const currentDotIndex = sv.stepDots.findIndex(dot => dot.equationRef === currentMajorStep); if (currentDotIndex >= 0) { // Find the position in the visible dots array const visibleDotIndex = i; // i is the position in visibleMajorSteps array const expansionDot = this._createSingleExpansionDot(visibleDotIndex, previousMajorStepIndex, hiddenIntermediateCount); expansionDot.majorStepIndex = currentMajorStepIndex; // Store for reference this.expansionDots.push(expansionDot); sv.visualContainer.addChild(expansionDot); } else { } } else { } } // Also create collapse dots for expanded sequences this._createCollapseDots(); } /** * Counts intermediate steps (stepMark > 0) between two visible dots * @private */ _countIntermediateStepsBetween(fromDotIndex, toDotIndex) { const sv = this.stepVisualizer; let count = 0; // Get the equation references for the from and to dots const fromEquation = sv.stepDots[fromDotIndex]?.equationRef; const toEquation = sv.stepDots[toDotIndex]?.equationRef; if (!fromEquation || !toEquation) { return 0; } // Find the step indices in the main steps array const fromStepIndex = sv.steps.indexOf(fromEquation); const toStepIndex = sv.steps.indexOf(toEquation); // Count intermediate steps between these two major steps for (let i = fromStepIndex + 1; i < toStepIndex; i++) { const step = sv.steps[i]; if (step && (step instanceof omdEquationNode || step.constructor.name === 'omdEquationNode')) { // Count intermediate steps (stepMark > 0) that are currently hidden if (step.stepMark !== undefined && step.stepMark > 0 && step.visible === false) { count++; } } } return count; } /** * Counts hidden steps between two step indices (legacy method for backward compatibility) * @private */ _countHiddenStepsBetween(fromIndex, toIndex) { return this._countIntermediateStepsBetween(fromIndex, toIndex); } /** * Creates a single expansion dot * @private */ _createSingleExpansionDot(currentStepIndex, previousStepIndex, hiddenCount) { const sv = this.stepVisualizer; const baseRadius = sv.styling?.dotRadius || getDotRadius(0); const expansionRadius = Math.max(3, baseRadius * (sv.styling?.expansionDotScale || 0.4)); const expansionDot = new jsvgEllipse(); expansionDot.setWidthAndHeight(expansionRadius * 2, expansionRadius * 2); // Use same color as regular dots from styling const dotColor = sv.styling?.dotColor || omdColor.stepColor; expansionDot.setFillColor(dotColor); expansionDot.setStrokeColor(dotColor); expansionDot.setStrokeWidth(sv.styling?.dotStrokeWidth || 1); // Store metadata expansionDot.isExpansionDot = true; expansionDot.currentStepIndex = currentStepIndex; expansionDot.previousStepIndex = previousStepIndex; expansionDot.hiddenCount = hiddenCount; expansionDot.radius = expansionRadius; // Make it clickable expansionDot.svgObject.style.cursor = "pointer"; expansionDot.svgObject.onclick = (event) => { try { this._handleExpansionDotClick(expansionDot); event.stopPropagation(); } catch (error) { console.error('Error in expansion dot click handler:', error); } }; return expansionDot; } /** * Positions expansion dots above their corresponding main dots * @private */ _positionExpansionDots() { const sv = this.stepVisualizer; this.expansionDots.forEach((expansionDot, index) => { let targetDot; if (expansionDot.isCollapseDot) { // For collapse dots, use the currentStepIndex which points to the dot index const dotIndex = expansionDot.currentStepIndex; targetDot = sv.stepDots[dotIndex]; } else { // For expansion dots, we need to find the actual visible dot that corresponds to the major step const majorStepIndex = expansionDot.majorStepIndex; const majorStep = sv.steps[majorStepIndex]; if (majorStep) { // Find the dot that corresponds to this major step const dotIndex = sv.stepDots.findIndex(dot => dot.equationRef === majorStep); targetDot = sv.stepDots[dotIndex]; } else { } } if (targetDot && targetDot.visible) { const offsetY = -(expansionDot.radius * 2 + 8); // Position above main dot const newX = targetDot.xpos; const newY = targetDot.ypos + offsetY; expansionDot.setPosition(newX, newY); } else { } }); } /** * Creates collapse dots for expanded sequences * @private */ _createCollapseDots() { const sv = this.stepVisualizer; const allDots = sv.stepDots; // Group visible intermediate steps by their consecutive sequences const intermediateGroups = []; let currentGroup = []; allDots.forEach((dot, index) => { if (dot && dot.visible && dot.equationRef) { const stepMark = dot.equationRef.stepMark; if (stepMark !== undefined && stepMark > 0) { currentGroup.push(index); } else if (currentGroup.length > 0) { // We hit a major step, so end the current group intermediateGroups.push([...currentGroup]); currentGroup = []; } } else if (currentGroup.length > 0) { // We hit a non-visible dot, so end the current group intermediateGroups.push([...currentGroup]); currentGroup = []; } }); // Don't forget the last group if it exists if (currentGroup.length > 0) { intermediateGroups.push([...currentGroup]); } // Create a collapse dot for each group intermediateGroups.forEach((group, groupIndex) => { if (group.length > 0) { // Find the major step that comes after the last intermediate step in this group const lastIntermediateIndex = group[group.length - 1]; const lastIntermediateDot = sv.stepDots[lastIntermediateIndex]; const lastIntermediateStep = lastIntermediateDot.equationRef; const lastIntermediateStepIndex = sv.steps.indexOf(lastIntermediateStep); // Find the next major step (stepMark = 0) after the intermediate steps let majorStepAfterIndex = -1; for (let i = lastIntermediateStepIndex + 1; i < sv.steps.length; i++) { const step = sv.steps[i]; if (step && (step instanceof omdEquationNode || step.constructor.name === 'omdEquationNode')) { if (step.stepMark === 0 && step.visible === true) { majorStepAfterIndex = i; break; } } } if (majorStepAfterIndex >= 0) { // Find the dot index for this major step const majorStepAfter = sv.steps[majorStepAfterIndex]; const majorDotIndex = sv.stepDots.findIndex(dot => dot.equationRef === majorStepAfter); if (majorDotIndex >= 0) { const collapseDot = this._createSingleExpansionDot(majorDotIndex, -1, group.length); collapseDot.isCollapseDot = true; collapseDot.intermediateSteps = group; collapseDot.groupIndex = groupIndex; // Store group reference this.expansionDots.push(collapseDot); sv.visualContainer.addChild(collapseDot); } else { } } else { } } }); } /** * Handles clicking on an expansion dot to toggle hidden steps * @private */ _handleExpansionDotClick(expansionDot) { const sv = this.stepVisualizer; // Clear all step visualizer highlights when expanding/contracting if (sv.highlighting && typeof sv.highlighting.clearAllExplainHighlights === 'function') { sv.highlighting.clearAllExplainHighlights(); } if (expansionDot.isCollapseDot) { // Handle collapse dot click - hide only the specific group of intermediate steps // Hide only the intermediate steps in this specific group const intermediateSteps = expansionDot.intermediateSteps || []; intermediateSteps.forEach(dotIndex => { const dot = sv.stepDots[dotIndex]; if (dot && dot.equationRef) { this._hideStep(dot.equationRef); // Also hide the corresponding dot dot.hide(); dot.visible = false; } }); // Remove any lines that connect to the hidden dots this._removeLinesToHiddenDots(); } else { // Handle expansion dot click - show steps between the major steps const { majorStepIndex, previousStepIndex } = expansionDot; // Remove this expansion dot immediately since we're expanding if (expansionDot.parentNode === sv.visualContainer) { sv.visualContainer.removeChild(expansionDot); } const dotIndex = this.expansionDots.indexOf(expansionDot); if (dotIndex >= 0) { this.expansionDots.splice(dotIndex, 1); } // Show all intermediate steps between the previous and current major steps for (let i = previousStepIndex + 1; i < majorStepIndex; i++) { const step = sv.steps[i]; if (step && (step instanceof omdEquationNode || step.constructor.name === 'omdEquationNode')) { if (step.stepMark > 0) { this._showStep(step); // Also show the corresponding dot const stepDotIndex = sv.stepDots.findIndex(dot => dot.equationRef === step); if (stepDotIndex >= 0) { const stepDot = sv.stepDots[stepDotIndex]; stepDot.show(); stepDot.visible = true; } } } } } // Force a complete refresh of the visualizer to clean up artifacts and rebuild lines sv.rebuildVisualizer(); } /** * Properly hides a step and all its child elements * @private */ _hideStep(step) { step.visible = false; if (step.svgObject) { step.svgObject.style.display = 'none'; } // Also hide operation display nodes if they exist if (step.operationDisplayNode) { step.operationDisplayNode.visible = false; if (step.operationDisplayNode.svgObject) { step.operationDisplayNode.svgObject.style.display = 'none'; } } // Hide any child nodes recursively if (step.children && Array.isArray(step.children)) { step.children.forEach(child => { if (child) { this._hideStep(child); } }); } } /** * Properly shows a step and all its child elements * @private */ _showStep(step) { step.visible = true; if (step.svgObject) { step.svgObject.style.display = ''; } // Also show operation display nodes if they exist if (step.operationDisplayNode) { step.operationDisplayNode.visible = true; if (step.operationDisplayNode.svgObject) { step.operationDisplayNode.svgObject.style.display = ''; } } // Show any child nodes recursively if (step.children && Array.isArray(step.children)) { step.children.forEach(child => { if (child) { this._showStep(child); } }); } } /** * Removes lines that connect to hidden dots * @private */ _removeLinesToHiddenDots() { const sv = this.stepVisualizer; // Get lines that connect to hidden dots const linesToRemove = []; sv.stepLines.forEach((line, lineIndex) => { const fromDot = sv.stepDots[line.fromDotIndex]; const toDot = sv.stepDots[line.toDotIndex]; if ((fromDot && !fromDot.visible) || (toDot && !toDot.visible)) { linesToRemove.push(line); } }); // Remove the problematic lines linesToRemove.forEach(line => { if (line.parent === sv.visualContainer) { sv.visualContainer.removeChild(line); } const lineIndex = sv.stepLines.indexOf(line); if (lineIndex >= 0) { sv.stepLines.splice(lineIndex, 1); } }); } }