@teachinglab/omd
Version:
omd
245 lines (213 loc) • 9.78 kB
JavaScript
import { omdEquationNode } from '../nodes/omdEquationNode.js';
import { omdColor } from '../../src/omdColor.js';
import { jsvgLine } from '@teachinglab/jsvg';
/**
* Handles visual layout, positioning, and visibility management for step visualizations
*/
export class omdStepVisualizerLayout {
constructor(stepVisualizer) {
this.stepVisualizer = stepVisualizer;
}
/**
* Updates the layout of visual elements relative to the sequence
*/
updateVisualLayout() {
if (this.stepVisualizer.stepDots.length === 0) return;
// Position visual container to the right of the sequence
// Add extra offset based on equation background padding (if any)
const baseRight = (this.stepVisualizer.sequenceWidth || this.stepVisualizer.width);
// Use EFFECTIVE padding (after pill clamping) to avoid overlap when pills are wider
const extraPaddingX = this._getMaxEquationEffectivePaddingX();
const visualX = baseRight + this.stepVisualizer.visualSpacing + extraPaddingX;
this.stepVisualizer.visualContainer.setPosition(visualX, 0);
// 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;
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();
}
/**
* 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);
}
}
});
}
/**
* 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 first, which is the source of truth
sv.stepDots.forEach(dot => {
if (dot.equationRef && dot.equationRef.visible !== false) {
dot.show();
dot.visible = true; // Use the dot's own visibility property
} else {
dot.hide();
dot.visible = false;
}
});
// 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();
line.setStrokeColor(omdColor.stepColor);
line.setStrokeWidth(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();
}
/**
* 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;
}
}
}