@teachinglab/omd 0.6.1 → 0.6.3

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