@teachinglab/omd 0.6.1 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (198) hide show
  1. package/README.md +257 -251
  2. package/README.old.md +137 -137
  3. package/canvas/core/canvasConfig.js +202 -202
  4. package/canvas/drawing/segment.js +167 -167
  5. package/canvas/drawing/stroke.js +385 -385
  6. package/canvas/events/eventManager.js +444 -444
  7. package/canvas/events/pointerEventHandler.js +262 -262
  8. package/canvas/index.js +48 -48
  9. package/canvas/tools/PointerTool.js +71 -71
  10. package/canvas/tools/tool.js +222 -222
  11. package/canvas/utils/boundingBox.js +377 -377
  12. package/canvas/utils/mathUtils.js +258 -258
  13. package/docs/api/configuration-options.md +198 -198
  14. package/docs/api/eventManager.md +82 -82
  15. package/docs/api/focusFrameManager.md +144 -144
  16. package/docs/api/index.md +105 -105
  17. package/docs/api/main.md +62 -62
  18. package/docs/api/omdBinaryExpressionNode.md +86 -86
  19. package/docs/api/omdCanvas.md +83 -83
  20. package/docs/api/omdConfigManager.md +112 -112
  21. package/docs/api/omdConstantNode.md +52 -52
  22. package/docs/api/omdDisplay.md +87 -87
  23. package/docs/api/omdEquationNode.md +174 -174
  24. package/docs/api/omdEquationSequenceNode.md +258 -258
  25. package/docs/api/omdEquationStack.md +192 -192
  26. package/docs/api/omdFunctionNode.md +82 -82
  27. package/docs/api/omdGroupNode.md +78 -78
  28. package/docs/api/omdHelpers.md +87 -87
  29. package/docs/api/omdLeafNode.md +85 -85
  30. package/docs/api/omdNode.md +201 -201
  31. package/docs/api/omdOperationDisplayNode.md +117 -117
  32. package/docs/api/omdOperatorNode.md +91 -91
  33. package/docs/api/omdParenthesisNode.md +133 -133
  34. package/docs/api/omdPopup.md +191 -191
  35. package/docs/api/omdPowerNode.md +131 -131
  36. package/docs/api/omdRationalNode.md +144 -144
  37. package/docs/api/omdSequenceNode.md +128 -128
  38. package/docs/api/omdSimplification.md +78 -78
  39. package/docs/api/omdSqrtNode.md +144 -144
  40. package/docs/api/omdStepVisualizer.md +146 -146
  41. package/docs/api/omdStepVisualizerHighlighting.md +65 -65
  42. package/docs/api/omdStepVisualizerInteractiveSteps.md +108 -108
  43. package/docs/api/omdStepVisualizerLayout.md +70 -70
  44. package/docs/api/omdStepVisualizerNodeUtils.md +140 -140
  45. package/docs/api/omdStepVisualizerTextBoxes.md +76 -76
  46. package/docs/api/omdToolbar.md +130 -130
  47. package/docs/api/omdTranscriptionService.md +95 -95
  48. package/docs/api/omdTreeDiff.md +169 -169
  49. package/docs/api/omdUnaryExpressionNode.md +137 -137
  50. package/docs/api/omdUtilities.md +82 -82
  51. package/docs/api/omdVariableNode.md +123 -123
  52. package/docs/api/selectTool.md +74 -74
  53. package/docs/api/simplificationEngine.md +97 -97
  54. package/docs/api/simplificationRules.md +76 -76
  55. package/docs/api/simplificationUtils.md +64 -64
  56. package/docs/api/transcribe.md +43 -43
  57. package/docs/api-reference.md +85 -85
  58. package/docs/index.html +453 -453
  59. package/docs/index.md +38 -38
  60. package/docs/omd-objects.md +258 -258
  61. package/index.js +79 -79
  62. package/jsvg/index.js +3 -0
  63. package/jsvg/jsvg.js +898 -898
  64. package/jsvg/jsvgComponents.js +357 -358
  65. package/npm-docs/DOCUMENTATION_SUMMARY.md +220 -220
  66. package/npm-docs/README.md +251 -251
  67. package/npm-docs/api/api-reference.md +85 -85
  68. package/npm-docs/api/configuration-options.md +198 -198
  69. package/npm-docs/api/eventManager.md +82 -82
  70. package/npm-docs/api/expression-nodes.md +561 -561
  71. package/npm-docs/api/focusFrameManager.md +144 -144
  72. package/npm-docs/api/index.md +105 -105
  73. package/npm-docs/api/main.md +62 -62
  74. package/npm-docs/api/omdBinaryExpressionNode.md +86 -86
  75. package/npm-docs/api/omdCanvas.md +83 -83
  76. package/npm-docs/api/omdConfigManager.md +112 -112
  77. package/npm-docs/api/omdConstantNode.md +52 -52
  78. package/npm-docs/api/omdDisplay.md +87 -87
  79. package/npm-docs/api/omdEquationNode.md +174 -174
  80. package/npm-docs/api/omdEquationSequenceNode.md +258 -258
  81. package/npm-docs/api/omdEquationStack.md +192 -192
  82. package/npm-docs/api/omdFunctionNode.md +82 -82
  83. package/npm-docs/api/omdGroupNode.md +78 -78
  84. package/npm-docs/api/omdHelpers.md +87 -87
  85. package/npm-docs/api/omdLeafNode.md +85 -85
  86. package/npm-docs/api/omdNode.md +201 -201
  87. package/npm-docs/api/omdOperationDisplayNode.md +117 -117
  88. package/npm-docs/api/omdOperatorNode.md +91 -91
  89. package/npm-docs/api/omdParenthesisNode.md +133 -133
  90. package/npm-docs/api/omdPopup.md +191 -191
  91. package/npm-docs/api/omdPowerNode.md +131 -131
  92. package/npm-docs/api/omdRationalNode.md +144 -144
  93. package/npm-docs/api/omdSequenceNode.md +128 -128
  94. package/npm-docs/api/omdSimplification.md +78 -78
  95. package/npm-docs/api/omdSqrtNode.md +144 -144
  96. package/npm-docs/api/omdStepVisualizer.md +146 -146
  97. package/npm-docs/api/omdStepVisualizerHighlighting.md +65 -65
  98. package/npm-docs/api/omdStepVisualizerInteractiveSteps.md +108 -108
  99. package/npm-docs/api/omdStepVisualizerLayout.md +70 -70
  100. package/npm-docs/api/omdStepVisualizerNodeUtils.md +140 -140
  101. package/npm-docs/api/omdStepVisualizerTextBoxes.md +76 -76
  102. package/npm-docs/api/omdToolbar.md +130 -130
  103. package/npm-docs/api/omdTranscriptionService.md +95 -95
  104. package/npm-docs/api/omdTreeDiff.md +169 -169
  105. package/npm-docs/api/omdUnaryExpressionNode.md +137 -137
  106. package/npm-docs/api/omdUtilities.md +82 -82
  107. package/npm-docs/api/omdVariableNode.md +123 -123
  108. package/npm-docs/api/selectTool.md +74 -74
  109. package/npm-docs/api/simplificationEngine.md +97 -97
  110. package/npm-docs/api/simplificationRules.md +76 -76
  111. package/npm-docs/api/simplificationUtils.md +64 -64
  112. package/npm-docs/api/transcribe.md +43 -43
  113. package/npm-docs/guides/equations.md +854 -854
  114. package/npm-docs/guides/factory-functions.md +354 -354
  115. package/npm-docs/guides/getting-started.md +318 -318
  116. package/npm-docs/guides/quick-examples.md +525 -525
  117. package/npm-docs/guides/visualizations.md +682 -682
  118. package/npm-docs/index.html +12 -0
  119. package/npm-docs/json-schemas.md +826 -826
  120. package/omd/config/omdConfigManager.js +279 -267
  121. package/omd/core/index.js +158 -158
  122. package/omd/core/omdEquationStack.js +546 -546
  123. package/omd/core/omdUtilities.js +113 -113
  124. package/omd/display/omdDisplay.js +969 -962
  125. package/omd/display/omdToolbar.js +501 -501
  126. package/omd/nodes/omdBinaryExpressionNode.js +459 -459
  127. package/omd/nodes/omdConstantNode.js +141 -141
  128. package/omd/nodes/omdEquationNode.js +1327 -1327
  129. package/omd/nodes/omdFunctionNode.js +351 -351
  130. package/omd/nodes/omdGroupNode.js +67 -67
  131. package/omd/nodes/omdLeafNode.js +76 -76
  132. package/omd/nodes/omdNode.js +556 -556
  133. package/omd/nodes/omdOperationDisplayNode.js +321 -321
  134. package/omd/nodes/omdOperatorNode.js +108 -108
  135. package/omd/nodes/omdParenthesisNode.js +292 -292
  136. package/omd/nodes/omdPowerNode.js +235 -235
  137. package/omd/nodes/omdRationalNode.js +295 -295
  138. package/omd/nodes/omdSqrtNode.js +307 -307
  139. package/omd/nodes/omdUnaryExpressionNode.js +227 -227
  140. package/omd/nodes/omdVariableNode.js +122 -122
  141. package/omd/simplification/omdSimplification.js +140 -140
  142. package/omd/simplification/omdSimplificationEngine.js +887 -887
  143. package/omd/simplification/package.json +5 -5
  144. package/omd/simplification/rules/binaryRules.js +1037 -1037
  145. package/omd/simplification/rules/functionRules.js +111 -111
  146. package/omd/simplification/rules/index.js +48 -48
  147. package/omd/simplification/rules/parenthesisRules.js +19 -19
  148. package/omd/simplification/rules/powerRules.js +143 -143
  149. package/omd/simplification/rules/rationalRules.js +725 -725
  150. package/omd/simplification/rules/sqrtRules.js +48 -48
  151. package/omd/simplification/rules/unaryRules.js +37 -37
  152. package/omd/simplification/simplificationRules.js +31 -31
  153. package/omd/simplification/simplificationUtils.js +1055 -1055
  154. package/omd/step-visualizer/omdStepVisualizer.js +947 -947
  155. package/omd/step-visualizer/omdStepVisualizerHighlighting.js +246 -246
  156. package/omd/step-visualizer/omdStepVisualizerLayout.js +892 -892
  157. package/omd/step-visualizer/omdStepVisualizerTextBoxes.js +200 -200
  158. package/omd/utils/aiNextEquationStep.js +106 -106
  159. package/omd/utils/omdNodeOverlay.js +638 -638
  160. package/omd/utils/omdPopup.js +1203 -1203
  161. package/omd/utils/omdStepVisualizerInteractiveSteps.js +684 -684
  162. package/omd/utils/omdStepVisualizerNodeUtils.js +267 -267
  163. package/omd/utils/omdTranscriptionService.js +123 -123
  164. package/omd/utils/omdTreeDiff.js +733 -733
  165. package/package.json +59 -57
  166. package/readme.html +184 -120
  167. package/src/index.js +74 -74
  168. package/src/json-schemas.md +576 -576
  169. package/src/omd-json-samples.js +147 -147
  170. package/src/omdApp.js +391 -391
  171. package/src/omdAppCanvas.js +335 -335
  172. package/src/omdBalanceHanger.js +199 -199
  173. package/src/omdColor.js +13 -13
  174. package/src/omdCoordinatePlane.js +541 -541
  175. package/src/omdExpression.js +115 -115
  176. package/src/omdFactory.js +150 -150
  177. package/src/omdFunction.js +114 -114
  178. package/src/omdMetaExpression.js +290 -290
  179. package/src/omdNaturalExpression.js +563 -563
  180. package/src/omdNode.js +383 -383
  181. package/src/omdNumber.js +52 -52
  182. package/src/omdNumberLine.js +114 -112
  183. package/src/omdNumberTile.js +118 -118
  184. package/src/omdOperator.js +72 -72
  185. package/src/omdPowerExpression.js +91 -91
  186. package/src/omdProblem.js +259 -259
  187. package/src/omdRatioChart.js +251 -251
  188. package/src/omdRationalExpression.js +114 -114
  189. package/src/omdSampleData.js +215 -215
  190. package/src/omdShapes.js +512 -512
  191. package/src/omdSpinner.js +151 -151
  192. package/src/omdString.js +49 -49
  193. package/src/omdTable.js +498 -498
  194. package/src/omdTapeDiagram.js +244 -244
  195. package/src/omdTerm.js +91 -91
  196. package/src/omdTileEquation.js +349 -349
  197. package/src/omdUtils.js +84 -84
  198. package/src/omdVariable.js +51 -51
