@teachinglab/omd 0.2.1 → 0.2.4

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.
@@ -67,6 +67,7 @@ export class omdEquationStack extends jsvgGroup {
67
67
  this.layoutGroup = new jsvgLayoutGroup();
68
68
  this.layoutGroup.setSpacer(16); // Adjust as needed for spacing
69
69
  this.layoutGroup.addChild(this.sequence);
70
+ this._overlayChildren = [];
70
71
 
71
72
  // Handle toolbar positioning
72
73
  const position = this.toolbarOptions?.position || this.options.toolbarPosition; // backward compat
@@ -201,6 +202,204 @@ export class omdEquationStack extends jsvgGroup {
201
202
  toolbarGroup.svgObject.style.display = 'block';
202
203
  toolbarGroup.svgObject.style.zIndex = '1000';
203
204
  }
205
+
206
+ // Update any overlay children so they remain locked to container anchors
207
+ try {
208
+ this.updateOverlayChildren(containerWidth, containerHeight, padding);
209
+ } catch (_) {}
210
+ }
211
+ /**
212
+ * General helper to position an overlay child relative to a chosen anchor.
213
+ * Does not assume only the toolbar; supports various anchors and options.
214
+ * @param {object} child - A jsvg node (or any object with setPosition/setScale)
215
+ * @param {number} containerWidth - Width of the container (px)
216
+ * @param {number} containerHeight - Height of the container (px)
217
+ * @param {object} [opts] - Options
218
+ * @param {string} [opts.anchor='toolbar-center'] - One of: 'toolbar-center','toolbar-left','toolbar-right','top-left','top-center','top-right','custom'
219
+ * @param {number} [opts.offsetX=0] - Horizontal offset in screen pixels (positive -> right)
220
+ * @param {number} [opts.offsetY=0] - Vertical offset in screen pixels (positive -> down)
221
+ * @param {number} [opts.padding=16] - Padding from edges when computing anchor
222
+ * @param {boolean} [opts.counterScale=true] - Whether to counter-scale the child to keep constant on-screen size
223
+ * @param {boolean} [opts.addToStack=true] - Whether to add the child to this stack's children (default true)
224
+ * @param {{x:number,y:number}|null} [opts.customCoords=null] - If anchor==='custom', use these screen coords
225
+ * @returns {object|null} The child or null if not applicable
226
+ */
227
+ addOverlayChild(child, containerWidth, containerHeight, opts = {}) {
228
+ const {
229
+ anchor = 'toolbar-center',
230
+ offsetX = 0,
231
+ offsetY = 0,
232
+ padding = 16,
233
+ counterScale = true,
234
+ addToStack = true,
235
+ customCoords = null
236
+ } = opts || {};
237
+
238
+ // Basic validation
239
+ if (!child || typeof containerWidth !== 'number' || typeof containerHeight !== 'number') return null;
240
+
241
+ const stackX = this.xpos || 0;
242
+ const stackY = this.ypos || 0;
243
+ const s = (typeof this.scale === 'number' && this.scale > 0) ? this.scale : 1;
244
+
245
+ // Determine base container (screen) coordinates for the anchor
246
+ let containerX = 0;
247
+ let containerY = 0;
248
+
249
+ if (anchor === 'custom' && customCoords && typeof customCoords.x === 'number' && typeof customCoords.y === 'number') {
250
+ containerX = Math.round(customCoords.x);
251
+ containerY = Math.round(customCoords.y);
252
+ } else if (anchor.startsWith('toolbar')) {
253
+ if (!this.toolbar) return null;
254
+ const tbW = this.toolbar.elements?.background?.width || 0;
255
+ const tbH = this.toolbar.elements?.background?.height || 0;
256
+ const left = (containerWidth - tbW) / 2;
257
+ const right = left + tbW;
258
+ const center = left + (tbW / 2);
259
+ containerY = Math.round(containerHeight - tbH - (typeof padding === 'number' ? padding : this.overlayPadding));
260
+ if (anchor === 'toolbar-center') containerX = Math.round(center);
261
+ else if (anchor === 'toolbar-left') containerX = Math.round(left);
262
+ else if (anchor === 'toolbar-right') containerX = Math.round(right);
263
+ else containerX = Math.round(center);
264
+ } else if (anchor.startsWith('top')) {
265
+ const topY = Math.round(typeof padding === 'number' ? padding : 16);
266
+ const leftX = Math.round(typeof padding === 'number' ? padding : 16);
267
+ const rightX = Math.round(containerWidth - (typeof padding === 'number' ? padding : 16));
268
+ containerY = topY;
269
+ if (anchor === 'top-left') containerX = leftX;
270
+ else if (anchor === 'top-center') containerX = Math.round(containerWidth / 2);
271
+ else if (anchor === 'top-right') containerX = rightX;
272
+ else containerX = leftX;
273
+ } else {
274
+ // fallback: center
275
+ containerX = Math.round(containerWidth / 2);
276
+ containerY = Math.round(containerHeight / 2);
277
+ }
278
+
279
+ // Apply offsets (in screen pixels)
280
+ containerX = Math.round(containerX + (offsetX || 0));
281
+ containerY = Math.round(containerY + (offsetY || 0));
282
+
283
+ // Convert to stack-local coordinates
284
+ const x = (containerX - stackX) / s;
285
+ const y = (containerY - stackY) / s;
286
+
287
+ // Optionally counter-scale child to keep constant on-screen size
288
+ if (counterScale && child && typeof child.setScale === 'function') {
289
+ try { child.setScale(1 / s); } catch (_) {}
290
+ }
291
+
292
+ // Position child in stack-local coords
293
+ if (child && typeof child.setPosition === 'function') {
294
+ try { child.setPosition(x, y); } catch (_) {}
295
+ }
296
+
297
+ // Optionally add to this stack
298
+ if (addToStack) {
299
+ try { this.addChild(child); } catch (_) {
300
+ try { this.layoutGroup.addChild(child); } catch (_) {}
301
+ }
302
+ }
303
+
304
+ // Remember the overlay child and its options so we can reposition it when
305
+ // the stack's scale/position changes (e.g., during zoom/center operations).
306
+ try {
307
+ // Store a shallow copy of opts to avoid external mutation surprises
308
+ const stored = { anchor, offsetX, offsetY, padding, counterScale, addToStack, customCoords };
309
+ this._overlayChildren.push({ child, opts: stored });
310
+ } catch (_) {}
311
+
312
+ // Make sure it's visible and above toolbar
313
+ if (child && child.svgObject) {
314
+ try { child.svgObject.style.zIndex = '1001'; } catch (_) {}
315
+ try { child.svgObject.style.display = 'block'; } catch (_) {}
316
+ }
317
+
318
+ return child;
319
+ }
320
+
321
+ /**
322
+ * Recompute and apply positions for tracked overlay children.
323
+ * Called automatically during `positionToolbarOverlay` and can be called
324
+ * manually if you change container size/stack position outside normal flows.
325
+ */
326
+ updateOverlayChildren(containerWidth, containerHeight, padding = 16) {
327
+ if (!Array.isArray(this._overlayChildren) || this._overlayChildren.length === 0) return;
328
+
329
+ const stackX = this.xpos || 0;
330
+ const stackY = this.ypos || 0;
331
+ const s = (typeof this.scale === 'number' && this.scale > 0) ? this.scale : 1;
332
+
333
+ for (const entry of this._overlayChildren) {
334
+ if (!entry || !entry.child) continue;
335
+ const child = entry.child;
336
+ const o = entry.opts || {};
337
+ const anchor = o.anchor || 'toolbar-center';
338
+ const offsetX = o.offsetX || 0;
339
+ const offsetY = o.offsetY || 0;
340
+ const pad = (typeof o.padding === 'number') ? o.padding : padding;
341
+ const counterScale = (typeof o.counterScale === 'boolean') ? o.counterScale : true;
342
+ const customCoords = o.customCoords || null;
343
+
344
+ // Compute container anchor coords (duplicated logic from addOverlayChild)
345
+ let containerX = 0;
346
+ let containerY = 0;
347
+ if (anchor === 'custom' && customCoords && typeof customCoords.x === 'number' && typeof customCoords.y === 'number') {
348
+ containerX = Math.round(customCoords.x + offsetX);
349
+ containerY = Math.round(customCoords.y + offsetY);
350
+ } else if (anchor.startsWith('toolbar')) {
351
+ if (!this.toolbar) continue;
352
+ const tbW = this.toolbar.elements?.background?.width || 0;
353
+ const tbH = this.toolbar.elements?.background?.height || 0;
354
+ const left = (containerWidth - tbW) / 2;
355
+ const right = left + tbW;
356
+ const center = left + (tbW / 2);
357
+ containerY = Math.round(containerHeight - tbH - pad + offsetY);
358
+ if (anchor === 'toolbar-center') containerX = Math.round(center + offsetX);
359
+ else if (anchor === 'toolbar-left') containerX = Math.round(left + offsetX);
360
+ else if (anchor === 'toolbar-right') containerX = Math.round(right + offsetX);
361
+ else containerX = Math.round(center + offsetX);
362
+ } else if (anchor.startsWith('top')) {
363
+ const topY = Math.round(pad);
364
+ const leftX = Math.round(pad);
365
+ const rightX = Math.round(containerWidth - pad);
366
+ containerY = topY + offsetY;
367
+ if (anchor === 'top-left') containerX = leftX + offsetX;
368
+ else if (anchor === 'top-center') containerX = Math.round(containerWidth / 2) + offsetX;
369
+ else if (anchor === 'top-right') containerX = rightX + offsetX;
370
+ else containerX = leftX + offsetX;
371
+ } else {
372
+ containerX = Math.round(containerWidth / 2 + offsetX);
373
+ containerY = Math.round(containerHeight / 2 + offsetY);
374
+ }
375
+
376
+ // Convert to stack-local coordinates
377
+ const x = (containerX - stackX) / s;
378
+ const y = (containerY - stackY) / s;
379
+
380
+ // Apply counter-scaling and position
381
+ if (counterScale && child && typeof child.setScale === 'function') {
382
+ try { child.setScale(1 / s); } catch (_) {}
383
+ }
384
+ if (child && typeof child.setPosition === 'function') {
385
+ try { child.setPosition(x, y); } catch (_) {}
386
+ }
387
+ }
388
+ }
389
+
390
+ /**
391
+ * Remove a previously added overlay child (if present).
392
+ */
393
+ removeOverlayChild(child) {
394
+ if (!child || !Array.isArray(this._overlayChildren)) return false;
395
+ let idx = -1;
396
+ for (let i = 0; i < this._overlayChildren.length; i++) {
397
+ if (this._overlayChildren[i].child === child) { idx = i; break; }
398
+ }
399
+ if (idx === -1) return false;
400
+ this._overlayChildren.splice(idx, 1);
401
+ try { this.removeChild(child); } catch (_) {}
402
+ return true;
204
403
  }
