@teachinglab/omd 0.2.2 → 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.
- package/omd/core/omdEquationStack.js +199 -0
- package/omd/display/omdDisplay.js +2 -0
- package/omd/nodes/omdUnaryExpressionNode.js +227 -227
- package/package.json +1 -1
- package/src/omdCoordinatePlane.js +25 -28
|
@@ -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
|
/**
|
|
@@ -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
|
@@ -130,29 +130,20 @@ 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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
const isEdgeTick = v === firstTick || v === lastTick;
|
|
146
|
-
if (isEdgeTick || Number.isInteger(v / candidate)) {
|
|
147
|
-
const p = this.computeAxisPos(isXAxis, v);
|
|
148
|
-
if (Math.abs(p - lastPos) < minLabelSpacing) { ok = false; break; }
|
|
149
|
-
positions.push(p);
|
|
150
|
-
lastPos = p;
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
if (ok && positions.length > 0) { labelMultiple = candidate; break; }
|
|
154
|
-
}
|
|
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;
|
|
155
145
|
}
|
|
146
|
+
|
|
156
147
|
let lastLabelPos = -Infinity;
|
|
157
148
|
let zeroLineDrawn = false;
|
|
158
149
|
|
|
@@ -169,22 +160,28 @@ export class omdCoordinatePlane extends jsvgGroup {
|
|
|
169
160
|
zeroLineDrawn = true;
|
|
170
161
|
}
|
|
171
162
|
|
|
172
|
-
const isEdgeTick = value === firstTick || value === lastTick;
|
|
173
|
-
|
|
174
163
|
let shouldShowLabel = false;
|
|
175
164
|
if (this.showTickLabels) {
|
|
176
165
|
if (this.forceAllTickLabels) {
|
|
177
166
|
shouldShowLabel = true;
|
|
178
|
-
} else if (labelMultiple) {
|
|
179
|
-
shouldShowLabel = isEdgeTick || Number.isInteger(value / labelMultiple);
|
|
180
167
|
} else {
|
|
181
|
-
|
|
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);
|
|
182
172
|
}
|
|
183
173
|
}
|
|
184
174
|
|
|
185
175
|
if (shouldShowLabel) {
|
|
186
176
|
this.addTickLabel(gridHolder, isXAxis, pos, value);
|
|
187
|
-
|
|
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
|
+
}
|
|
188
185
|
}
|
|
189
186
|
}
|
|
190
187
|
|