@@ -1,963 +1,970 @@
1
- import { omdEquationNode } from '../nodes/omdEquationNode.js';
2
- import { omdStepVisualizer } from '../step-visualizer/omdStepVisualizer.js';
3
- import { getNodeForAST } from '../core/omdUtilities.js';
4
- import { jsvgContainer } from '@teachinglab/jsvg';
5
-
6
- /**
7
- * OMD Renderer - Handles rendering of mathematical expressions
8
- * This class provides a cleaner API for rendering expressions without
9
- * being tied to specific DOM elements or UI concerns.
10
- */
11
- export class omdDisplay {
12
- constructor(container, options = {}) {
13
- this.container = container;
14
- this.options = {
15
- fontSize: 32,
16
- centerContent: true,
17
- topMargin: 40,
18
- bottomMargin: 16,
19
- fitToContent: false, // Only fit to content when explicitly requested
20
- autoScale: true, // Automatically scale content to fit container
21
- maxScale: 1, // Do not upscale beyond 1 by default
22
- edgePadding: 16, // Horizontal padding from edges when scaling
23
- autoCloseStepVisualizer: true, // Close active step visualizer text boxes before autoscale to avoid shrink
24
- ...options
25
- };
26
-
27
- // Create SVG container
28
- this.svg = new jsvgContainer();
29
- this.node = null;
30
-
31
- // Internal guards to prevent recursive resize induced growth
32
- this._suppressResizeObserver = false; // When true, _handleResize is a no-op
33
- this._lastViewbox = null; // Cache last applied viewBox string
34
- this._lastContentExtents = null; // Cache last measured content extents to detect real growth
35
- this._viewboxLocked = false; // When true, suppress micro growth adjustments
36
- this._viewboxLockThreshold = 8; // Require at least 8px growth once locked
37
-
38
- // Set up the SVG
39
- this._setupSVG();
40
- }
41
-
42
- _setupSVG() {
43
- const width = this.container.offsetWidth || 800;
44
- const height = this.container.offsetHeight || 600;
45
-
46
- this.svg.setViewbox(width, height);
47
- this.svg.svgObject.style.verticalAlign = "middle";
48
- // Enable internal scrolling via native SVG scrolling if content overflows
49
- this.svg.svgObject.style.overflow = 'hidden';
50
- this.container.appendChild(this.svg.svgObject);
51
-
52
- // Create a dedicated content group we can translate to compensate for
53
- // viewBox origin changes (so expanding the origin doesn't visually move content).
54
- try {
55
- const ns = 'http://www.w3.org/2000/svg';
56
- this._contentGroup = document.createElementNS(ns, 'g');
57
- this._contentGroup.setAttribute('id', 'omd-content-root');
58
- this.svg.svgObject.appendChild(this._contentGroup);
59
- this._contentOffsetX = 0;
60
- this._contentOffsetY = 0;
61
- } catch (e) {
62
- this._contentGroup = null;
63
- }
64
-
65
- // Handle resize
66
- if (window.ResizeObserver) {
67
- this.resizeObserver = new ResizeObserver(() => {
68
- this._handleResize();
69
- });
70
- this.resizeObserver.observe(this.container);
71
- }
72
- }
73
-
74
- _handleResize() {
75
- if (this._suppressResizeObserver) return; // Prevent re-entrant resize loops
76
- const width = this.container.offsetWidth;
77
- const height = this.container.offsetHeight;
78
- // Skip if size unchanged; avoids loops where internal changes trigger observer without real container delta
79
- if (this._lastContainerWidth === width && this._lastContainerHeight === height) return;
80
- this._lastContainerWidth = width;
81
- this._lastContainerHeight = height;
82
- this.svg.setViewbox(width, height);
83
-
84
- if (this.options.centerContent && this.node) {
85
- this.centerNode();
86
- }
87
-
88
- // Reposition overlay toolbar (if any) on resize
89
- this._repositionOverlayToolbar();
90
- if (this.options.debugExtents) this._drawDebugOverlays();
91
- }
92
-
93
- /**
94
- * Ensure the internal SVG viewBox is at least as large as the provided content dimensions.
95
- * This prevents clipping when content is larger than the current viewBox.
96
- * @param {number} contentWidth
97
- * @param {number} contentHeight
98
- */
99
- _ensureViewboxFits(contentWidth, contentHeight) {
100
- // If caller provided just width/height, but we prefer extents, bail early
101
- if (!this.node) return;
102
- const pad = 10;
103
-
104
- // Prefer DOM measured extents (accounts for strokes, transforms, children SVG geometry)
105
- let ext = null;
106
- try {
107
- const collected = this._collectNodeExtents(this.node);
108
- if (collected) {
109
- ext = { minX: collected.minX, minY: collected.minY, maxX: collected.maxX, maxY: collected.maxY };
110
- }
111
- } catch (e) {
112
- ext = null;
113
- }
114
- if (!ext) {
115
- ext = this._computeNodeExtents(this.node);
116
- }
117
- if (!ext) return;
118
-
119
- const minX = Math.floor(ext.minX - pad);
120
- const minY = Math.floor(ext.minY - pad);
121
- const maxX = Math.ceil(ext.maxX + pad);
122
- const maxY = Math.ceil(ext.maxY + pad);
123
-
124
- const curView = this.svg.svgObject.getAttribute('viewBox') || '';
125
- let curX = 0, curY = 0, curW = 0, curH = 0;
126
- if (curView) {
127
- const parts = curView.split(/\s+/).map(Number).filter(n => !isNaN(n));
128
- if (parts.length === 4) {
129
- curX = parts[0];
130
- curY = parts[1];
131
- curW = parts[2];
132
- curH = parts[3];
133
- }
134
- }
135
-
136
- // To avoid shifting visible content, keep the current viewBox origin (curX,curY)
137
- // and only expand width/height as needed. Changing the origin would change
138
- // the mapping from SVG coordinates to screen coordinates and appear to move
139
- // existing content.
140
- const desiredX = curX;
141
- const desiredY = curY;
142
- const desiredRight = Math.max(curX + curW, maxX);
143
- const desiredBottom = Math.max(curY + curH, maxY);
144
- const desiredW = Math.max(curW, desiredRight - desiredX);
145
- const desiredH = Math.max(curH, desiredBottom - desiredY);
146
-
147
- // Guard: If the desired size change is negligible (< 0.5px), skip.
148
- const widthDelta = Math.abs(desiredW - curW);
149
- const heightDelta = Math.abs(desiredH - curH);
150
-
151
- // Safety cap to avoid runaway expansion due to logic errors.
152
- const MAX_DIM = 10000; // arbitrary large but finite limit
153
- if (desiredW > MAX_DIM || desiredH > MAX_DIM) {
154
- console.warn('omdDisplay: viewBox growth capped to prevent runaway expansion', desiredW, desiredH);
155
- return;
156
- }
157
-
158
- if (widthDelta < 0.5 && heightDelta < 0.5) return;
159
-
160
- // Detect repeated growth with identical content extents (suggests feedback loop)
161
- const curExtSignature = `${minX},${minY},${maxX},${maxY}`;
162
- if (this._lastContentExtents === curExtSignature && heightDelta > 0 && desiredH > curH) {
163
- return; // content unchanged, skip
164
- }
165
-
166
- // If locked, only allow substantial growth
167
- if (this._viewboxLocked) {
168
- const growW = desiredW - curW;
169
- const growH = desiredH - curH;
170
- if (growW < this._viewboxLockThreshold && growH < this._viewboxLockThreshold) {
171
- return; // ignore micro growth attempts
172
- }
173
- }
174
-
175
- const newViewBox = `${desiredX} ${desiredY} ${desiredW} ${desiredH}`;
176
- if (this._lastViewbox === newViewBox) return;
177
-
178
- this._suppressResizeObserver = true;
179
- try {
180
- this.svg.svgObject.setAttribute('viewBox', newViewBox);
181
- } finally {
182
- // Allow ResizeObserver events after microtask; use timeout to defer
183
- setTimeout(() => { this._suppressResizeObserver = false; }, 0);
184
- }
185
- this._lastViewbox = newViewBox;
186
- this._lastContentExtents = curExtSignature;
187
-
188
- // Lock if the growth applied was small; prevents future tiny increments
189
- if (heightDelta < 2 && widthDelta < 2 && !this._viewboxLocked) {
190
- this._viewboxLocked = true;
191
- }
192
- }
193
-
194
- /**
195
- * Walk the node tree and compute absolute extents in SVG coordinates.
196
- * Uses `xpos`/`ypos` and `width`/`height` properties; falls back to 0 when missing.
197
- * @param {omdNode} root
198
- * @returns {{minX:number,minY:number,maxX:number,maxY:number}}
199
- */
200
- _computeNodeExtents(root) {
201
- if (!root) return null;
202
- const visited = new Set();
203
- const stack = [{ node: root, absX: root.xpos || 0, absY: root.ypos || 0 }];
204
- let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
205
-
206
- while (stack.length) {
207
- const { node, absX, absY } = stack.pop();
208
- if (!node || visited.has(node)) continue;
209
- visited.add(node);
210
-
211
- const w = node.width || 0;
212
- const h = node.height || 0;
213
- const nx = absX;
214
- const ny = absY;
215
- minX = Math.min(minX, nx);
216
- minY = Math.min(minY, ny);
217
- maxX = Math.max(maxX, nx + w);
218
- maxY = Math.max(maxY, ny + h);
219
-
220
- // push children
221
- if (Array.isArray(node.childList)) {
222
- for (const c of node.childList) {
223
- if (!c) continue;
224
- const cx = (c.xpos || 0) + nx;
225
- const cy = (c.ypos || 0) + ny;
226
- stack.push({ node: c, absX: cx, absY: cy });
227
- }
228
- }
229
- if (node.argumentNodeList) {
230
- for (const val of Object.values(node.argumentNodeList)) {
231
- if (Array.isArray(val)) {
232
- for (const v of val) {
233
- if (!v) continue;
234
- const vx = (v.xpos || 0) + nx;
235
- const vy = (v.ypos || 0) + ny;
236
- stack.push({ node: v, absX: vx, absY: vy });
237
- }
238
- } else if (val) {
239
- const vx = (val.xpos || 0) + nx;
240
- const vy = (val.ypos || 0) + ny;
241
- stack.push({ node: val, absX: vx, absY: vy });
242
- }
243
- }
244
- }
245
- }
246
-
247
- if (minX === Infinity) return null;
248
- return { minX, minY, maxX, maxY };
249
- }
250
-
251
- /**
252
- * Collect extents for each node and return per-node list plus overall extents.
253
- * Useful for debugging elements that extend outside parent coordinates.
254
- * @param {omdNode} root
255
- * @returns {{nodes:Array, minX:number, minY:number, maxX:number, maxY:number}}
256
- */
257
- _collectNodeExtents(root) {
258
- if (!root) return null;
259
- const visited = new Set();
260
- const stack = [{ node: root, absX: root.xpos || 0, absY: root.ypos || 0, parent: null }];
261
- const nodes = [];
262
- let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
263
-
264
-
265
- while (stack.length) {
266
- const { node, absX, absY, parent } = stack.pop();
267
- if (!node || visited.has(node)) continue;
268
- visited.add(node);
269
-
270
- // Prefer DOM measurement if available for accuracy
271
- let nx = absX;
272
- let ny = absY;
273
- let nminX = nx;
274
- let nminY = ny;
275
- let nmaxX = nx;
276
- let nmaxY = ny;
277
-
278
- try {
279
- if (node.svgObject && typeof node.svgObject.getBBox === 'function' && typeof node.svgObject.getCTM === 'function') {
280
- const bbox = node.svgObject.getBBox();
281
- const ctm = node.svgObject.getCTM();
282
- // Transform all four bbox corners into root SVG coordinates
283
- const corners = [
284
- { x: bbox.x, y: bbox.y },
285
- { x: bbox.x + bbox.width, y: bbox.y },
286
- { x: bbox.x, y: bbox.y + bbox.height },
287
- { x: bbox.x + bbox.width, y: bbox.y + bbox.height }
288
- ];
289
- const tx = corners.map(p => ({
290
- x: ctm.a * p.x + ctm.c * p.y + ctm.e,
291
- y: ctm.b * p.x + ctm.d * p.y + ctm.f
292
- }));
293
- nminX = Math.min(...tx.map(t => t.x));
294
- nminY = Math.min(...tx.map(t => t.y));
295
- nmaxX = Math.max(...tx.map(t => t.x));
296
- nmaxY = Math.max(...tx.map(t => t.y));
297
- nx = nminX;
298
- ny = nminY;
299
- } else {
300
- const w = node.width || 0;
301
- const h = node.height || 0;
302
- nx = absX;
303
- ny = absY;
304
- nminX = nx;
305
- nminY = ny;
306
- nmaxX = nx + w;
307
- nmaxY = ny + h;
308
- }
309
- } catch (e) {
310
- const w = node.width || 0;
311
- const h = node.height || 0;
312
- nx = absX;
313
- ny = absY;
314
- nminX = nx;
315
- nminY = ny;
316
- nmaxX = nx + w;
317
- nmaxY = ny + h;
318
- }
319
-
320
- nodes.push({ node, minX: nminX, minY: nminY, maxX: nmaxX, maxY: nmaxY, parent });
321
-
322
- minX = Math.min(minX, nminX);
323
- minY = Math.min(minY, nminY);
324
- maxX = Math.max(maxX, nmaxX);
325
- maxY = Math.max(maxY, nmaxY);
326
-
327
- // push children
328
- if (Array.isArray(node.childList)) {
329
- for (const c of node.childList) {
330
- if (!c) continue;
331
- const cx = (c.xpos || 0) + nx;
332
- const cy = (c.ypos || 0) + ny;
333
- stack.push({ node: c, absX: cx, absY: cy, parent: node });
334
- }
335
- }
336
- if (node.argumentNodeList) {
337
- for (const val of Object.values(node.argumentNodeList)) {
338
- if (Array.isArray(val)) {
339
- for (const v of val) {
340
- if (!v) continue;
341
- const vx = (v.xpos || 0) + nx;
342
- const vy = (v.ypos || 0) + ny;
343
- stack.push({ node: v, absX: vx, absY: vy, parent: node });
344
- }
345
- } else if (val) {
346
- const vx = (val.xpos || 0) + nx;
347
- const vy = (val.ypos || 0) + ny;
348
- stack.push({ node: val, absX: vx, absY: vy, parent: node });
349
- }
350
- }
351
- }
352
- }
353
-
354
- if (minX === Infinity) return null;
355
- return { nodes, minX, minY, maxX, maxY };
356
- }
357
-
358
- _clearDebugOverlays() {
359
- if (!this.svg || !this.svg.svgObject) return;
360
- const existing = this.svg.svgObject.querySelector('#omd-debug-overlays');
361
- if (existing) existing.remove();
362
- }
363
-
364
- _drawDebugOverlays() {
365
- if (!this.options.debugExtents) return;
366
- if (!this.svg || !this.svg.svgObject || !this.node) return;
367
-
368
- this._clearDebugOverlays();
369
-
370
- const ns = 'http://www.w3.org/2000/svg';
371
- const group = document.createElementNS(ns, 'g');
372
- group.setAttribute('id', 'omd-debug-overlays');
373
- group.setAttribute('pointer-events', 'none');
374
-
375
- // overall node extents
376
- const collected = this._collectNodeExtents(this.node);
377
- if (!collected) return;
378
-
379
- const { nodes, minX, minY, maxX, maxY } = collected;
380
-
381
- // Draw content extents (blue dashed)
382
- const contentRect = document.createElementNS(ns, 'rect');
383
- contentRect.setAttribute('x', String(minX));
384
- contentRect.setAttribute('y', String(minY));
385
- contentRect.setAttribute('width', String(maxX - minX));
386
- contentRect.setAttribute('height', String(maxY - minY));
387
- contentRect.setAttribute('fill', 'none');
388
- contentRect.setAttribute('stroke', 'blue');
389
- contentRect.setAttribute('stroke-dasharray', '6 4');
390
- contentRect.setAttribute('stroke-width', '0.8');
391
- group.appendChild(contentRect);
392
-
393
- // Draw viewBox rect (orange)
394
- const curView = this.svg.svgObject.getAttribute('viewBox') || '';
395
- if (curView) {
396
- const parts = curView.split(/\s+/).map(Number).filter(n => !isNaN(n));
397
- if (parts.length === 4) {
398
- const [vx, vy, vw, vh] = parts;
399
- const vbRect = document.createElementNS(ns, 'rect');
400
- vbRect.setAttribute('x', String(vx));
401
- vbRect.setAttribute('y', String(vy));
402
- vbRect.setAttribute('width', String(vw));
403
- vbRect.setAttribute('height', String(vh));
404
- vbRect.setAttribute('fill', 'none');
405
- vbRect.setAttribute('stroke', 'orange');
406
- vbRect.setAttribute('stroke-width', '1');
407
- vbRect.setAttribute('opacity', '0.9');
408
- group.appendChild(vbRect);
409
- }
410
- }
411
-
412
- // Per-node boxes: green if inside parent, red if overflowing parent bounds
413
- const overflowing = [];
414
- for (const item of nodes) {
415
- const r = document.createElementNS(ns, 'rect');
416
- r.setAttribute('x', String(item.minX));
417
- r.setAttribute('y', String(item.minY));
418
- r.setAttribute('width', String(Math.max(0, item.maxX - item.minX)));
419
- r.setAttribute('height', String(Math.max(0, item.maxY - item.minY)));
420
- r.setAttribute('fill', 'none');
421
- r.setAttribute('stroke-width', '0.6');
422
-
423
- let stroke = 'green';
424
- if (item.parent) {
425
- const pMinX = (item.parent.xpos || 0) + (item.parent._absX || 0);
426
- const pMinY = (item.parent.ypos || 0) + (item.parent._absY || 0);
427
- // fallback compute parent's absX/Y from nodes list if available
428
- const parentEntry = nodes.find(n => n.node === item.parent);
429
- const pminX = parentEntry ? parentEntry.minX : pMinX;
430
- const pminY = parentEntry ? parentEntry.minY : pMinY;
431
- const pmaxX = parentEntry ? parentEntry.maxX : pminX + (item.parent.width || 0);
432
- const pmaxY = parentEntry ? parentEntry.maxY : pminY + (item.parent.height || 0);
433
-
434
- if (item.minX < pminX || item.minY < pminY || item.maxX > pmaxX || item.maxY > pmaxY) {
435
- stroke = 'red';
436
- overflowing.push({ node: item.node, bounds: item });
437
- }
438
- }
439
-
440
- r.setAttribute('stroke', stroke);
441
- r.setAttribute('opacity', stroke === 'red' ? '0.9' : '0.6');
442
- group.appendChild(r);
443
- }
444
-
445
- if (overflowing.length) {
446
- console.warn('omdDisplay: debugExtents found overflowing nodes:', overflowing.map(o => ({ type: o.node?.type, bounds: o.bounds })));
447
- }
448
-
449
- this.svg.svgObject.appendChild(group);
450
- }
451
-
452
- centerNode() {
453
- if (!this.node) return;
454
- if (!this._centerCallCount) this._centerCallCount = 0;
455
- this._centerCallCount++;
456
- if (this._centerCallCount > 500) {
457
- console.warn('omdDisplay: excessive centerNode calls detected; halting further centering to prevent loop');
458
- return;
459
- }
460
- const containerWidth = this.container.offsetWidth || 0;
461
- const containerHeight = this.container.offsetHeight || 0;
462
-
463
- // Early auto-close of step visualizer UI before measuring dimensions to avoid transient height inflation
464
- if (this.options.autoCloseStepVisualizer && this.node) {
465
- try {
466
- if (typeof this.node.forceCloseAll === 'function') {
467
- this.node.forceCloseAll();
468
- } else if (typeof this.node.closeAllTextBoxes === 'function') {
469
- this.node.closeAllTextBoxes();
470
- } else if (typeof this.node.closeActiveDot === 'function') {
471
- this.node.closeActiveDot();
472
- }
473
- } catch (e) { /* no-op */ }
474
- }
475
-
476
- // Determine actual content size (prefer sequence/current step when available)
477
- let contentWidth = this.node.width || 0;
478
- let contentHeight = this.node.height || 0;
479
- if (this.node.getSequence) {
480
- const seq = this.node.getSequence();
481
- if (seq) {
482
- if (seq.width && seq.height) {
483
- // For step visualizers, use sequenceWidth/Height instead of total dimensions to exclude visualizer elements from autoscale
484
- contentWidth = seq.sequenceWidth || seq.width;
485
- contentHeight = seq.sequenceHeight || seq.height;
486
- }
487
- if (seq.getCurrentStep) {
488
- const step = seq.getCurrentStep();
489
- if (step && step.width && step.height) {
490
- // For step visualizers, prioritize sequenceWidth/Height for dimension calculations
491
- const stepWidth = seq.sequenceWidth || step.width;
492
- const stepHeight = seq.sequenceHeight || step.height;
493
- contentWidth = Math.max(contentWidth, stepWidth);
494
- contentHeight = Math.max(contentHeight, stepHeight);
495
- }
496
- }
497
- }
498
- }
499
-
500
- // Compute scale to keep within bounds
501
- let scale = 1;
502
- if (this.options.autoScale && contentWidth > 0 && contentHeight > 0) {
503
- // Optionally close any open step visualizer textbox to prevent transient height expansion
504
- if (this.options.autoCloseStepVisualizer && this.node) {
505
- try {
506
- if (typeof this.node.closeActiveDot === 'function') {
507
- this.node.closeActiveDot();
508
- } else if (typeof this.node.closeAllTextBoxes === 'function') {
509
- this.node.closeAllTextBoxes();
510
- }
511
- } catch (e) { /* no-op */ }
512
- }
513
- // Detect step visualizer directly on node (getSequence returns underlying sequence only)
514
- let hasStepVisualizer = false;
515
- if (this.node) {
516
- const ctorName = this.node.constructor?.name;
517
- hasStepVisualizer = (ctorName === 'omdStepVisualizer') || this.node.type === 'omdStepVisualizer' || (typeof omdStepVisualizer !== 'undefined' && this.node instanceof omdStepVisualizer);
518
- }
519
-
520
- if (hasStepVisualizer) {
521
- // Preserve existing scale if already set on node; otherwise lock to 1.
522
- const existingScale = (this.node && typeof this.node.scale === 'number') ? this.node.scale : undefined;
523
- scale = (existingScale && existingScale > 0) ? existingScale : 1;
524
- } else {
525
- const hPad = this.options.edgePadding || 0;
526
- const vPadTop = this.options.topMargin || 0;
527
- const vPadBottom = this.options.bottomMargin || 0;
528
- // Reserve extra space for overlay toolbar if needed
529
- let reserveBottom = vPadBottom;
530
- if (this.node && typeof this.node.isToolbarOverlay === 'function' && this.node.isToolbarOverlay()) {
531
- const tH = (typeof this.node.getToolbarVisualHeight === 'function') ? this.node.getToolbarVisualHeight() : 0;
532
- reserveBottom += (tH + (this.node.getOverlayPadding ? this.node.getOverlayPadding() : 16));
533
- }
534
- const availW = Math.max(0, containerWidth - hPad * 2);
535
- const availH = Math.max(0, containerHeight - (vPadTop + reserveBottom));
536
- const sx = availW > 0 ? (availW / contentWidth) : 1;
537
- const sy = availH > 0 ? (availH / contentHeight) : 1;
538
- const maxScale = (typeof this.options.maxScale === 'number') ? this.options.maxScale : 1;
539
- scale = Math.min(sx, sy, maxScale);
540
- if (!isFinite(scale) || scale <= 0) scale = 1;
541
- }
542
- }
543
-
544
- // Apply scale
545
- if (typeof this.node.setScale === 'function') {
546
- this.node.setScale(scale);
547
- }
548
-
549
- // Compute X so that equals anchor (if present) is centered after scaling
550
- let x;
551
- if (this.node.type === 'omdEquationSequenceNode' && this.node.alignPointX !== undefined) {
552
- const screenCenterX = containerWidth / 2;
553
- x = screenCenterX - (this.node.alignPointX * scale);
554
- } else {
555
- const scaledWidth = contentWidth * scale;
556
- x = (containerWidth - scaledWidth) / 2;
557
- }
558
-
559
- // Decide whether positioning would move content outside container. If so,
560
- // prefer expanding the SVG viewBox instead of moving nodes.
561
- const scaledWidthFinal = contentWidth * scale;
562
- const scaledHeightFinal = contentHeight * scale;
563
- const totalNeededH = scaledHeightFinal + (this.options.topMargin || 0) + (this.options.bottomMargin || 0);
564
-
565
- const willOverflowHoriz = scaledWidthFinal > containerWidth;
566
- const willOverflowVert = totalNeededH > containerHeight;
567
-
568
- // Avoid looping if content dimension signature hasn't changed
569
- const contentSig = `${contentWidth}x${contentHeight}x${scale}`;
570
- if (this._lastCenterSignature === contentSig && !willOverflowHoriz && !willOverflowVert) {
571
- // Only update position; skip expensive ensureViewboxFits
572
- if (this.node.setPosition) this.node.setPosition(x, this.options.topMargin);
573
- return;
574
- }
575
-
576
- if (willOverflowHoriz || willOverflowVert) {
577
- // Set scale but do NOT reposition node (preserve its absolute positions).
578
- if (this.node.setScale) this.node.setScale(scale);
579
- // Expand viewBox to contain entire unscaled content so nothing is clipped.
580
- this._ensureViewboxFits(contentWidth, contentHeight);
581
- // Reposition overlay toolbar in case viewBox/container changed
582
- this._repositionOverlayToolbar();
583
-
584
- // If content still exceeds available height in the host, allow vertical scrolling
585
- if (willOverflowVert) {
586
- this.container.style.overflowY = 'auto';
587
- this.container.style.overflowX = 'hidden';
588
- } else {
589
- this.container.style.overflow = 'hidden';
590
- }
591
- if (this.options.debugExtents) this._drawDebugOverlays();
592
- } else {
593
- // Y is top margin; scaled content will grow downward
594
- this.node.setPosition(x, this.options.topMargin);
595
-
596
- // Reposition overlay toolbar (if any)
597
- this._repositionOverlayToolbar();
598
-
599
- // Ensure viewBox can contain the (unscaled) content to avoid clipping in some hosts
600
- this._ensureViewboxFits(contentWidth, contentHeight);
601
-
602
- if (totalNeededH > containerHeight) {
603
- // Let the host scroll vertically; keep horizontal overflow hidden to avoid layout shift
604
- this.container.style.overflowY = 'auto';
605
- this.container.style.overflowX = 'hidden';
606
- } else {
607
- this.container.style.overflow = 'hidden';
608
- }
609
- if (this.options.debugExtents) this._drawDebugOverlays();
610
- }
611
-
612
- this._lastCenterSignature = contentSig;
613
- }
614
-
615
- fitToContent() {
616
- if (!this.node) {
617
- return;
618
- }
619
-
620
- // Try to get actual rendered dimensions
621
- let actualWidth = 0;
622
- let actualHeight = 0;
623
-
624
- // Get both sequence and current step dimensions
625
- let sequenceWidth = 0, sequenceHeight = 0;
626
- let stepWidth = 0, stepHeight = 0;
627
-
628
- if (this.node.getSequence) {
629
- const sequence = this.node.getSequence();
630
- if (sequence && sequence.width && sequence.height) {
631
- // For step visualizers, use sequenceWidth/Height instead of total dimensions to exclude visualizer elements from autoscale
632
- sequenceWidth = sequence.sequenceWidth || sequence.width;
633
- sequenceHeight = sequence.sequenceHeight || sequence.height;
634
-
635
- // Check current step dimensions too
636
- if (sequence.getCurrentStep) {
637
- const currentStep = sequence.getCurrentStep();
638
- if (currentStep && currentStep.width && currentStep.height) {
639
- // For step visualizers, prioritize sequenceWidth/Height for dimension calculations
640
- stepWidth = sequence.sequenceWidth || currentStep.width;
641
- stepHeight = sequence.sequenceHeight || currentStep.height;
642
- }
643
- }
644
-
645
- // Use the larger of sequence or step dimensions
646
- actualWidth = Math.max(sequenceWidth, stepWidth);
647
- actualHeight = Math.max(sequenceHeight, stepHeight);
648
- }
649
- }
650
-
651
- // Fallback to node dimensions only if sequence/step dimensions aren't available
652
- if ((actualWidth === 0 || actualHeight === 0) && this.node.width && this.node.height) {
653
- actualWidth = this.node.width;
654
- actualHeight = this.node.height;
655
- }
656
-
657
- // Fallback dimensions
658
- if (actualWidth === 0 || actualHeight === 0) {
659
- actualWidth = 200;
660
- actualHeight = 60;
661
- }
662
-
663
- const padding = 10; // More comfortable padding to match user expectation
664
- const newWidth = actualWidth + (padding * 2);
665
- const newHeight = actualHeight + (padding * 2);
666
-
667
-
668
- // Position the content at the minimal padding offset FIRST
669
- if (this.node && this.node.setPosition) {
670
- this.node.setPosition(padding, padding);
671
- }
672
-
673
- // Update SVG dimensions with viewBox starting from 0,0 since we repositioned content
674
- this.svg.setViewbox(newWidth, newHeight);
675
- this.svg.setWidthAndHeight(newWidth, newHeight);
676
-
677
- // Update container
678
- this.container.style.width = `${newWidth}px`;
679
- this.container.style.height = `${newHeight}px`;
680
- if (this.options.debugExtents) this._drawDebugOverlays();
681
- else this._clearDebugOverlays();
682
- }
683
-
684
- /**
685
- * Renders a mathematical expression or equation
686
- * @param {string|omdNode} expression - Expression string or node
687
- * @returns {omdNode} The rendered node
688
- */
689
- render(expression) {
690
- // Clear previous node
691
- if (this.node) {
692
- if (this._contentGroup && this.node && this.node.svgObject) {
693
- this._contentGroup.removeChild(this.node.svgObject);
694
- } else {
695
- this.svg.removeChild(this.node);
696
- }
697
- }
698
-
699
- // Create node from expression
700
- if (typeof expression === 'string') {
701
- if (expression.includes(';')) {
702
- // Multiple equations
703
- const equationStrings = expression.split(';').filter(s => s.trim() !== '');
704
- const steps = equationStrings.map(str => omdEquationNode.fromString(str));
705
- this.node = new omdStepVisualizer(steps, this.options.styling || {});
706
- } else {
707
- // Single expression or equation
708
- if (expression.includes('=')) {
709
- const firstStep = omdEquationNode.fromString(expression);
710
- this.node = new omdStepVisualizer([firstStep], this.options.styling || {});
711
- } else {
712
- // Create node directly from expression
713
- const parsedAST = math.parse(expression);
714
- const NodeClass = getNodeForAST(parsedAST);
715
- const firstStep = new NodeClass(parsedAST);
716
- this.node = new omdStepVisualizer([firstStep], this.options.styling || {});
717
- }
718
- }
719
- } else {
720
- // Assume it's already a node
721
- this.node = expression;
722
- }
723
-
724
- // Initialize and render
725
- const sequence = this.node.getSequence ? this.node.getSequence() : null;
726
- if (sequence) {
727
- sequence.setFontSize(this.options.fontSize);
728
- // Apply filtering based on filterLevel
729
- sequence.updateStepsVisibility(step => (step.stepMark ?? 0) === sequence.getFilterLevel());
730
- }
731
- // Prefer appending the node's svgObject into our content group so DOM measurements are consistent
732
- if (this._contentGroup && this.node && this.node.svgObject) {
733
- try { this._contentGroup.appendChild(this.node.svgObject); } catch (e) { this.svg.addChild(this.node); }
734
- } else {
735
- this.svg.addChild(this.node);
736
- }
737
-
738
- // Apply any stored font settings
739
- if (this.options.fontFamily) {
740
- this.setFont(this.options.fontFamily, this.options.fontWeight || '400');
741
- }
742
-
743
- // Only use fitToContent for tight sizing when explicitly requested
744
- if (this.options.fitToContent) {
745
- this.fitToContent();
746
- } else if (this.options.centerContent) {
747
- this.centerNode();
748
- }
749
- // Ensure overlay toolbar is positioned initially
750
- this._repositionOverlayToolbar();
751
-
752
- // Also ensure the viewBox is large enough to contain the node (avoid clipping)
753
- const cw = (this.node && this.node.width) ? this.node.width : 0;
754
- const ch = (this.node && this.node.height) ? this.node.height : 0;
755
- this._ensureViewboxFits(cw, ch);
756
-
757
- if (this.options.debugExtents) this._drawDebugOverlays();
758
- else this._clearDebugOverlays();
759
-
760
- // Provide a default global refresh function if not present
761
- if (typeof window !== 'undefined' && !window.refreshDisplayAndFilters) {
762
- window.refreshDisplayAndFilters = () => {
763
- try {
764
- const node = this.getCurrentNode();
765
- const sequence = node?.getSequence ? node.getSequence() : null;
766
- if (sequence) {
767
- if (typeof sequence.simplifyAll === 'function') {
768
- sequence.simplifyAll();
769
- }
770
- if (typeof sequence.updateStepsVisibility === 'function') {
771
- sequence.updateStepsVisibility(step => (step.stepMark ?? 0) === 0);
772
- }
773
- if (typeof node.updateLayout === 'function') {
774
- node.updateLayout();
775
- }
776
- }
777
- if (this.options.centerContent) {
778
- this.centerNode();
779
- }
780
- } catch (e) {
781
- // no-op
782
- }
783
- };
784
- }
785
-
786
- return this.node;
787
- }
788
-
789
- /**
790
- * Add a jsvg child to the internal SVG container and optionally
791
- * trigger layout/centering.
792
- * @param {object} child - A jsvg node to add
793
- */
794
- addChild(child) {
795
- if (this._contentGroup && child && child.svgObject) {
796
- try { this._contentGroup.appendChild(child.svgObject); } catch (e) { this.svg.addChild(child); }
797
- } else {
798
- this.svg.addChild(child);
799
- }
800
-
801
- if (this.options.centerContent) this.centerNode();
802
-
803
- return child;
804
- }
805
-
806
- /**
807
- * Remove a child previously added to the internal SVG container.
808
- * @param {object} child
809
- */
810
- removeChild(child) {
811
- if (!this.svg) return;
812
- try {
813
- if (child && child.svgObject) {
814
- if (this._contentGroup && this._contentGroup.contains(child.svgObject)) {
815
- this._contentGroup.removeChild(child.svgObject);
816
- } else if (this.svg.svgObject && this.svg.svgObject.contains(child.svgObject)) {
817
- this.svg.svgObject.removeChild(child.svgObject);
818
- } else if (typeof this.svg.removeChild === 'function') {
819
- this.svg.removeChild(child);
820
- }
821
- } else if (typeof this.svg.removeChild === 'function') {
822
- this.svg.removeChild(child);
823
- }
824
- } catch (e) {
825
- // no-op
826
- }
827
- // If the removed child was the main node, clear reference
828
- if (this.node === child) this.node = null;
829
- }
830
-
831
- /**
832
- * Updates the display with a new node
833
- * @param {omdNode} newNode - The new node to display
834
- */
835
- update(newNode) {
836
- if (this.node) {
837
- if (this._contentGroup && this.node && this.node.svgObject && this._contentGroup.contains(this.node.svgObject)) {
838
- this._contentGroup.removeChild(this.node.svgObject);
839
- } else if (typeof this.svg.removeChild === 'function') {
840
- this.svg.removeChild(this.node);
841
- }
842
- }
843
-
844
- this.node = newNode;
845
- this.node.setFontSize(this.options.fontSize);
846
- this.node.initialize();
847
- if (this._contentGroup && this.node && this.node.svgObject) {
848
- try { this._contentGroup.appendChild(this.node.svgObject); } catch (e) { this.svg.addChild(this.node); }
849
- } else {
850
- this.svg.addChild(this.node);
851
- }
852
-
853
- if (this.options.centerContent) {
854
- this.centerNode();
855
- }
856
- // Ensure overlay toolbar is positioned on updates
857
- this._repositionOverlayToolbar();
858
- }
859
-
860
-
861
- /**
862
- * Sets the font size
863
- * @param {number} size - The font size
864
- */
865
- setFontSize(size) {
866
- this.options.fontSize = size;
867
- if (this.node) {
868
- // Apply font size - handle different node types
869
- if (this.node.getSequence && typeof this.node.getSequence === 'function') {
870
- // For omdEquationStack, set font size on the sequence
871
- this.node.getSequence().setFontSize(size);
872
- } else if (this.node.setFontSize && typeof this.node.setFontSize === 'function') {
873
- // For regular nodes with setFontSize method
874
- this.node.setFontSize(size);
875
- }
876
- this.node.initialize();
877
- if (this.options.centerContent) {
878
- this.centerNode();
879
- }
880
- }
881
- }
882
-
883
- /**
884
- * Sets the font family for all elements in the display
885
- * @param {string} fontFamily - CSS font-family string (e.g., '"Shantell Sans", cursive')
886
- * @param {string} fontWeight - CSS font-weight (default: '400')
887
- */
888
- setFont(fontFamily, fontWeight = '400') {
889
- if (this.svg?.svgObject) {
890
- const applyFont = (element) => {
891
- if (element.style) {
892
- element.style.fontFamily = fontFamily;
893
- element.style.fontWeight = fontWeight;
894
- }
895
- // Recursively apply to all children
896
- Array.from(element.children || []).forEach(applyFont);
897
- };
898
-
899
- // Apply font to the entire SVG
900
- applyFont(this.svg.svgObject);
901
-
902
- // Store font settings for future use
903
- this.options.fontFamily = fontFamily;
904
- this.options.fontWeight = fontWeight;
905
- }
906
- }
907
-
908
- /**
909
- * Clears the display
910
- */
911
- clear() {
912
- if (this.node) {
913
- this.svg.removeChild(this.node);
914
- this.node = null;
915
- }
916
- }
917
-
918
- /**
919
- * Destroys the renderer and cleans up resources
920
- */
921
- destroy() {
922
- this.clear();
923
- if (this.resizeObserver) {
924
- this.resizeObserver.disconnect();
925
- }
926
- if (this.container.contains(this.svg.svgObject)) {
927
- this.container.removeChild(this.svg.svgObject);
928
- }
929
- }
930
-
931
- /**
932
- * Repositions overlay toolbar if current node supports it
933
- * @private
934
- */
935
- _repositionOverlayToolbar() {
936
- // Use same width calculation as centering to ensure consistency
937
- const containerWidth = this.container.offsetWidth || 0;
938
- const containerHeight = this.container.offsetHeight || 0;
939
- const node = this.node;
940
- if (!node) return;
941
- const hasOverlayApi = typeof node.isToolbarOverlay === 'function' && typeof node.positionToolbarOverlay === 'function';
942
- if (hasOverlayApi && node.isToolbarOverlay()) {
943
- const padding = (typeof node.getOverlayPadding === 'function') ? node.getOverlayPadding() : 16;
944
- node.positionToolbarOverlay(containerWidth, containerHeight, padding);
945
- }
946
- }
947
-
948
- /**
949
- * Public API: returns the currently rendered root node (could be a step visualizer, sequence, or plain node)
950
- * @returns {object|null}
951
- */
952
- getCurrentNode() {
953
- return this.node;
954
- }
955
-
956
- /**
957
- * Returns the SVG element for the entire display.
958
- * @returns {SVGElement} The SVG element representing the display.
959
- */
960
- getSVG() {
961
- return this.svg.svgObject;
962
- }
1
+ import { omdEquationNode } from '../nodes/omdEquationNode.js';
2
+ import { omdStepVisualizer } from '../step-visualizer/omdStepVisualizer.js';
3
+ import { getNodeForAST } from '../core/omdUtilities.js';
4
+ import { jsvgContainer } from '@teachinglab/jsvg';
5
+
6
+ /**
7
+ * OMD Renderer - Handles rendering of mathematical expressions
8
+ * This class provides a cleaner API for rendering expressions without
9
+ * being tied to specific DOM elements or UI concerns.
10
+ */
11
+ export class omdDisplay {
12
+ constructor(container, options = {}) {
13
+ this.container = container;
14
+ this.options = {
15
+ fontSize: 32,
16
+ centerContent: true,
17
+ topMargin: 40,
18
+ bottomMargin: 16,
19
+ fitToContent: true, // Fit to content size by default
20
+ autoScale: false, // Don't auto-scale by default
21
+ maxScale: 1, // Do not upscale beyond 1 by default
22
+ edgePadding: 16, // Horizontal padding from edges when scaling
23
+ autoCloseStepVisualizer: true, // Close active step visualizer text boxes before autoscale to avoid shrink
24
+ ...options
25
+ };
26
+
27
+ // Create SVG container
28
+ this.svg = new jsvgContainer();
29
+ this.node = null;
30
+
31
+ // Internal guards to prevent recursive resize induced growth
32
+ this._suppressResizeObserver = false; // When true, _handleResize is a no-op
33
+ this._lastViewbox = null; // Cache last applied viewBox string
34
+ this._lastContentExtents = null; // Cache last measured content extents to detect real growth
35
+ this._viewboxLocked = false; // When true, suppress micro growth adjustments
36
+ this._viewboxLockThreshold = 8; // Require at least 8px growth once locked
37
+
38
+ // Set up the SVG
39
+ this._setupSVG();
40
+ }
41
+
42
+ _setupSVG() {
43
+ const width = this.container.offsetWidth || 800;
44
+ const height = this.container.offsetHeight || 600;
45
+
46
+ this.svg.setViewbox(width, height);
47
+ this.svg.svgObject.style.verticalAlign = "middle";
48
+ // Enable internal scrolling via native SVG scrolling if content overflows
49
+ this.svg.svgObject.style.overflow = 'hidden';
50
+ this.container.appendChild(this.svg.svgObject);
51
+
52
+ // Create a dedicated content group we can translate to compensate for
53
+ // viewBox origin changes (so expanding the origin doesn't visually move content).
54
+ try {
55
+ const ns = 'http://www.w3.org/2000/svg';
56
+ this._contentGroup = document.createElementNS(ns, 'g');
57
+ this._contentGroup.setAttribute('id', 'omd-content-root');
58
+ this.svg.svgObject.appendChild(this._contentGroup);
59
+ this._contentOffsetX = 0;
60
+ this._contentOffsetY = 0;
61
+ } catch (e) {
62
+ this._contentGroup = null;
63
+ }
64
+
65
+ // Handle resize
66
+ if (window.ResizeObserver) {
67
+ this.resizeObserver = new ResizeObserver(() => {
68
+ this._handleResize();
69
+ });
70
+ this.resizeObserver.observe(this.container);
71
+ }
72
+ }
73
+
74
+ _handleResize() {
75
+ if (this._suppressResizeObserver) return; // Prevent re-entrant resize loops
76
+ const width = this.container.offsetWidth;
77
+ const height = this.container.offsetHeight;
78
+ // Skip if size unchanged; avoids loops where internal changes trigger observer without real container delta
79
+ if (this._lastContainerWidth === width && this._lastContainerHeight === height) return;
80
+ this._lastContainerWidth = width;
81
+ this._lastContainerHeight = height;
82
+ this.svg.setViewbox(width, height);
83
+
84
+ if (this.options.centerContent && this.node) {
85
+ this.centerNode();
86
+ }
87
+
88
+ // Reposition overlay toolbar (if any) on resize
89
+ this._repositionOverlayToolbar();
90
+ if (this.options.debugExtents) this._drawDebugOverlays();
91
+ }
92
+
93
+ /**
94
+ * Ensure the internal SVG viewBox is at least as large as the provided content dimensions.
95
+ * This prevents clipping when content is larger than the current viewBox.
96
+ * @param {number} contentWidth
97
+ * @param {number} contentHeight
98
+ */
99
+ _ensureViewboxFits(contentWidth, contentHeight) {
100
+ // If caller provided just width/height, but we prefer extents, bail early
101
+ if (!this.node) return;
102
+ const pad = 10;
103
+
104
+ // Prefer DOM measured extents (accounts for strokes, transforms, children SVG geometry)
105
+ let ext = null;
106
+ try {
107
+ const collected = this._collectNodeExtents(this.node);
108
+ if (collected) {
109
+ ext = { minX: collected.minX, minY: collected.minY, maxX: collected.maxX, maxY: collected.maxY };
110
+ }
111
+ } catch (e) {
112
+ ext = null;
113
+ }
114
+ if (!ext) {
115
+ ext = this._computeNodeExtents(this.node);
116
+ }
117
+ if (!ext) return;
118
+
119
+ const minX = Math.floor(ext.minX - pad);
120
+ const minY = Math.floor(ext.minY - pad);
121
+ const maxX = Math.ceil(ext.maxX + pad);
122
+ const maxY = Math.ceil(ext.maxY + pad);
123
+
124
+ const curView = this.svg.svgObject.getAttribute('viewBox') || '';
125
+ let curX = 0, curY = 0, curW = 0, curH = 0;
126
+ if (curView) {
127
+ const parts = curView.split(/\s+/).map(Number).filter(n => !isNaN(n));
128
+ if (parts.length === 4) {
129
+ curX = parts[0];
130
+ curY = parts[1];
131
+ curW = parts[2];
132
+ curH = parts[3];
133
+ }
134
+ }
135
+
136
+ // To avoid shifting visible content, keep the current viewBox origin (curX,curY)
137
+ // and only expand width/height as needed. Changing the origin would change
138
+ // the mapping from SVG coordinates to screen coordinates and appear to move
139
+ // existing content.
140
+ const desiredX = curX;
141
+ const desiredY = curY;
142
+ const desiredRight = Math.max(curX + curW, maxX);
143
+ const desiredBottom = Math.max(curY + curH, maxY);
144
+ const desiredW = Math.max(curW, desiredRight - desiredX);
145
+ const desiredH = Math.max(curH, desiredBottom - desiredY);
146
+
147
+ // Guard: If the desired size change is negligible (< 0.5px), skip.
148
+ const widthDelta = Math.abs(desiredW - curW);
149
+ const heightDelta = Math.abs(desiredH - curH);
150
+
151
+ // Safety cap to avoid runaway expansion due to logic errors.
152
+ const MAX_DIM = 10000; // arbitrary large but finite limit
153
+ if (desiredW > MAX_DIM || desiredH > MAX_DIM) {
154
+ console.warn('omdDisplay: viewBox growth capped to prevent runaway expansion', desiredW, desiredH);
155
+ return;
156
+ }
157
+
158
+ if (widthDelta < 0.5 && heightDelta < 0.5) return;
159
+
160
+ // Detect repeated growth with identical content extents (suggests feedback loop)
161
+ const curExtSignature = `${minX},${minY},${maxX},${maxY}`;
162
+ if (this._lastContentExtents === curExtSignature && heightDelta > 0 && desiredH > curH) {
163
+ return; // content unchanged, skip
164
+ }
165
+
166
+ // If locked, only allow substantial growth
167
+ if (this._viewboxLocked) {
168
+ const growW = desiredW - curW;
169
+ const growH = desiredH - curH;
170
+ if (growW < this._viewboxLockThreshold && growH < this._viewboxLockThreshold) {
171
+ return; // ignore micro growth attempts
172
+ }
173
+ }
174
+
175
+ const newViewBox = `${desiredX} ${desiredY} ${desiredW} ${desiredH}`;
176
+ if (this._lastViewbox === newViewBox) return;
177
+
178
+ this._suppressResizeObserver = true;
179
+ try {
180
+ this.svg.svgObject.setAttribute('viewBox', newViewBox);
181
+ } finally {
182
+ // Allow ResizeObserver events after microtask; use timeout to defer
183
+ setTimeout(() => { this._suppressResizeObserver = false; }, 0);
184
+ }
185
+ this._lastViewbox = newViewBox;
186
+ this._lastContentExtents = curExtSignature;
187
+
188
+ // Lock if the growth applied was small; prevents future tiny increments
189
+ if (heightDelta < 2 && widthDelta < 2 && !this._viewboxLocked) {
190
+ this._viewboxLocked = true;
191
+ }
192
+ }
193
+
194
+ /**
195
+ * Walk the node tree and compute absolute extents in SVG coordinates.
196
+ * Uses `xpos`/`ypos` and `width`/`height` properties; falls back to 0 when missing.
197
+ * @param {omdNode} root
198
+ * @returns {{minX:number,minY:number,maxX:number,maxY:number}}
199
+ */
200
+ _computeNodeExtents(root) {
201
+ if (!root) return null;
202
+ const visited = new Set();
203
+ const stack = [{ node: root, absX: root.xpos || 0, absY: root.ypos || 0 }];
204
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
205
+
206
+ while (stack.length) {
207
+ const { node, absX, absY } = stack.pop();
208
+ if (!node || visited.has(node)) continue;
209
+ visited.add(node);
210
+
211
+ const w = node.width || 0;
212
+ const h = node.height || 0;
213
+ const nx = absX;
214
+ const ny = absY;
215
+ minX = Math.min(minX, nx);
216
+ minY = Math.min(minY, ny);
217
+ maxX = Math.max(maxX, nx + w);
218
+ maxY = Math.max(maxY, ny + h);
219
+
220
+ // push children
221
+ if (Array.isArray(node.childList)) {
222
+ for (const c of node.childList) {
223
+ if (!c) continue;
224
+ const cx = (c.xpos || 0) + nx;
225
+ const cy = (c.ypos || 0) + ny;
226
+ stack.push({ node: c, absX: cx, absY: cy });
227
+ }
228
+ }
229
+ if (node.argumentNodeList) {
230
+ for (const val of Object.values(node.argumentNodeList)) {
231
+ if (Array.isArray(val)) {
232
+ for (const v of val) {
233
+ if (!v) continue;
234
+ const vx = (v.xpos || 0) + nx;
235
+ const vy = (v.ypos || 0) + ny;
236
+ stack.push({ node: v, absX: vx, absY: vy });
237
+ }
238
+ } else if (val) {
239
+ const vx = (val.xpos || 0) + nx;
240
+ const vy = (val.ypos || 0) + ny;
241
+ stack.push({ node: val, absX: vx, absY: vy });
242
+ }
243
+ }
244
+ }
245
+ }
246
+
247
+ if (minX === Infinity) return null;
248
+ return { minX, minY, maxX, maxY };
249
+ }
250
+
251
+ /**
252
+ * Collect extents for each node and return per-node list plus overall extents.
253
+ * Useful for debugging elements that extend outside parent coordinates.
254
+ * @param {omdNode} root
255
+ * @returns {{nodes:Array, minX:number, minY:number, maxX:number, maxY:number}}
256
+ */
257
+ _collectNodeExtents(root) {
258
+ if (!root) return null;
259
+ const visited = new Set();
260
+ const stack = [{ node: root, absX: root.xpos || 0, absY: root.ypos || 0, parent: null }];
261
+ const nodes = [];
262
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
263
+
264
+
265
+ while (stack.length) {
266
+ const { node, absX, absY, parent } = stack.pop();
267
+ if (!node || visited.has(node)) continue;
268
+ visited.add(node);
269
+
270
+ // Prefer DOM measurement if available for accuracy
271
+ let nx = absX;
272
+ let ny = absY;
273
+ let nminX = nx;
274
+ let nminY = ny;
275
+ let nmaxX = nx;
276
+ let nmaxY = ny;
277
+
278
+ try {
279
+ if (node.svgObject && typeof node.svgObject.getBBox === 'function' && typeof node.svgObject.getCTM === 'function') {
280
+ const bbox = node.svgObject.getBBox();
281
+ const ctm = node.svgObject.getCTM();
282
+ // Transform all four bbox corners into root SVG coordinates
283
+ const corners = [
284
+ { x: bbox.x, y: bbox.y },
285
+ { x: bbox.x + bbox.width, y: bbox.y },
286
+ { x: bbox.x, y: bbox.y + bbox.height },
287
+ { x: bbox.x + bbox.width, y: bbox.y + bbox.height }
288
+ ];
289
+ const tx = corners.map(p => ({
290
+ x: ctm.a * p.x + ctm.c * p.y + ctm.e,
291
+ y: ctm.b * p.x + ctm.d * p.y + ctm.f
292
+ }));
293
+ nminX = Math.min(...tx.map(t => t.x));
294
+ nminY = Math.min(...tx.map(t => t.y));
295
+ nmaxX = Math.max(...tx.map(t => t.x));
296
+ nmaxY = Math.max(...tx.map(t => t.y));
297
+ nx = nminX;
298
+ ny = nminY;
299
+ } else {
300
+ const w = node.width || 0;
301
+ const h = node.height || 0;
302
+ nx = absX;
303
+ ny = absY;
304
+ nminX = nx;
305
+ nminY = ny;
306
+ nmaxX = nx + w;
307
+ nmaxY = ny + h;
308
+ }
309
+ } catch (e) {
310
+ const w = node.width || 0;
311
+ const h = node.height || 0;
312
+ nx = absX;
313
+ ny = absY;
314
+ nminX = nx;
315
+ nminY = ny;
316
+ nmaxX = nx + w;
317
+ nmaxY = ny + h;
318
+ }
319
+
320
+ nodes.push({ node, minX: nminX, minY: nminY, maxX: nmaxX, maxY: nmaxY, parent });
321
+
322
+ minX = Math.min(minX, nminX);
323
+ minY = Math.min(minY, nminY);
324
+ maxX = Math.max(maxX, nmaxX);
325
+ maxY = Math.max(maxY, nmaxY);
326
+
327
+ // push children
328
+ if (Array.isArray(node.childList)) {
329
+ for (const c of node.childList) {
330
+ if (!c) continue;
331
+ const cx = (c.xpos || 0) + nx;
332
+ const cy = (c.ypos || 0) + ny;
333
+ stack.push({ node: c, absX: cx, absY: cy, parent: node });
334
+ }
335
+ }
336
+ if (node.argumentNodeList) {
337
+ for (const val of Object.values(node.argumentNodeList)) {
338
+ if (Array.isArray(val)) {
339
+ for (const v of val) {
340
+ if (!v) continue;
341
+ const vx = (v.xpos || 0) + nx;
342
+ const vy = (v.ypos || 0) + ny;
343
+ stack.push({ node: v, absX: vx, absY: vy, parent: node });
344
+ }
345
+ } else if (val) {
346
+ const vx = (val.xpos || 0) + nx;
347
+ const vy = (val.ypos || 0) + ny;
348
+ stack.push({ node: val, absX: vx, absY: vy, parent: node });
349
+ }
350
+ }
351
+ }
352
+ }
353
+
354
+ if (minX === Infinity) return null;
355
+ return { nodes, minX, minY, maxX, maxY };
356
+ }
357
+
358
+ _clearDebugOverlays() {
359
+ if (!this.svg || !this.svg.svgObject) return;
360
+ const existing = this.svg.svgObject.querySelector('#omd-debug-overlays');
361
+ if (existing) existing.remove();
362
+ }
363
+
364
+ _drawDebugOverlays() {
365
+ if (!this.options.debugExtents) return;
366
+ if (!this.svg || !this.svg.svgObject || !this.node) return;
367
+
368
+ this._clearDebugOverlays();
369
+
370
+ const ns = 'http://www.w3.org/2000/svg';
371
+ const group = document.createElementNS(ns, 'g');
372
+ group.setAttribute('id', 'omd-debug-overlays');
373
+ group.setAttribute('pointer-events', 'none');
374
+
375
+ // overall node extents
376
+ const collected = this._collectNodeExtents(this.node);
377
+ if (!collected) return;
378
+
379
+ const { nodes, minX, minY, maxX, maxY } = collected;
380
+
381
+ // Draw content extents (blue dashed)
382
+ const contentRect = document.createElementNS(ns, 'rect');
383
+ contentRect.setAttribute('x', String(minX));
384
+ contentRect.setAttribute('y', String(minY));
385
+ contentRect.setAttribute('width', String(maxX - minX));
386
+ contentRect.setAttribute('height', String(maxY - minY));
387
+ contentRect.setAttribute('fill', 'none');
388
+ contentRect.setAttribute('stroke', 'blue');
389
+ contentRect.setAttribute('stroke-dasharray', '6 4');
390
+ contentRect.setAttribute('stroke-width', '0.8');
391
+ group.appendChild(contentRect);
392
+
393
+ // Draw viewBox rect (orange)
394
+ const curView = this.svg.svgObject.getAttribute('viewBox') || '';
395
+ if (curView) {
396
+ const parts = curView.split(/\s+/).map(Number).filter(n => !isNaN(n));
397
+ if (parts.length === 4) {
398
+ const [vx, vy, vw, vh] = parts;
399
+ const vbRect = document.createElementNS(ns, 'rect');
400
+ vbRect.setAttribute('x', String(vx));
401
+ vbRect.setAttribute('y', String(vy));
402
+ vbRect.setAttribute('width', String(vw));
403
+ vbRect.setAttribute('height', String(vh));
404
+ vbRect.setAttribute('fill', 'none');
405
+ vbRect.setAttribute('stroke', 'orange');
406
+ vbRect.setAttribute('stroke-width', '1');
407
+ vbRect.setAttribute('opacity', '0.9');
408
+ group.appendChild(vbRect);
409
+ }
410
+ }
411
+
412
+ // Per-node boxes: green if inside parent, red if overflowing parent bounds
413
+ const overflowing = [];
414
+ for (const item of nodes) {
415
+ const r = document.createElementNS(ns, 'rect');
416
+ r.setAttribute('x', String(item.minX));
417
+ r.setAttribute('y', String(item.minY));
418
+ r.setAttribute('width', String(Math.max(0, item.maxX - item.minX)));
419
+ r.setAttribute('height', String(Math.max(0, item.maxY - item.minY)));
420
+ r.setAttribute('fill', 'none');
421
+ r.setAttribute('stroke-width', '0.6');
422
+
423
+ let stroke = 'green';
424
+ if (item.parent) {
425
+ const pMinX = (item.parent.xpos || 0) + (item.parent._absX || 0);
426
+ const pMinY = (item.parent.ypos || 0) + (item.parent._absY || 0);
427
+ // fallback compute parent's absX/Y from nodes list if available
428
+ const parentEntry = nodes.find(n => n.node === item.parent);
429
+ const pminX = parentEntry ? parentEntry.minX : pMinX;
430
+ const pminY = parentEntry ? parentEntry.minY : pMinY;
431
+ const pmaxX = parentEntry ? parentEntry.maxX : pminX + (item.parent.width || 0);
432
+ const pmaxY = parentEntry ? parentEntry.maxY : pminY + (item.parent.height || 0);
433
+
434
+ if (item.minX < pminX || item.minY < pminY || item.maxX > pmaxX || item.maxY > pmaxY) {
435
+ stroke = 'red';
436
+ overflowing.push({ node: item.node, bounds: item });
437
+ }
438
+ }
439
+
440
+ r.setAttribute('stroke', stroke);
441
+ r.setAttribute('opacity', stroke === 'red' ? '0.9' : '0.6');
442
+ group.appendChild(r);
443
+ }
444
+
445
+ if (overflowing.length) {
446
+ console.warn('omdDisplay: debugExtents found overflowing nodes:', overflowing.map(o => ({ type: o.node?.type, bounds: o.bounds })));
447
+ }
448
+
449
+ this.svg.svgObject.appendChild(group);
450
+ }
451
+
452
+ centerNode() {
453
+ if (!this.node) return;
454
+ if (!this._centerCallCount) this._centerCallCount = 0;
455
+ this._centerCallCount++;
456
+ if (this._centerCallCount > 500) {
457
+ console.warn('omdDisplay: excessive centerNode calls detected; halting further centering to prevent loop');
458
+ return;
459
+ }
460
+ const containerWidth = this.container.offsetWidth || 0;
461
+ const containerHeight = this.container.offsetHeight || 0;
462
+
463
+ // Early auto-close of step visualizer UI before measuring dimensions to avoid transient height inflation
464
+ if (this.options.autoCloseStepVisualizer && this.node) {
465
+ try {
466
+ if (typeof this.node.forceCloseAll === 'function') {
467
+ this.node.forceCloseAll();
468
+ } else if (typeof this.node.closeAllTextBoxes === 'function') {
469
+ this.node.closeAllTextBoxes();
470
+ } else if (typeof this.node.closeActiveDot === 'function') {
471
+ this.node.closeActiveDot();
472
+ }
473
+ } catch (e) { /* no-op */ }
474
+ }
475
+
476
+ // Determine actual content size (prefer sequence/current step when available)
477
+ let contentWidth = this.node.width || 0;
478
+ let contentHeight = this.node.height || 0;
479
+ if (this.node.getSequence) {
480
+ const seq = this.node.getSequence();
481
+ if (seq) {
482
+ if (seq.width && seq.height) {
483
+ // For step visualizers, use sequenceWidth/Height instead of total dimensions to exclude visualizer elements from autoscale
484
+ contentWidth = seq.sequenceWidth || seq.width;
485
+ contentHeight = seq.sequenceHeight || seq.height;
486
+ }
487
+ if (seq.getCurrentStep) {
488
+ const step = seq.getCurrentStep();
489
+ if (step && step.width && step.height) {
490
+ // For step visualizers, prioritize sequenceWidth/Height for dimension calculations
491
+ const stepWidth = seq.sequenceWidth || step.width;
492
+ const stepHeight = seq.sequenceHeight || step.height;
493
+ contentWidth = Math.max(contentWidth, stepWidth);
494
+ contentHeight = Math.max(contentHeight, stepHeight);
495
+ }
496
+ }
497
+ }
498
+ }
499
+
500
+ // Compute scale to keep within bounds
501
+ let scale = 1;
502
+ if (this.options.autoScale && contentWidth > 0 && contentHeight > 0) {
503
+ // Optionally close any open step visualizer textbox to prevent transient height expansion
504
+ if (this.options.autoCloseStepVisualizer && this.node) {
505
+ try {
506
+ if (typeof this.node.closeActiveDot === 'function') {
507
+ this.node.closeActiveDot();
508
+ } else if (typeof this.node.closeAllTextBoxes === 'function') {
509
+ this.node.closeAllTextBoxes();
510
+ }
511
+ } catch (e) { /* no-op */ }
512
+ }
513
+ // Detect step visualizer directly on node (getSequence returns underlying sequence only)
514
+ let hasStepVisualizer = false;
515
+ if (this.node) {
516
+ const ctorName = this.node.constructor?.name;
517
+ hasStepVisualizer = (ctorName === 'omdStepVisualizer') || this.node.type === 'omdStepVisualizer' || (typeof omdStepVisualizer !== 'undefined' && this.node instanceof omdStepVisualizer);
518
+ }
519
+
520
+ if (hasStepVisualizer) {
521
+ // Preserve existing scale if already set on node; otherwise lock to 1.
522
+ const existingScale = (this.node && typeof this.node.scale === 'number') ? this.node.scale : undefined;
523
+ scale = (existingScale && existingScale > 0) ? existingScale : 1;
524
+ } else {
525
+ const hPad = this.options.edgePadding || 0;
526
+ const vPadTop = this.options.topMargin || 0;
527
+ const vPadBottom = this.options.bottomMargin || 0;
528
+ // Reserve extra space for overlay toolbar if needed
529
+ let reserveBottom = vPadBottom;
530
+ if (this.node && typeof this.node.isToolbarOverlay === 'function' && this.node.isToolbarOverlay()) {
531
+ const tH = (typeof this.node.getToolbarVisualHeight === 'function') ? this.node.getToolbarVisualHeight() : 0;
532
+ reserveBottom += (tH + (this.node.getOverlayPadding ? this.node.getOverlayPadding() : 16));
533
+ }
534
+ const availW = Math.max(0, containerWidth - hPad * 2);
535
+ const availH = Math.max(0, containerHeight - (vPadTop + reserveBottom));
536
+ const sx = availW > 0 ? (availW / contentWidth) : 1;
537
+ const sy = availH > 0 ? (availH / contentHeight) : 1;
538
+ const maxScale = (typeof this.options.maxScale === 'number') ? this.options.maxScale : 1;
539
+ scale = Math.min(sx, sy, maxScale);
540
+ if (!isFinite(scale) || scale <= 0) scale = 1;
541
+ }
542
+ }
543
+
544
+ // Apply scale
545
+ if (typeof this.node.setScale === 'function') {
546
+ this.node.setScale(scale);
547
+ }
548
+
549
+ // Compute X so that equals anchor (if present) is centered after scaling
550
+ let x;
551
+ if (this.node.type === 'omdEquationSequenceNode' && this.node.alignPointX !== undefined) {
552
+ const screenCenterX = containerWidth / 2;
553
+ x = screenCenterX - (this.node.alignPointX * scale);
554
+ } else {
555
+ const scaledWidth = contentWidth * scale;
556
+ x = (containerWidth - scaledWidth) / 2;
557
+ }
558
+
559
+ // Decide whether positioning would move content outside container. If so,
560
+ // prefer expanding the SVG viewBox instead of moving nodes.
561
+ const scaledWidthFinal = contentWidth * scale;
562
+ const scaledHeightFinal = contentHeight * scale;
563
+ const totalNeededH = scaledHeightFinal + (this.options.topMargin || 0) + (this.options.bottomMargin || 0);
564
+
565
+ const willOverflowHoriz = scaledWidthFinal > containerWidth;
566
+ const willOverflowVert = totalNeededH > containerHeight;
567
+
568
+ // Avoid looping if content dimension signature hasn't changed
569
+ const contentSig = `${contentWidth}x${contentHeight}x${scale}`;
570
+ if (this._lastCenterSignature === contentSig && !willOverflowHoriz && !willOverflowVert) {
571
+ // Only update position; skip expensive ensureViewboxFits
572
+ if (this.node.setPosition) this.node.setPosition(x, this.options.topMargin);
573
+ return;
574
+ }
575
+
576
+ if (willOverflowHoriz || willOverflowVert) {
577
+ // Set scale but do NOT reposition node (preserve its absolute positions).
578
+ if (this.node.setScale) this.node.setScale(scale);
579
+ // Expand viewBox to contain entire unscaled content so nothing is clipped.
580
+ this._ensureViewboxFits(contentWidth, contentHeight);
581
+ // Reposition overlay toolbar in case viewBox/container changed
582
+ this._repositionOverlayToolbar();
583
+
584
+ // If content still exceeds available height in the host, allow vertical scrolling
585
+ if (willOverflowVert) {
586
+ this.container.style.overflowY = 'auto';
587
+ this.container.style.overflowX = 'hidden';
588
+ } else {
589
+ this.container.style.overflow = 'hidden';
590
+ }
591
+ if (this.options.debugExtents) this._drawDebugOverlays();
592
+ } else {
593
+ // Y is top margin; scaled content will grow downward
594
+ this.node.setPosition(x, this.options.topMargin);
595
+
596
+ // Reposition overlay toolbar (if any)
597
+ this._repositionOverlayToolbar();
598
+
599
+ // Ensure viewBox can contain the (unscaled) content to avoid clipping in some hosts
600
+ this._ensureViewboxFits(contentWidth, contentHeight);
601
+
602
+ if (totalNeededH > containerHeight) {
603
+ // Let the host scroll vertically; keep horizontal overflow hidden to avoid layout shift
604
+ this.container.style.overflowY = 'auto';
605
+ this.container.style.overflowX = 'hidden';
606
+ } else {
607
+ this.container.style.overflow = 'hidden';
608
+ }
609
+ if (this.options.debugExtents) this._drawDebugOverlays();
610
+ }
611
+
612
+ this._lastCenterSignature = contentSig;
613
+ }
614
+
615
+ fitToContent() {
616
+ if (!this.node) {
617
+ return;
618
+ }
619
+
620
+ // Try to get actual rendered dimensions
621
+ let actualWidth = 0;
622
+ let actualHeight = 0;
623
+
624
+ // Get both sequence and current step dimensions
625
+ let sequenceWidth = 0, sequenceHeight = 0;
626
+ let stepWidth = 0, stepHeight = 0;
627
+
628
+ if (this.node.getSequence) {
629
+ const sequence = this.node.getSequence();
630
+ if (sequence && sequence.width && sequence.height) {
631
+ // For step visualizers, use sequenceWidth/Height instead of total dimensions to exclude visualizer elements from autoscale
632
+ sequenceWidth = sequence.sequenceWidth || sequence.width;
633
+ sequenceHeight = sequence.sequenceHeight || sequence.height;
634
+
635
+ // Check current step dimensions too
636
+ if (sequence.getCurrentStep) {
637
+ const currentStep = sequence.getCurrentStep();
638
+ if (currentStep && currentStep.width && currentStep.height) {
639
+ // For step visualizers, prioritize sequenceWidth/Height for dimension calculations
640
+ stepWidth = sequence.sequenceWidth || currentStep.width;
641
+ stepHeight = sequence.sequenceHeight || currentStep.height;
642
+ }
643
+ }
644
+
645
+ // Use the larger of sequence or step dimensions
646
+ actualWidth = Math.max(sequenceWidth, stepWidth);
647
+ actualHeight = Math.max(sequenceHeight, stepHeight);
648
+ }
649
+ }
650
+
651
+ // Fallback to node dimensions only if sequence/step dimensions aren't available
652
+ if ((actualWidth === 0 || actualHeight === 0) && this.node.width && this.node.height) {
653
+ actualWidth = this.node.width;
654
+ actualHeight = this.node.height;
655
+ }
656
+
657
+ // Fallback dimensions
658
+ if (actualWidth === 0 || actualHeight === 0) {
659
+ actualWidth = 200;
660
+ actualHeight = 60;
661
+ }
662
+
663
+ const padding = 10; // More comfortable padding to match user expectation
664
+ const newWidth = actualWidth + (padding * 2);
665
+ const newHeight = actualHeight + (padding * 2);
666
+
667
+
668
+ // Position the content at the minimal padding offset FIRST
669
+ if (this.node && this.node.setPosition) {
670
+ this.node.setPosition(padding, padding);
671
+ }
672
+
673
+ // Update SVG dimensions with viewBox starting from 0,0 since we repositioned content
674
+ this.svg.setViewbox(newWidth, newHeight);
675
+ this.svg.setWidthAndHeight(newWidth, newHeight);
676
+
677
+ // Update container
678
+ this.container.style.width = `${newWidth}px`;
679
+ this.container.style.height = `${newHeight}px`;
680
+ if (this.options.debugExtents) this._drawDebugOverlays();
681
+ else this._clearDebugOverlays();
682
+ }
683
+
684
+ /**
685
+ * s a mathematical expression or equation
686
+ * @param {string|omdNode} expression - Expression string or node
687
+ * @returns {omdNode} The rendered node
688
+ */
689
+ render(expression) {
690
+ // Clear previous node
691
+ if (this.node) {
692
+ if (this._contentGroup && this.node && this.node.svgObject) {
693
+ try {
694
+ if (this.node.svgObject.parentNode === this._contentGroup) {
695
+ this._contentGroup.removeChild(this.node.svgObject);
696
+ }
697
+ } catch (e) {
698
+ // Fallback to svg remove
699
+ this.svg.removeChild(this.node);
700
+ }
701
+ } else {
702
+ this.svg.removeChild(this.node);
703
+ }
704
+ }
705
+
706
+ // Create node from expression
707
+ if (typeof expression === 'string') {
708
+ if (expression.includes(';')) {
709
+ // Multiple equations
710
+ const equationStrings = expression.split(';').filter(s => s.trim() !== '');
711
+ const steps = equationStrings.map(str => omdEquationNode.fromString(str));
712
+ this.node = new omdStepVisualizer(steps, this.options.styling || {});
713
+ } else {
714
+ // Single expression or equation
715
+ if (expression.includes('=')) {
716
+ const firstStep = omdEquationNode.fromString(expression);
717
+ this.node = new omdStepVisualizer([firstStep], this.options.styling || {});
718
+ } else {
719
+ // Create node directly from expression
720
+ const parsedAST = math.parse(expression);
721
+ const NodeClass = getNodeForAST(parsedAST);
722
+ const firstStep = new NodeClass(parsedAST);
723
+ this.node = new omdStepVisualizer([firstStep], this.options.styling || {});
724
+ }
725
+ }
726
+ } else {
727
+ // Assume it's already a node
728
+ this.node = expression;
729
+ }
730
+
731
+ // Initialize and render
732
+ const sequence = this.node.getSequence ? this.node.getSequence() : null;
733
+ if (sequence) {
734
+ sequence.setFontSize(this.options.fontSize);
735
+ // Apply filtering based on filterLevel
736
+ sequence.updateStepsVisibility(step => (step.stepMark ?? 0) === sequence.getFilterLevel());
737
+ }
738
+ // Prefer appending the node's svgObject into our content group so DOM measurements are consistent
739
+ if (this._contentGroup && this.node && this.node.svgObject) {
740
+ try { this._contentGroup.appendChild(this.node.svgObject); } catch (e) { this.svg.addChild(this.node); }
741
+ } else {
742
+ this.svg.addChild(this.node);
743
+ }
744
+
745
+ // Apply any stored font settings
746
+ if (this.options.fontFamily) {
747
+ this.setFont(this.options.fontFamily, this.options.fontWeight || '400');
748
+ }
749
+
750
+ // Only use fitToContent for tight sizing when explicitly requested
751
+ if (this.options.fitToContent) {
752
+ this.fitToContent();
753
+ } else if (this.options.centerContent) {
754
+ this.centerNode();
755
+ }
756
+ // Ensure overlay toolbar is positioned initially
757
+ this._repositionOverlayToolbar();
758
+
759
+ // Also ensure the viewBox is large enough to contain the node (avoid clipping)
760
+ const cw = (this.node && this.node.width) ? this.node.width : 0;
761
+ const ch = (this.node && this.node.height) ? this.node.height : 0;
762
+ this._ensureViewboxFits(cw, ch);
763
+
764
+ if (this.options.debugExtents) this._drawDebugOverlays();
765
+ else this._clearDebugOverlays();
766
+
767
+ // Provide a default global refresh function if not present
768
+ if (typeof window !== 'undefined' && !window.refreshDisplayAndFilters) {
769
+ window.refreshDisplayAndFilters = () => {
770
+ try {
771
+ const node = this.getCurrentNode();
772
+ const sequence = node?.getSequence ? node.getSequence() : null;
773
+ if (sequence) {
774
+ if (typeof sequence.simplifyAll === 'function') {
775
+ sequence.simplifyAll();
776
+ }
777
+ if (typeof sequence.updateStepsVisibility === 'function') {
778
+ sequence.updateStepsVisibility(step => (step.stepMark ?? 0) === 0);
779
+ }
780
+ if (typeof node.updateLayout === 'function') {
781
+ node.updateLayout();
782
+ }
783
+ }
784
+ if (this.options.centerContent) {
785
+ this.centerNode();
786
+ }
787
+ } catch (e) {
788
+ // no-op
789
+ }
790
+ };
791
+ }
792
+
793
+ return this.node;
794
+ }
795
+
796
+ /**
797
+ * Add a jsvg child to the internal SVG container and optionally
798
+ * trigger layout/centering.
799
+ * @param {object} child - A jsvg node to add
800
+ */
801
+ addChild(child) {
802
+ if (this._contentGroup && child && child.svgObject) {
803
+ try { this._contentGroup.appendChild(child.svgObject); } catch (e) { this.svg.addChild(child); }
804
+ } else {
805
+ this.svg.addChild(child);
806
+ }
807
+
808
+ if (this.options.centerContent) this.centerNode();
809
+
810
+ return child;
811
+ }
812
+
813
+ /**
814
+ * Remove a child previously added to the internal SVG container.
815
+ * @param {object} child
816
+ */
817
+ removeChild(child) {
818
+ if (!this.svg) return;
819
+ try {
820
+ if (child && child.svgObject) {
821
+ if (this._contentGroup && this._contentGroup.contains(child.svgObject)) {
822
+ this._contentGroup.removeChild(child.svgObject);
823
+ } else if (this.svg.svgObject && this.svg.svgObject.contains(child.svgObject)) {
824
+ this.svg.svgObject.removeChild(child.svgObject);
825
+ } else if (typeof this.svg.removeChild === 'function') {
826
+ this.svg.removeChild(child);
827
+ }
828
+ } else if (typeof this.svg.removeChild === 'function') {
829
+ this.svg.removeChild(child);
830
+ }
831
+ } catch (e) {
832
+ // no-op
833
+ }
834
+ // If the removed child was the main node, clear reference
835
+ if (this.node === child) this.node = null;
836
+ }
837
+
838
+ /**
839
+ * Updates the display with a new node
840
+ * @param {omdNode} newNode - The new node to display
841
+ */
842
+ update(newNode) {
843
+ if (this.node) {
844
+ if (this._contentGroup && this.node && this.node.svgObject && this._contentGroup.contains(this.node.svgObject)) {
845
+ this._contentGroup.removeChild(this.node.svgObject);
846
+ } else if (typeof this.svg.removeChild === 'function') {
847
+ this.svg.removeChild(this.node);
848
+ }
849
+ }
850
+
851
+ this.node = newNode;
852
+ this.node.setFontSize(this.options.fontSize);
853
+ this.node.initialize();
854
+ if (this._contentGroup && this.node && this.node.svgObject) {
855
+ try { this._contentGroup.appendChild(this.node.svgObject); } catch (e) { this.svg.addChild(this.node); }
856
+ } else {
857
+ this.svg.addChild(this.node);
858
+ }
859
+
860
+ if (this.options.centerContent) {
861
+ this.centerNode();
862
+ }
863
+ // Ensure overlay toolbar is positioned on updates
864
+ this._repositionOverlayToolbar();
865
+ }
866
+
867
+
868
+ /**
869
+ * Sets the font size
870
+ * @param {number} size - The font size
871
+ */
872
+ setFontSize(size) {
873
+ this.options.fontSize = size;
874
+ if (this.node) {
875
+ // Apply font size - handle different node types
876
+ if (this.node.getSequence && typeof this.node.getSequence === 'function') {
877
+ // For omdEquationStack, set font size on the sequence
878
+ this.node.getSequence().setFontSize(size);
879
+ } else if (this.node.setFontSize && typeof this.node.setFontSize === 'function') {
880
+ // For regular nodes with setFontSize method
881
+ this.node.setFontSize(size);
882
+ }
883
+ this.node.initialize();
884
+ if (this.options.centerContent) {
885
+ this.centerNode();
886
+ }
887
+ }
888
+ }
889
+
890
+ /**
891
+ * Sets the font family for all elements in the display
892
+ * @param {string} fontFamily - CSS font-family string (e.g., '"Shantell Sans", cursive')
893
+ * @param {string} fontWeight - CSS font-weight (default: '400')
894
+ */
895
+ setFont(fontFamily, fontWeight = '400') {
896
+ if (this.svg?.svgObject) {
897
+ const applyFont = (element) => {
898
+ if (element.style) {
899
+ element.style.fontFamily = fontFamily;
900
+ element.style.fontWeight = fontWeight;
901
+ }
902
+ // Recursively apply to all children
903
+ Array.from(element.children || []).forEach(applyFont);
904
+ };
905
+
906
+ // Apply font to the entire SVG
907
+ applyFont(this.svg.svgObject);
908
+
909
+ // Store font settings for future use
910
+ this.options.fontFamily = fontFamily;
911
+ this.options.fontWeight = fontWeight;
912
+ }
913
+ }
914
+
915
+ /**
916
+ * Clears the display
917
+ */
918
+ clear() {
919
+ if (this.node) {
920
+ this.svg.removeChild(this.node);
921
+ this.node = null;
922
+ }
923
+ }
924
+
925
+ /**
926
+ * Destroys the renderer and cleans up resources
927
+ */
928
+ destroy() {
929
+ this.clear();
930
+ if (this.resizeObserver) {
931
+ this.resizeObserver.disconnect();
932
+ }
933
+ if (this.container.contains(this.svg.svgObject)) {
934
+ this.container.removeChild(this.svg.svgObject);
935
+ }
936
+ }
937
+
938
+ /**
939
+ * Repositions overlay toolbar if current node supports it
940
+ * @private
941
+ */
942
+ _repositionOverlayToolbar() {
943
+ // Use same width calculation as centering to ensure consistency
944
+ const containerWidth = this.container.offsetWidth || 0;
945
+ const containerHeight = this.container.offsetHeight || 0;
946
+ const node = this.node;
947
+ if (!node) return;
948
+ const hasOverlayApi = typeof node.isToolbarOverlay === 'function' && typeof node.positionToolbarOverlay === 'function';
949
+ if (hasOverlayApi && node.isToolbarOverlay()) {
950
+ const padding = (typeof node.getOverlayPadding === 'function') ? node.getOverlayPadding() : 16;
951
+ node.positionToolbarOverlay(containerWidth, containerHeight, padding);
952
+ }
953
+ }
954
+
955
+ /**
956
+ * Public API: returns the currently rendered root node (could be a step visualizer, sequence, or plain node)
957
+ * @returns {object|null}
958
+ */
959
+ getCurrentNode() {
960
+ return this.node;
961
+ }
962
+
963
+ /**
964
+ * Returns the SVG element for the entire display.
965
+ * @returns {SVGElement} The SVG element representing the display.
966
+ */
967
+ getSVG() {
968
+ return this.svg.svgObject;
969
+ }
963
970
  }