205
404
 
206
405
  /**
@@ -378,6 +378,8 @@ export class omdDisplay {
378
378
  }
379
379
  }
380
380
 
381
+
382
+
381
383
  /**
382
384
  * Sets the font size
383
385
  * @param {number} size - The font size
@@ -1,228 +1,228 @@
1
- import { omdNode } from "./omdNode.js";
2
- import { getNodeForAST } from "../core/omdUtilities.js";
3
- import { omdOperatorNode } from "./omdOperatorNode.js";
4
- import { omdConstantNode } from "./omdConstantNode.js";
5
- import { omdBinaryExpressionNode } from "./omdBinaryExpressionNode.js";
6
- import { simplifyStep } from "../simplification/omdSimplification.js";
7
-
8
- /**
9
- * Represents a unary expression, like negation (-(x+y)).
10
- * @extends omdNode
11
- */
12
- export class omdUnaryExpressionNode extends omdNode {
13
- /**
14
- * @param {Object} ast - The AST node from math.js.
15
- */
16
- constructor(ast) {
17
- super(ast);
18
- this.type = "omdUnaryExpressionNode";
19
- if (!ast.args || ast.args.length !== 1) {
20
- console.error("omdUnaryExpressionNode requires an AST node with exactly 1 argument", ast);
21
- return;
22
- }
23
-
24
- this.op = this.createOperatorNode(ast);
25
- this.operand = this.createExpressionNode(ast.args[0]);
26
-
27
- // Set the operation property for compatibility with SimplificationEngine
28
- this.operation = ast.fn || ast.op;
29
-
30
- // Populate the argumentNodeList for mathematical child nodes
31
- this.operand = this.operand;
32
- this.argument = this.operand;
33
- this.argumentNodeList.operand = this.operand;
34
- this.argumentNodeList.argument = this.argument;
35
-
36
- this.addChild(this.op);
37
- this.addChild(this.operand);
38
- }
39
-
40
- createOperatorNode(ast) {
41
- const opNode = new omdOperatorNode({ op: ast.op, fn: ast.fn });
42
- opNode.parent = this;
43
- return opNode;
44
- }
45
-
46
- createExpressionNode(ast) {
47
- const NodeClass = getNodeForAST(ast);
48
- const node = new NodeClass(ast);
49
- node.parent = this;
50
- return node;
51
- }
52
-
53
- computeDimensions() {
54
- this.op.computeDimensions();
55
- this.operand.computeDimensions();
56
-
57
- const opWidth = this.op.width;
58
- // No extra spacing for unary minus.
59
- const argWidth = this.operand.width;
60
-
61
- const totalWidth = opWidth + argWidth;
62
- const totalHeight = Math.max(this.op.height, this.operand.height);
63
-
64
- this.setWidthAndHeight(totalWidth, totalHeight);
65
- }
66
-
67
- updateLayout() {
68
- this.op.updateLayout();
69
- this.operand.updateLayout();
70
-
71
- const opY = (this.height - this.op.height) / 2;
72
- const argY = (this.height - this.operand.height) / 2;
73
-
74
- this.op.setPosition(0, opY);
75
- this.operand.setPosition(this.op.width, argY);
76
- }
77
-
78
- clone() {
79
- // Create a new node. The constructor will add a backRect and temporary children.
80
- const tempAst = { type: 'OperatorNode', op: '-', args: [{type: 'ConstantNode', value: 1}] };
81
- const clone = new omdUnaryExpressionNode(tempAst);
82
-
83
- // Keep the backRect, but get rid of the temporary children.
84
- const backRect = clone.backRect;
85
- clone.removeAllChildren();
86
- clone.addChild(backRect);
87
-
88
- // Manually clone the real children to ensure the entire tree has correct provenance.
89
- clone.op = this.op.clone();
90
- clone.addChild(clone.op);
91
-
92
- clone.operand = this.operand.clone();
93
- clone.addChild(clone.operand);
94
-
95
- // Ensure `argument` alias exists on the clone for compatibility
96
- clone.argument = clone.operand;
97
-
98
- // Rebuild the argument list and copy AST data.
99
- clone.argumentNodeList = { operand: clone.operand, argument: clone.argument };
100
- clone.astNodeData = JSON.parse(JSON.stringify(this.astNodeData));
101
-
102
- // The crucial step: link the clone to its origin.
103
- clone.provenance.push(this.id);
104
-
105
- return clone;
106
- }
107
-
108
- /**
109
- * A unary expression is constant if its operand is constant.
110
- * @returns {boolean}
111
- */
112
- isConstant() {
113
- return !!(this.operand && typeof this.operand.isConstant === 'function' && this.operand.isConstant());
114
- }
115
-
116
- /**
117
- * Get numeric value for unary expression (handles unary minus)
118
- * @returns {number}
119
- */
120
- getValue() {
121
- if (!this.isConstant()) throw new Error('Node is not a constant expression');
122
- const val = this.operand.getValue();
123
- if (this.operation === 'unaryMinus' || (this.op && this.op.opName === '-')) {
124
- return -val;
125
- }
126
- return val;
127
- }
128
-
129
- /**
130
- * If the operand represents a rational constant, return its rational pair adjusted for sign.
131
- */
132
- getRationalValue() {
133
- if (!this.isConstant()) throw new Error('Node is not a constant expression');
134
-
135
- // If operand supports getRationalValue, use it and adjust sign
136
- if (typeof this.operand.getRationalValue === 'function') {
137
- const { num, den } = this.operand.getRationalValue();
138
- if (this.operation === 'unaryMinus' || (this.op && this.op.opName === '-')) {
139
- return { num: -num, den };
140
- }
141
- return { num, den };
142
- }
143
-
144
- // Fallback: treat operand as integer rational
145
- const raw = this.operand.getValue();
146
- if (this.operation === 'unaryMinus' || (this.op && this.op.opName === '-')) {
147
- return { num: -raw, den: 1 };
148
- }
149
- return { num: raw, den: 1 };
150
- }
151
-
152
- /**
153
- * Converts the omdUnaryExpressionNode to a math.js AST node.
154
- * @returns {Object} A math.js-compatible AST node.
155
- */
156
- toMathJSNode() {
157
- const astNode = {
158
- type: 'OperatorNode',
159
- op: this.op.opName,
160
- fn: this.operation,
161
- args: [this.operand.toMathJSNode()],
162
- id: this.id,
163
- provenance: this.provenance
164
- };
165
-
166
- // Add a clone method to maintain compatibility with math.js's expectations.
167
- astNode.clone = function() {
168
- const clonedNode = { ...this };
169
- clonedNode.args = this.args.map(arg => arg.clone());
170
- return clonedNode;
171
- };
172
- return astNode;
173
- }
174
-
175
- /**
176
- * Converts the unary expression node to a string.
177
- * @returns {string} The string representation of the expression.
178
- */
179
- toString() {
180
- const operandStr = this.operand.toString();
181
- if (this.needsParentheses()) {
182
- return `${this.op.opName}(${operandStr})`;
183
- }
184
- return `${this.op.opName}${operandStr}`;
185
- }
186
-
187
- /**
188
- * Check if the operand needs parentheses.
189
- * @returns {boolean} Whether parentheses are required
190
- */
191
- needsParentheses() {
192
- // Parentheses are needed if the operand is a binary expression.
193
- return this.operand.type === 'omdBinaryExpressionNode';
194
- }
195
-
196
- /**
197
- * Evaluate the negation.
198
- * @param {Object} variables - Variable name to value mapping
199
- * @returns {number} The negated result
200
- */
201
- evaluate(variables = {}) {
202
- if (!this.operand.evaluate) return NaN;
203
- const value = this.operand.evaluate(variables);
204
- if (this.op.opName === '-') {
205
- return -value;
206
- }
207
- return value; // For other potential unary ops like '+'
208
- }
209
-
210
- /**
211
- * Create a negation node from a string.
212
- * @static
213
- * @param {string} expressionString - Expression with negation
214
- * @returns {omdUnaryExpressionNode}
215
- */
216
- static fromString(expressionString) {
217
- try {
218
- const ast = window.math.parse(expressionString);
219
- if (ast.type === 'OperatorNode' && ast.fn === 'unaryMinus') {
220
- return new omdUnaryExpressionNode(ast);
221
- }
222
- throw new Error("Expression is not a unary minus operation.");
223
- } catch (error) {
224
- console.error("Failed to create unary expression node from string:", error);
225
- throw error;
226
- }
227
- }
1
+ import { omdNode } from "./omdNode.js";
2
+ import { getNodeForAST } from "../core/omdUtilities.js";
3
+ import { omdOperatorNode } from "./omdOperatorNode.js";
4
+ import { omdConstantNode } from "./omdConstantNode.js";
5
+ import { omdBinaryExpressionNode } from "./omdBinaryExpressionNode.js";
6
+ import { simplifyStep } from "../simplification/omdSimplification.js";
7
+
8
+ /**
9
+ * Represents a unary expression, like negation (-(x+y)).
10
+ * @extends omdNode
11
+ */
12
+ export class omdUnaryExpressionNode extends omdNode {
13
+ /**
14
+ * @param {Object} ast - The AST node from math.js.
15
+ */
16
+ constructor(ast) {
17
+ super(ast);
18
+ this.type = "omdUnaryExpressionNode";
19
+ if (!ast.args || ast.args.length !== 1) {
20
+ console.error("omdUnaryExpressionNode requires an AST node with exactly 1 argument", ast);
21
+ return;
22
+ }
23
+
24
+ this.op = this.createOperatorNode(ast);
25
+ this.operand = this.createExpressionNode(ast.args[0]);
26
+
27
+ // Set the operation property for compatibility with SimplificationEngine
28
+ this.operation = ast.fn || ast.op;
29
+
30
+ // Populate the argumentNodeList for mathematical child nodes
31
+ this.operand = this.operand;
32
+ this.argument = this.operand;
33
+ this.argumentNodeList.operand = this.operand;
34
+ this.argumentNodeList.argument = this.argument;
35
+
36
+ this.addChild(this.op);
37
+ this.addChild(this.operand);
38
+ }
39
+
40
+ createOperatorNode(ast) {
41
+ const opNode = new omdOperatorNode({ op: ast.op, fn: ast.fn });
42
+ opNode.parent = this;
43
+ return opNode;
44
+ }
45
+
46
+ createExpressionNode(ast) {
47
+ const NodeClass = getNodeForAST(ast);
48
+ const node = new NodeClass(ast);
49
+ node.parent = this;
50
+ return node;
51
+ }
52
+
53
+ computeDimensions() {
54
+ this.op.computeDimensions();
55
+ this.operand.computeDimensions();
56
+
57
+ const opWidth = this.op.width;
58
+ // No extra spacing for unary minus.
59
+ const argWidth = this.operand.width;
60
+
61
+ const totalWidth = opWidth + argWidth;
62
+ const totalHeight = Math.max(this.op.height, this.operand.height);
63
+
64
+ this.setWidthAndHeight(totalWidth, totalHeight);
65
+ }
66
+
67
+ updateLayout() {
68
+ this.op.updateLayout();
69
+ this.operand.updateLayout();
70
+
71
+ const opY = (this.height - this.op.height) / 2;
72
+ const argY = (this.height - this.operand.height) / 2;
73
+
74
+ this.op.setPosition(0, opY);
75
+ this.operand.setPosition(this.op.width, argY);
76
+ }
77
+
78
+ clone() {
79
+ // Create a new node. The constructor will add a backRect and temporary children.
80
+ const tempAst = { type: 'OperatorNode', op: '-', args: [{type: 'ConstantNode', value: 1}] };
81
+ const clone = new omdUnaryExpressionNode(tempAst);
82
+
83
+ // Keep the backRect, but get rid of the temporary children.
84
+ const backRect = clone.backRect;
85
+ clone.removeAllChildren();
86
+ clone.addChild(backRect);
87
+
88
+ // Manually clone the real children to ensure the entire tree has correct provenance.
89
+ clone.op = this.op.clone();
90
+ clone.addChild(clone.op);
91
+
92
+ clone.operand = this.operand.clone();
93
+ clone.addChild(clone.operand);
94
+
95
+ // Ensure `argument` alias exists on the clone for compatibility
96
+ clone.argument = clone.operand;
97
+
98
+ // Rebuild the argument list and copy AST data.
99
+ clone.argumentNodeList = { operand: clone.operand, argument: clone.argument };
100
+ clone.astNodeData = JSON.parse(JSON.stringify(this.astNodeData));
101
+
102
+ // The crucial step: link the clone to its origin.
103
+ clone.provenance.push(this.id);
104
+
105
+ return clone;
106
+ }
107
+
108
+ /**
109
+ * A unary expression is constant if its operand is constant.
110
+ * @returns {boolean}
111
+ */
112
+ isConstant() {
113
+ return !!(this.operand && typeof this.operand.isConstant === 'function' && this.operand.isConstant());
114
+ }
115
+
116
+ /**
117
+ * Get numeric value for unary expression (handles unary minus)
118
+ * @returns {number}
119
+ */
120
+ getValue() {
121
+ if (!this.isConstant()) throw new Error('Node is not a constant expression');
122
+ const val = this.operand.getValue();
123
+ if (this.operation === 'unaryMinus' || (this.op && this.op.opName === '-')) {
124
+ return -val;
125
+ }
126
+ return val;
127
+ }
128
+
129
+ /**
130
+ * If the operand represents a rational constant, return its rational pair adjusted for sign.
131
+ */
132
+ getRationalValue() {
133
+ if (!this.isConstant()) throw new Error('Node is not a constant expression');
134
+
135
+ // If operand supports getRationalValue, use it and adjust sign
136
+ if (typeof this.operand.getRationalValue === 'function') {
137
+ const { num, den } = this.operand.getRationalValue();
138
+ if (this.operation === 'unaryMinus' || (this.op && this.op.opName === '-')) {
139
+ return { num: -num, den };
140
+ }
141
+ return { num, den };
142
+ }
143
+
144
+ // Fallback: treat operand as integer rational
145
+ const raw = this.operand.getValue();
146
+ if (this.operation === 'unaryMinus' || (this.op && this.op.opName === '-')) {
147
+ return { num: -raw, den: 1 };
148
+ }
149
+ return { num: raw, den: 1 };
150
+ }
151
+
152
+ /**
153
+ * Converts the omdUnaryExpressionNode to a math.js AST node.
154
+ * @returns {Object} A math.js-compatible AST node.
155
+ */
156
+ toMathJSNode() {
157
+ const astNode = {
158
+ type: 'OperatorNode',
159
+ op: this.op.opName,
160
+ fn: this.operation,
161
+ args: [this.operand.toMathJSNode()],
162
+ id: this.id,
163
+ provenance: this.provenance
164
+ };
165
+
166
+ // Add a clone method to maintain compatibility with math.js's expectations.
167
+ astNode.clone = function() {
168
+ const clonedNode = { ...this };
169
+ clonedNode.args = this.args.map(arg => arg.clone());
170
+ return clonedNode;
171
+ };
172
+ return astNode;
173
+ }
174
+
175
+ /**
176
+ * Converts the unary expression node to a string.
177
+ * @returns {string} The string representation of the expression.
178
+ */
179
+ toString() {
180
+ const operandStr = this.operand.toString();
181
+ if (this.needsParentheses()) {
182
+ return `${this.op.opName}(${operandStr})`;
183
+ }
184
+ return `${this.op.opName}${operandStr}`;
185
+ }
186
+
187
+ /**
188
+ * Check if the operand needs parentheses.
189
+ * @returns {boolean} Whether parentheses are required
190
+ */
191
+ needsParentheses() {
192
+ // Parentheses are needed if the operand is a binary expression.
193
+ return this.operand.type === 'omdBinaryExpressionNode';
194
+ }
195
+
196
+ /**
197
+ * Evaluate the negation.
198
+ * @param {Object} variables - Variable name to value mapping
199
+ * @returns {number} The negated result
200
+ */
201
+ evaluate(variables = {}) {
202
+ if (!this.operand.evaluate) return NaN;
203
+ const value = this.operand.evaluate(variables);
204
+ if (this.op.opName === '-') {
205
+ return -value;
206
+ }
207
+ return value; // For other potential unary ops like '+'
208
+ }
209
+
210
+ /**
211
+ * Create a negation node from a string.
212
+ * @static
213
+ * @param {string} expressionString - Expression with negation
214
+ * @returns {omdUnaryExpressionNode}
215
+ */
216
+ static fromString(expressionString) {
217
+ try {
218
+ const ast = window.math.parse(expressionString);
219
+ if (ast.type === 'OperatorNode' && ast.fn === 'unaryMinus') {
220
+ return new omdUnaryExpressionNode(ast);
221
+ }
222
+ throw new Error("Expression is not a unary minus operation.");
223
+ } catch (error) {
224
+ console.error("Failed to create unary expression node from string:", error);
225
+ throw error;
226
+ }
227
+ }
228
228
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teachinglab/omd",
3
- "version": "0.2.1",
3
+ "version": "0.2.4",
4
4
  "description": "omd",
5
5
  "main": "./index.js",
6
6
  "module": "./index.js",
@@ -130,6 +130,19 @@ export class omdCoordinatePlane extends jsvgGroup {
130
130
  const firstTick = Math.ceil(min);
131
131
  const lastTick = Math.floor(max);
132
132
  const minLabelSpacing = 10;
133
+ const labelWidthEstimate = 20; // matches createNumericLabel width
134
+ const requiredLabelSpacing = minLabelSpacing + labelWidthEstimate;
135
+
136
+ // Compute label interval for label display only
137
+ let labelInterval = interval;
138
+ if (!this.forceAllTickLabels) {
139
+ const span = isXAxis ? this.xMax - this.xMin : this.yMax - this.yMin;
140
+ let floored = Math.floor(span / 10) * 10;
141
+ let raw = floored / 6;
142
+ let rounded = Math.round(raw / 5) * 5;
143
+ if (rounded === 0) rounded = 1;
144
+ labelInterval = rounded;
145
+ }
133
146
 
134
147
  let lastLabelPos = -Infinity;
135
148
  let zeroLineDrawn = false;
@@ -147,16 +160,28 @@ export class omdCoordinatePlane extends jsvgGroup {
147
160
  zeroLineDrawn = true;
148
161
  }
149
162
 
150
- const isEdgeTick = value === firstTick || value === lastTick;
151
- const shouldShowLabel = this.showTickLabels && (
152
- this.forceAllTickLabels ||
153
- isEdgeTick ||
154
- Math.abs(pos - lastLabelPos) >= minLabelSpacing
155
- );
163
+ let shouldShowLabel = false;
164
+ if (this.showTickLabels) {
165
+ if (this.forceAllTickLabels) {
166
+ shouldShowLabel = true;
167
+ } else {
168
+ // Only show label if value is a multiple of labelInterval
169
+ const eps = 1e-9;
170
+ const isMultiple = (v, candidate) => Math.abs(v / candidate - Math.round(v / candidate)) < eps;
171
+ shouldShowLabel = isMultiple(value, labelInterval);
172
+ }
173
+ }
156
174
 
157
175
  if (shouldShowLabel) {
158
176
  this.addTickLabel(gridHolder, isXAxis, pos, value);
159
- lastLabelPos = pos;
177
+ // store the actual clamped center used by addTickLabel
178
+ if (isXAxis) {
179
+ const center = Math.max(20, Math.min(pos, this.width - 20));
180
+ lastLabelPos = center;
181
+ } else {
182
+ const center = Math.max(15, Math.min(pos, this.height - 7.5));
183
+ lastLabelPos = center;
184
+ }
160
185
  }
161
186
  }
162
187