UNPKG

@teachinglab/omd

Version:

omd

1,037 lines (917 loc) 42.9 kB
import { omdEquationNode } from '../nodes/omdEquationNode.js'; import { omdEquationStack } from '../core/omdEquationStack.js'; import { omdStepVisualizer } from '../step-visualizer/omdStepVisualizer.js'; import { getNodeForAST } from '../core/omdUtilities.js'; import { jsvgContainer } from '@teachinglab/jsvg'; /** * OMD Renderer - Handles rendering of mathematical expressions * This class provides a cleaner API for rendering expressions without * being tied to specific DOM elements or UI concerns. */ export class omdDisplay { constructor(container, options = {}) { this.container = container; const { stepVisualizer = false, stackOptions = null, math: mathInstance = (typeof window !== 'undefined' && window.math) ? window.math : null, ...otherOptions } = options || {}; this.options = { fontSize: 32, centerContent: true, topMargin: 40, bottomMargin: 16, fitToContent: true, // Fit to content size by default autoScale: false, // Don't auto-scale by default maxScale: 1, // Do not upscale beyond 1 by default edgePadding: 16, // Horizontal padding from edges when scaling autoCloseStepVisualizer: true, // Close active step visualizer text boxes before autoscale to avoid shrink stepVisualizer, stackOptions, math: mathInstance, ...otherOptions }; // Create SVG container this.svg = new jsvgContainer(); this.node = null; // Internal guards to prevent recursive resize induced growth this._suppressResizeObserver = false; // When true, _handleResize is a no-op this._lastViewbox = null; // Cache last applied viewBox string this._lastContentExtents = null; // Cache last measured content extents to detect real growth this._viewboxLocked = false; // When true, suppress micro growth adjustments this._viewboxLockThreshold = 8; // Require at least 8px growth once locked // Set up the SVG this._setupSVG(); } _setupSVG() { const width = this.container.offsetWidth || 800; const height = this.container.offsetHeight || 600; this.svg.setViewbox(width, height); this.svg.svgObject.style.width = '100%'; this.svg.svgObject.style.height = '100%'; this.svg.svgObject.style.verticalAlign = "middle"; // Enable internal scrolling via native SVG scrolling if content overflows this.svg.svgObject.style.overflow = 'hidden'; this.container.appendChild(this.svg.svgObject); // Create a dedicated content group we can translate to compensate for // viewBox origin changes (so expanding the origin doesn't visually move content). try { const ns = 'http://www.w3.org/2000/svg'; this._contentGroup = document.createElementNS(ns, 'g'); this._contentGroup.setAttribute('id', 'omd-content-root'); this.svg.svgObject.appendChild(this._contentGroup); this._contentOffsetX = 0; this._contentOffsetY = 0; } catch (e) { this._contentGroup = null; } // Handle resize if (window.ResizeObserver) { this.resizeObserver = new ResizeObserver(() => { this._handleResize(); }); this.resizeObserver.observe(this.container); } } _handleResize() { if (this._suppressResizeObserver) return; // Prevent re-entrant resize loops const width = this.container.offsetWidth; const height = this.container.offsetHeight; // Skip if size unchanged; avoids loops where internal changes trigger observer without real container delta if (this._lastContainerWidth === width && this._lastContainerHeight === height) return; this._lastContainerWidth = width; this._lastContainerHeight = height; this.svg.setViewbox(width, height); if (this.options.centerContent && this.node) { this.centerNode(); } // Reposition overlay toolbar (if any) on resize this._repositionOverlayToolbar(); if (this.options.debugExtents) this._drawDebugOverlays(); } /** * Ensure the internal SVG viewBox is at least as large as the provided content dimensions. * This prevents clipping when content is larger than the current viewBox. * @param {number} contentWidth * @param {number} contentHeight */ _ensureViewboxFits(contentWidth, contentHeight) { // If caller provided just width/height, but we prefer extents, bail early if (!this.node) return; const pad = 10; // Prefer DOM measured extents (accounts for strokes, transforms, children SVG geometry) let ext = null; try { const collected = this._collectNodeExtents(this.node); if (collected) { ext = { minX: collected.minX, minY: collected.minY, maxX: collected.maxX, maxY: collected.maxY }; } } catch (e) { ext = null; } if (!ext) { ext = this._computeNodeExtents(this.node); } if (!ext) return; const minX = Math.floor(ext.minX - pad); const minY = Math.floor(ext.minY - pad); const maxX = Math.ceil(ext.maxX + pad); const maxY = Math.ceil(ext.maxY + pad); const curView = this.svg.svgObject.getAttribute('viewBox') || ''; let curX = 0, curY = 0, curW = 0, curH = 0; if (curView) { const parts = curView.split(/\s+/).map(Number).filter(n => !isNaN(n)); if (parts.length === 4) { curX = parts[0]; curY = parts[1]; curW = parts[2]; curH = parts[3]; } } // To avoid shifting visible content, keep the current viewBox origin (curX,curY) // and only expand width/height as needed. Changing the origin would change // the mapping from SVG coordinates to screen coordinates and appear to move // existing content. const desiredX = curX; const desiredY = curY; const desiredRight = Math.max(curX + curW, maxX); const desiredBottom = Math.max(curY + curH, maxY); const desiredW = Math.max(curW, desiredRight - desiredX); const desiredH = Math.max(curH, desiredBottom - desiredY); // Guard: If the desired size change is negligible (< 0.5px), skip. const widthDelta = Math.abs(desiredW - curW); const heightDelta = Math.abs(desiredH - curH); // Safety cap to avoid runaway expansion due to logic errors. const MAX_DIM = 10000; // arbitrary large but finite limit if (desiredW > MAX_DIM || desiredH > MAX_DIM) { console.warn('omdDisplay: viewBox growth capped to prevent runaway expansion', desiredW, desiredH); return; } if (widthDelta < 0.5 && heightDelta < 0.5) return; // Detect repeated growth with identical content extents (suggests feedback loop) const curExtSignature = `${minX},${minY},${maxX},${maxY}`; if (this._lastContentExtents === curExtSignature && heightDelta > 0 && desiredH > curH) { return; // content unchanged, skip } // If locked, only allow substantial growth if (this._viewboxLocked) { const growW = desiredW - curW; const growH = desiredH - curH; if (growW < this._viewboxLockThreshold && growH < this._viewboxLockThreshold) { return; // ignore micro growth attempts } } const newViewBox = `${desiredX} ${desiredY} ${desiredW} ${desiredH}`; if (this._lastViewbox === newViewBox) return; this._suppressResizeObserver = true; try { this.svg.svgObject.setAttribute('viewBox', newViewBox); } finally { // Allow ResizeObserver events after microtask; use timeout to defer setTimeout(() => { this._suppressResizeObserver = false; }, 0); } this._lastViewbox = newViewBox; this._lastContentExtents = curExtSignature; // Lock if the growth applied was small; prevents future tiny increments if (heightDelta < 2 && widthDelta < 2 && !this._viewboxLocked) { this._viewboxLocked = true; } } /** * Walk the node tree and compute absolute extents in SVG coordinates. * Uses `xpos`/`ypos` and `width`/`height` properties; falls back to 0 when missing. * @param {omdNode} root * @returns {{minX:number,minY:number,maxX:number,maxY:number}} */ _computeNodeExtents(root) { if (!root) return null; const visited = new Set(); const stack = [{ node: root, absX: root.xpos || 0, absY: root.ypos || 0 }]; let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; while (stack.length) { const { node, absX, absY } = stack.pop(); if (!node || visited.has(node)) continue; visited.add(node); const w = node.width || 0; const h = node.height || 0; const nx = absX; const ny = absY; minX = Math.min(minX, nx); minY = Math.min(minY, ny); maxX = Math.max(maxX, nx + w); maxY = Math.max(maxY, ny + h); // push children if (Array.isArray(node.childList)) { for (const c of node.childList) { if (!c) continue; const cx = (c.xpos || 0) + nx; const cy = (c.ypos || 0) + ny; stack.push({ node: c, absX: cx, absY: cy }); } } if (node.argumentNodeList) { for (const val of Object.values(node.argumentNodeList)) { if (Array.isArray(val)) { for (const v of val) { if (!v) continue; const vx = (v.xpos || 0) + nx; const vy = (v.ypos || 0) + ny; stack.push({ node: v, absX: vx, absY: vy }); } } else if (val) { const vx = (val.xpos || 0) + nx; const vy = (val.ypos || 0) + ny; stack.push({ node: val, absX: vx, absY: vy }); } } } } if (minX === Infinity) return null; return { minX, minY, maxX, maxY }; } /** * Collect extents for each node and return per-node list plus overall extents. * Useful for debugging elements that extend outside parent coordinates. * @param {omdNode} root * @returns {{nodes:Array, minX:number, minY:number, maxX:number, maxY:number}} */ _collectNodeExtents(root) { if (!root) return null; const visited = new Set(); const stack = [{ node: root, absX: root.xpos || 0, absY: root.ypos || 0, parent: null }]; const nodes = []; let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; while (stack.length) { const { node, absX, absY, parent } = stack.pop(); if (!node || visited.has(node)) continue; visited.add(node); // Prefer DOM measurement if available for accuracy let nx = absX; let ny = absY; let nminX = nx; let nminY = ny; let nmaxX = nx; let nmaxY = ny; try { if (node.svgObject && typeof node.svgObject.getBBox === 'function' && typeof node.svgObject.getCTM === 'function') { const bbox = node.svgObject.getBBox(); const ctm = node.svgObject.getCTM(); // Transform all four bbox corners into root SVG coordinates const corners = [ { x: bbox.x, y: bbox.y }, { x: bbox.x + bbox.width, y: bbox.y }, { x: bbox.x, y: bbox.y + bbox.height }, { x: bbox.x + bbox.width, y: bbox.y + bbox.height } ]; const tx = corners.map(p => ({ x: ctm.a * p.x + ctm.c * p.y + ctm.e, y: ctm.b * p.x + ctm.d * p.y + ctm.f })); nminX = Math.min(...tx.map(t => t.x)); nminY = Math.min(...tx.map(t => t.y)); nmaxX = Math.max(...tx.map(t => t.x)); nmaxY = Math.max(...tx.map(t => t.y)); nx = nminX; ny = nminY; } else { const w = node.width || 0; const h = node.height || 0; nx = absX; ny = absY; nminX = nx; nminY = ny; nmaxX = nx + w; nmaxY = ny + h; } } catch (e) { const w = node.width || 0; const h = node.height || 0; nx = absX; ny = absY; nminX = nx; nminY = ny; nmaxX = nx + w; nmaxY = ny + h; } nodes.push({ node, minX: nminX, minY: nminY, maxX: nmaxX, maxY: nmaxY, parent }); minX = Math.min(minX, nminX); minY = Math.min(minY, nminY); maxX = Math.max(maxX, nmaxX); maxY = Math.max(maxY, nmaxY); // push children if (Array.isArray(node.childList)) { for (const c of node.childList) { if (!c) continue; const cx = (c.xpos || 0) + nx; const cy = (c.ypos || 0) + ny; stack.push({ node: c, absX: cx, absY: cy, parent: node }); } } if (node.argumentNodeList) { for (const val of Object.values(node.argumentNodeList)) { if (Array.isArray(val)) { for (const v of val) { if (!v) continue; const vx = (v.xpos || 0) + nx; const vy = (v.ypos || 0) + ny; stack.push({ node: v, absX: vx, absY: vy, parent: node }); } } else if (val) { const vx = (val.xpos || 0) + nx; const vy = (val.ypos || 0) + ny; stack.push({ node: val, absX: vx, absY: vy, parent: node }); } } } } if (minX === Infinity) return null; return { nodes, minX, minY, maxX, maxY }; } _clearDebugOverlays() { if (!this.svg || !this.svg.svgObject) return; const existing = this.svg.svgObject.querySelector('#omd-debug-overlays'); if (existing) existing.remove(); } _drawDebugOverlays() { if (!this.options.debugExtents) return; if (!this.svg || !this.svg.svgObject || !this.node) return; this._clearDebugOverlays(); const ns = 'http://www.w3.org/2000/svg'; const group = document.createElementNS(ns, 'g'); group.setAttribute('id', 'omd-debug-overlays'); group.setAttribute('pointer-events', 'none'); // overall node extents const collected = this._collectNodeExtents(this.node); if (!collected) return; const { nodes, minX, minY, maxX, maxY } = collected; // Draw content extents (blue dashed) const contentRect = document.createElementNS(ns, 'rect'); contentRect.setAttribute('x', String(minX)); contentRect.setAttribute('y', String(minY)); contentRect.setAttribute('width', String(maxX - minX)); contentRect.setAttribute('height', String(maxY - minY)); contentRect.setAttribute('fill', 'none'); contentRect.setAttribute('stroke', 'blue'); contentRect.setAttribute('stroke-dasharray', '6 4'); contentRect.setAttribute('stroke-width', '0.8'); group.appendChild(contentRect); // Draw viewBox rect (orange) const curView = this.svg.svgObject.getAttribute('viewBox') || ''; if (curView) { const parts = curView.split(/\s+/).map(Number).filter(n => !isNaN(n)); if (parts.length === 4) { const [vx, vy, vw, vh] = parts; const vbRect = document.createElementNS(ns, 'rect'); vbRect.setAttribute('x', String(vx)); vbRect.setAttribute('y', String(vy)); vbRect.setAttribute('width', String(vw)); vbRect.setAttribute('height', String(vh)); vbRect.setAttribute('fill', 'none'); vbRect.setAttribute('stroke', 'orange'); vbRect.setAttribute('stroke-width', '1'); vbRect.setAttribute('opacity', '0.9'); group.appendChild(vbRect); } } // Per-node boxes: green if inside parent, red if overflowing parent bounds const overflowing = []; for (const item of nodes) { const r = document.createElementNS(ns, 'rect'); r.setAttribute('x', String(item.minX)); r.setAttribute('y', String(item.minY)); r.setAttribute('width', String(Math.max(0, item.maxX - item.minX))); r.setAttribute('height', String(Math.max(0, item.maxY - item.minY))); r.setAttribute('fill', 'none'); r.setAttribute('stroke-width', '0.6'); let stroke = 'green'; if (item.parent) { const pMinX = (item.parent.xpos || 0) + (item.parent._absX || 0); const pMinY = (item.parent.ypos || 0) + (item.parent._absY || 0); // fallback compute parent's absX/Y from nodes list if available const parentEntry = nodes.find(n => n.node === item.parent); const pminX = parentEntry ? parentEntry.minX : pMinX; const pminY = parentEntry ? parentEntry.minY : pMinY; const pmaxX = parentEntry ? parentEntry.maxX : pminX + (item.parent.width || 0); const pmaxY = parentEntry ? parentEntry.maxY : pminY + (item.parent.height || 0); if (item.minX < pminX || item.minY < pminY || item.maxX > pmaxX || item.maxY > pmaxY) { stroke = 'red'; overflowing.push({ node: item.node, bounds: item }); } } r.setAttribute('stroke', stroke); r.setAttribute('opacity', stroke === 'red' ? '0.9' : '0.6'); group.appendChild(r); } if (overflowing.length) { console.warn('omdDisplay: debugExtents found overflowing nodes:', overflowing.map(o => ({ type: o.node?.type, bounds: o.bounds }))); } this.svg.svgObject.appendChild(group); } centerNode() { if (!this.node) return; if (!this._centerCallCount) this._centerCallCount = 0; this._centerCallCount++; if (this._centerCallCount > 500) { console.warn('omdDisplay: excessive centerNode calls detected; halting further centering to prevent loop'); return; } const containerWidth = this.container.offsetWidth || 0; const containerHeight = this.container.offsetHeight || 0; // Early auto-close of step visualizer UI before measuring dimensions to avoid transient height inflation if (this.options.autoCloseStepVisualizer && this.node) { try { if (typeof this.node.forceCloseAll === 'function') { this.node.forceCloseAll(); } else if (typeof this.node.closeAllTextBoxes === 'function') { this.node.closeAllTextBoxes(); } else if (typeof this.node.closeActiveDot === 'function') { this.node.closeActiveDot(); } } catch (e) { /* no-op */ } } // Determine actual content size (prefer sequence/current step when available) let contentWidth = this.node.width || 0; let contentHeight = this.node.height || 0; if (this.node.getSequence) { const seq = this.node.getSequence(); if (seq) { if (seq.width && seq.height) { // For step visualizers, use sequenceWidth/Height instead of total dimensions to exclude visualizer elements from autoscale contentWidth = seq.sequenceWidth || seq.width; contentHeight = seq.sequenceHeight || seq.height; } if (seq.getCurrentStep) { const step = seq.getCurrentStep(); if (step && step.width && step.height) { // For step visualizers, prioritize sequenceWidth/Height for dimension calculations const stepWidth = seq.sequenceWidth || step.width; const stepHeight = seq.sequenceHeight || step.height; contentWidth = Math.max(contentWidth, stepWidth); contentHeight = Math.max(contentHeight, stepHeight); } } } } // Compute scale to keep within bounds let scale = 1; if (this.options.autoScale && contentWidth > 0 && contentHeight > 0) { // Optionally close any open step visualizer textbox to prevent transient height expansion if (this.options.autoCloseStepVisualizer && this.node) { try { if (typeof this.node.closeActiveDot === 'function') { this.node.closeActiveDot(); } else if (typeof this.node.closeAllTextBoxes === 'function') { this.node.closeAllTextBoxes(); } } catch (e) { /* no-op */ } } // Detect step visualizer directly on node (getSequence returns underlying sequence only) let hasStepVisualizer = false; if (this.node) { const ctorName = this.node.constructor?.name; hasStepVisualizer = (ctorName === 'omdStepVisualizer') || this.node.type === 'omdStepVisualizer' || (typeof omdStepVisualizer !== 'undefined' && this.node instanceof omdStepVisualizer); } if (hasStepVisualizer) { // Preserve existing scale if already set on node; otherwise lock to 1. const existingScale = (this.node && typeof this.node.scale === 'number') ? this.node.scale : undefined; scale = (existingScale && existingScale > 0) ? existingScale : 1; } else { const hPad = this.options.edgePadding || 0; const vPadTop = this.options.topMargin || 0; const vPadBottom = this.options.bottomMargin || 0; // Reserve extra space for overlay toolbar if needed let reserveBottom = vPadBottom; if (this.node && typeof this.node.isToolbarOverlay === 'function' && this.node.isToolbarOverlay()) { const tH = (typeof this.node.getToolbarVisualHeight === 'function') ? this.node.getToolbarVisualHeight() : 0; reserveBottom += (tH + (this.node.getOverlayPadding ? this.node.getOverlayPadding() : 16)); } const availW = Math.max(0, containerWidth - hPad * 2); const availH = Math.max(0, containerHeight - (vPadTop + reserveBottom)); const sx = availW > 0 ? (availW / contentWidth) : 1; const sy = availH > 0 ? (availH / contentHeight) : 1; const maxScale = (typeof this.options.maxScale === 'number') ? this.options.maxScale : 1; scale = Math.min(sx, sy, maxScale); if (!isFinite(scale) || scale <= 0) scale = 1; } } // Apply scale if (typeof this.node.setScale === 'function') { this.node.setScale(scale); } // Compute X so that equals anchor (if present) is centered after scaling let x; if (this.node.type === 'omdEquationSequenceNode' && this.node.alignPointX !== undefined) { const screenCenterX = containerWidth / 2; x = screenCenterX - (this.node.alignPointX * scale); } else { const scaledWidth = contentWidth * scale; x = (containerWidth - scaledWidth) / 2; } // Decide whether positioning would move content outside container. If so, // prefer expanding the SVG viewBox instead of moving nodes. const scaledWidthFinal = contentWidth * scale; const scaledHeightFinal = contentHeight * scale; const totalNeededH = scaledHeightFinal + (this.options.topMargin || 0) + (this.options.bottomMargin || 0); const willOverflowHoriz = scaledWidthFinal > containerWidth; const willOverflowVert = totalNeededH > containerHeight; // Avoid looping if content dimension signature hasn't changed const contentSig = `${contentWidth}x${contentHeight}x${scale}`; if (this._lastCenterSignature === contentSig && !willOverflowHoriz && !willOverflowVert) { // Only update position; skip expensive ensureViewboxFits if (this.node.setPosition) this.node.setPosition(x, this.options.topMargin); return; } if (willOverflowHoriz || willOverflowVert) { // Set scale but do NOT reposition node (preserve its absolute positions). if (this.node.setScale) this.node.setScale(scale); // Expand viewBox to contain entire unscaled content so nothing is clipped. this._ensureViewboxFits(contentWidth, contentHeight); // Reposition overlay toolbar in case viewBox/container changed this._repositionOverlayToolbar(); // If content still exceeds available height in the host, allow vertical scrolling if (willOverflowVert) { this.container.style.overflowY = 'auto'; this.container.style.overflowX = 'hidden'; } else { this.container.style.overflow = 'hidden'; } if (this.options.debugExtents) this._drawDebugOverlays(); } else { // Y is top margin; scaled content will grow downward this.node.setPosition(x, this.options.topMargin); // Reposition overlay toolbar (if any) this._repositionOverlayToolbar(); // Ensure viewBox can contain the (unscaled) content to avoid clipping in some hosts this._ensureViewboxFits(contentWidth, contentHeight); if (totalNeededH > containerHeight) { // Let the host scroll vertically; keep horizontal overflow hidden to avoid layout shift this.container.style.overflowY = 'auto'; this.container.style.overflowX = 'hidden'; } else { this.container.style.overflow = 'hidden'; } if (this.options.debugExtents) this._drawDebugOverlays(); } this._lastCenterSignature = contentSig; } _getMathInstance() { if (this.options.math) { return this.options.math; } if (typeof window !== 'undefined' && window.math) { return window.math; } return null; } _createNodeFromSegment(segment, mathLib) { try { if (segment.includes('=')) { return omdEquationNode.fromString(segment); } if (!mathLib || typeof mathLib.parse !== 'function') { throw new Error('math.js parser is unavailable'); } const ast = mathLib.parse(segment); const NodeClass = getNodeForAST(ast); return new NodeClass(ast); } catch (error) { const reason = error?.message || String(error); throw new Error(`Failed to parse expression "${segment}": ${reason}`, { cause: error }); } } _createNodesFromString(expressionString) { const segments = (expressionString || '') .split(';') .map(segment => segment.trim()) .filter(Boolean); if (!segments.length) { throw new Error('omdDisplay.render() received an empty expression string.'); } const mathLib = this._getMathInstance(); return segments.map(segment => this._createNodeFromSegment(segment, mathLib)); } _buildStackOptions() { const baseOptions = {}; if (typeof this.options.stepVisualizer === 'boolean') { baseOptions.stepVisualizer = this.options.stepVisualizer; } if (this.options.styling) { baseOptions.styling = this.options.styling; } if (Object.prototype.hasOwnProperty.call(this.options, 'toolbar')) { baseOptions.toolbar = this.options.toolbar; } if (this.options.stackOptions && typeof this.options.stackOptions === 'object') { return { ...baseOptions, ...this.options.stackOptions }; } return baseOptions; } _createStackFromSteps(steps) { if (!steps || !steps.length) { throw new Error('omdDisplay.render() received no steps to render.'); } return new omdEquationStack(steps, this._buildStackOptions()); } fitToContent() { if (!this.node) { return; } // Try to get actual rendered dimensions let actualWidth = 0; let actualHeight = 0; // Get both sequence and current step dimensions let sequenceWidth = 0, sequenceHeight = 0; let stepWidth = 0, stepHeight = 0; if (this.node.getSequence) { const sequence = this.node.getSequence(); if (sequence && sequence.width && sequence.height) { // For step visualizers, use sequenceWidth/Height instead of total dimensions to exclude visualizer elements from autoscale sequenceWidth = sequence.sequenceWidth || sequence.width; sequenceHeight = sequence.sequenceHeight || sequence.height; // Check current step dimensions too if (sequence.getCurrentStep) { const currentStep = sequence.getCurrentStep(); if (currentStep && currentStep.width && currentStep.height) { // For step visualizers, prioritize sequenceWidth/Height for dimension calculations stepWidth = sequence.sequenceWidth || currentStep.width; stepHeight = sequence.sequenceHeight || currentStep.height; } } // Use the larger of sequence or step dimensions actualWidth = Math.max(sequenceWidth, stepWidth); actualHeight = Math.max(sequenceHeight, stepHeight); } } // Fallback to node dimensions only if sequence/step dimensions aren't available if ((actualWidth === 0 || actualHeight === 0) && this.node.width && this.node.height) { actualWidth = this.node.width; actualHeight = this.node.height; } // Fallback dimensions if (actualWidth === 0 || actualHeight === 0) { actualWidth = 200; actualHeight = 60; } const padding = 10; // More comfortable padding to match user expectation const newWidth = actualWidth + (padding * 2); const newHeight = actualHeight + (padding * 2); // Position the content at the minimal padding offset FIRST if (this.node && this.node.setPosition) { this.node.setPosition(padding, padding); } // Update SVG dimensions with viewBox starting from 0,0 since we repositioned content this.svg.setViewbox(newWidth, newHeight); this.svg.setWidthAndHeight(newWidth, newHeight); // Update container this.container.style.width = `${newWidth}px`; this.container.style.height = `${newHeight}px`; if (this.options.debugExtents) this._drawDebugOverlays(); else this._clearDebugOverlays(); } /** * s a mathematical expression or equation * @param {string|omdNode} expression - Expression string or node * @returns {omdNode} The rendered node */ render(expression) { // Clear previous node if (this.node) { this.removeChild(this.node); } // Create node from expression if (typeof expression === 'string') { const steps = this._createNodesFromString(expression); this.node = this._createStackFromSteps(steps); } else if (Array.isArray(expression)) { const steps = expression.flatMap(item => { if (typeof item === 'string') { return this._createNodesFromString(item); } return item; }).filter(Boolean); this.node = this._createStackFromSteps(steps); } else { // Assume it's already a node this.node = expression; } if (!this.node) { throw new Error('omdDisplay.render() was unable to create a node from the provided expression.'); } // Initialize and render const sequence = this.node.getSequence ? this.node.getSequence() : null; if (sequence) { sequence.setFontSize(this.options.fontSize); // Apply filtering based on filterLevel sequence.updateStepsVisibility(step => (step.stepMark ?? 0) === sequence.getFilterLevel()); } // Prefer appending the node's svgObject into our content group so DOM measurements are consistent if (this._contentGroup && this.node && this.node.svgObject) { try { this._contentGroup.appendChild(this.node.svgObject); } catch (e) { this.svg.addChild(this.node); } } else { this.svg.addChild(this.node); } // Apply any stored font settings if (this.options.fontFamily) { this.setFont(this.options.fontFamily, this.options.fontWeight || '400'); } // Only use fitToContent for tight sizing when explicitly requested if (this.options.fitToContent) { this.fitToContent(); } else if (this.options.centerContent) { this.centerNode(); } // Ensure overlay toolbar is positioned initially this._repositionOverlayToolbar(); // Also ensure the viewBox is large enough to contain the node (avoid clipping) const cw = (this.node && this.node.width) ? this.node.width : 0; const ch = (this.node && this.node.height) ? this.node.height : 0; this._ensureViewboxFits(cw, ch); if (this.options.debugExtents) this._drawDebugOverlays(); else this._clearDebugOverlays(); // Provide a default global refresh function if not present if (typeof window !== 'undefined' && !window.refreshDisplayAndFilters) { window.refreshDisplayAndFilters = () => { try { const node = this.getCurrentNode(); const sequence = node?.getSequence ? node.getSequence() : null; if (sequence) { if (typeof sequence.simplifyAll === 'function') { sequence.simplifyAll(); } if (typeof sequence.updateStepsVisibility === 'function') { sequence.updateStepsVisibility(step => (step.stepMark ?? 0) === 0); } if (typeof node.updateLayout === 'function') { node.updateLayout(); } } if (this.options.centerContent) { this.centerNode(); } } catch (e) { // no-op } }; } return this.node; } /** * Add a jsvg child to the internal SVG container and optionally * trigger layout/centering. * @param {object} child - A jsvg node to add */ addChild(child) { if (this._contentGroup && child && child.svgObject) { try { this._contentGroup.appendChild(child.svgObject); } catch (e) { this.svg.addChild(child); } } else { this.svg.addChild(child); } if (this.options.centerContent) this.centerNode(); return child; } /** * Remove a child previously added to the internal SVG container. * @param {object} child */ removeChild(child) { if (!this.svg) return; try { if (child && child.svgObject) { if (this._contentGroup && this._contentGroup.contains(child.svgObject)) { this._contentGroup.removeChild(child.svgObject); } else if (this.svg.svgObject && this.svg.svgObject.contains(child.svgObject)) { this.svg.svgObject.removeChild(child.svgObject); } else if (typeof this.svg.removeChild === 'function') { this.svg.removeChild(child); } } else if (typeof this.svg.removeChild === 'function') { this.svg.removeChild(child); } } catch (e) { // no-op } // If the removed child was the main node, clear reference if (this.node === child) this.node = null; } /** * Updates the display with a new node * @param {omdNode} newNode - The new node to display */ update(newNode) { if (this.node) { if (this._contentGroup && this.node && this.node.svgObject && this._contentGroup.contains(this.node.svgObject)) { this._contentGroup.removeChild(this.node.svgObject); } else if (typeof this.svg.removeChild === 'function') { this.svg.removeChild(this.node); } } this.node = newNode; this.node.setFontSize(this.options.fontSize); this.node.initialize(); if (this._contentGroup && this.node && this.node.svgObject) { try { this._contentGroup.appendChild(this.node.svgObject); } catch (e) { this.svg.addChild(this.node); } } else { this.svg.addChild(this.node); } if (this.options.centerContent) { this.centerNode(); } // Ensure overlay toolbar is positioned on updates this._repositionOverlayToolbar(); } /** * Sets the font size * @param {number} size - The font size */ setFontSize(size) { this.options.fontSize = size; if (this.node) { // Apply font size - handle different node types if (this.node.getSequence && typeof this.node.getSequence === 'function') { // For omdEquationStack, set font size on the sequence this.node.getSequence().setFontSize(size); } else if (this.node.setFontSize && typeof this.node.setFontSize === 'function') { // For regular nodes with setFontSize method this.node.setFontSize(size); } this.node.initialize(); if (this.options.centerContent) { this.centerNode(); } } } /** * Sets the font family for all elements in the display * @param {string} fontFamily - CSS font-family string (e.g., '"Shantell Sans", cursive') * @param {string} fontWeight - CSS font-weight (default: '400') */ setFont(fontFamily, fontWeight = '400') { if (this.svg?.svgObject) { const applyFont = (element) => { if (element.style) { element.style.fontFamily = fontFamily; element.style.fontWeight = fontWeight; } // Recursively apply to all children Array.from(element.children || []).forEach(applyFont); }; // Apply font to the entire SVG applyFont(this.svg.svgObject); // Store font settings for future use this.options.fontFamily = fontFamily; this.options.fontWeight = fontWeight; } } /** * Clears the display */ clear() { if (this.node) { this.removeChild(this.node); this.node = null; } } /** * Destroys the renderer and cleans up resources */ destroy() { this.clear(); if (this.resizeObserver) { this.resizeObserver.disconnect(); } if (this.container.contains(this.svg.svgObject)) { this.container.removeChild(this.svg.svgObject); } } /** * Repositions overlay toolbar if current node supports it * @private */ _repositionOverlayToolbar() { // Use same width calculation as centering to ensure consistency const containerWidth = this.container.offsetWidth || 0; const containerHeight = this.container.offsetHeight || 0; const node = this.node; if (!node) return; const hasOverlayApi = typeof node.isToolbarOverlay === 'function' && typeof node.positionToolbarOverlay === 'function'; if (hasOverlayApi && node.isToolbarOverlay()) { const padding = (typeof node.getOverlayPadding === 'function') ? node.getOverlayPadding() : 16; node.positionToolbarOverlay(containerWidth, containerHeight, padding); } } /** * Public API: returns the currently rendered root node (could be a step visualizer, sequence, or plain node) * @returns {object|null} */ getCurrentNode() { return this.node; } /** * Returns the SVG element for the entire display. * @returns {SVGElement} The SVG element representing the display. */ getSVG() { return this.svg.svgObject; } }