@teachinglab/omd 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -28,7 +28,10 @@ export class omdUnaryExpressionNode extends omdNode {
28
28
  this.operation = ast.fn || ast.op;
29
29
 
30
30
  // Populate the argumentNodeList for mathematical child nodes
31
+ this.operand = this.operand;
32
+ this.argument = this.operand;
31
33
  this.argumentNodeList.operand = this.operand;
34
+ this.argumentNodeList.argument = this.argument;
32
35
 
33
36
  this.addChild(this.op);
34
37
  this.addChild(this.operand);
@@ -83,14 +86,17 @@ export class omdUnaryExpressionNode extends omdNode {
83
86
  clone.addChild(backRect);
84
87
 
85
88
  // Manually clone the real children to ensure the entire tree has correct provenance.
86
- clone.op = this.op.clone();
87
- clone.addChild(clone.op);
89
+ clone.op = this.op.clone();
90
+ clone.addChild(clone.op);
88
91
 
89
- clone.operand = this.operand.clone();
90
- clone.addChild(clone.operand);
92
+ clone.operand = this.operand.clone();
93
+ clone.addChild(clone.operand);
91
94
 
92
- // Rebuild the argument list and copy AST data.
93
- clone.argumentNodeList = { operand: clone.operand };
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 };
94
100
  clone.astNodeData = JSON.parse(JSON.stringify(this.astNodeData));
95
101
 
96
102
  // The crucial step: link the clone to its origin.
@@ -99,6 +105,50 @@ export class omdUnaryExpressionNode extends omdNode {
99
105
  return clone;
100
106
  }
101
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
+
102
152
  /**
103
153
  * Converts the omdUnaryExpressionNode to a math.js AST node.
104
154
  * @returns {Object} A math.js-compatible AST node.
@@ -189,7 +189,8 @@ export class SimplificationEngine {
189
189
  * @returns {boolean} True if the node matches the type, false otherwise.
190
190
  */
191
191
  static isType(node, typeName) {
192
- return node.type === typeName;
192
+ if (!node) return false;
193
+ return node.type === typeName;
193
194
  }
194
195
 
195
196
  /**
@@ -495,7 +496,8 @@ export class SimplificationEngine {
495
496
  static getNodeClass(className) {
496
497
  const classMap = {
497
498
  'omdVariableNode': omdVariableNode,
498
- 'omdPowerNode': omdPowerNode
499
+ 'omdPowerNode': omdPowerNode,
500
+ 'omdUnaryExpressionNode': omdUnaryExpressionNode
499
501
  };
500
502
  return classMap[className];
501
503
  }
@@ -528,7 +530,7 @@ export class SimplificationEngine {
528
530
  */
529
531
  canApply(node) {
530
532
  const result = this.match(node);
531
- return result === false || result === null || result === undefined ? null :
533
+ return result === false || result === null || result === undefined ? null :
532
534
  result === true ? {} : result; // Normalize match results
533
535
  }
534
536
 
@@ -1,8 +1,115 @@
1
1
  import { SimplificationEngine } from '../omdSimplificationEngine.js';
2
2
  import * as utils from '../simplificationUtils.js';
3
+ import { omdRationalNode } from '../../nodes/omdRationalNode.js';
3
4
 
4
5
  // ===== RATIONAL NODE RULES =====
5
6
  export const rationalRules = [
7
+ // Simplify when both numerator and denominator are unary negatives: (-a)/(-b) -> a/b
8
+ SimplificationEngine.createRule("Unary Minus Cancellation",
9
+ (node) => {
10
+ if (node.type !== 'omdRationalNode') return false;
11
+
12
+ const num = SimplificationEngine.unwrapParentheses(node.numerator);
13
+ const den = SimplificationEngine.unwrapParentheses(node.denominator);
14
+
15
+ if (SimplificationEngine.isType(num, 'omdUnaryExpressionNode') &&
16
+ SimplificationEngine.isType(den, 'omdUnaryExpressionNode')) {
17
+ // Both sides are unary; cancel the unary minus operators
18
+ return {
19
+ numeratorArg: num.argument || num.operand,
20
+ denominatorArg: den.argument || den.operand,
21
+ numeratorNode: num,
22
+ denominatorNode: den
23
+ };
24
+ }
25
+
26
+ return false;
27
+ },
28
+ (node, data) => {
29
+ // Create a new rational node with arguments unwrapped
30
+ const fontSize = node.getFontSize();
31
+ const numArg = data.numeratorArg;
32
+ const denArg = data.denominatorArg;
33
+
34
+ // If the denominator argument is a constant 1, return the numerator directly (e.g., x/1 -> x)
35
+ if (denArg && typeof denArg.isConstant === 'function' && denArg.isConstant() && denArg.getValue() === 1) {
36
+ const result = numArg.clone();
37
+ // Preserve provenance from original nodes and the rational node
38
+ try { utils.applyProvenance(result, node.numerator, node.denominator, node); } catch (e) {
39
+ // Fallback to manual pushes if applyProvenance fails
40
+ result.provenance = result.provenance || [];
41
+ if (data.numeratorNode) result.provenance.push(data.numeratorNode.id);
42
+ if (data.denominatorNode) result.provenance.push(data.denominatorNode.id);
43
+ result.provenance.push(node.id);
44
+ }
45
+ return result;
46
+ }
47
+
48
+ const newNum = numArg.clone();
49
+ const newDen = denArg.clone();
50
+
51
+ const ast = {
52
+ type: 'OperatorNode', op: '/', fn: 'divide',
53
+ args: [newNum.toMathJSNode(), newDen.toMathJSNode()],
54
+ clone: function() { return { ...this, args: this.args.map(a => a.clone()) }; }
55
+ };
56
+
57
+ const rational = new omdRationalNode(ast);
58
+ rational.setFontSize(fontSize);
59
+ rational.initialize();
60
+
61
+ // Give granular provenance to numerator and denominator children
62
+ try {
63
+ // Attach provenance to the child numerator and denominator specifically
64
+ if (rational.numerator) utils.applyProvenance(rational.numerator, node.numerator);
65
+ if (rational.denominator) utils.applyProvenance(rational.denominator, node.denominator);
66
+ // Also attach provenance to the rational node itself
67
+ utils.applyProvenance(rational, node.numerator, node.denominator, node);
68
+ } catch (e) {
69
+ _preserveComponentProvenance(rational, data.numeratorNode.argument || data.numeratorNode.operand, data.denominatorNode.argument || data.denominatorNode.operand);
70
+ if (!rational.provenance.includes(node.id)) rational.provenance.push(node.id);
71
+ }
72
+
73
+ return rational;
74
+ },
75
+ (originalNode, ruleData, newNode) => {
76
+ return `Canceled unary negatives in fraction`;
77
+ },
78
+ 'rational'
79
+ ),
80
+
81
+ // Simplify when numerator is unary minus and denominator is constant -1: (-x)/-1 -> x
82
+ SimplificationEngine.createRule("Unary Numerator Divide By -1",
83
+ (node) => {
84
+ if (node.type !== 'omdRationalNode') return false;
85
+ const num = SimplificationEngine.unwrapParentheses(node.numerator);
86
+ const den = SimplificationEngine.unwrapParentheses(node.denominator);
87
+
88
+ if (SimplificationEngine.isType(num, 'omdUnaryExpressionNode') && den.isConstant && den.isConstant() && den.getValue() === -1) {
89
+ return { numeratorArg: num.argument || num.operand, numeratorNode: num, denominatorNode: den };
90
+ }
91
+
92
+ return false;
93
+ },
94
+ (node, data) => {
95
+ const newNode = data.numeratorArg.clone();
96
+ // Preserve granular provenance: numerator argument's provenance and the denominator and whole node
97
+ try {
98
+ // If the result is a leaf (variable/constant), ensure it gets provenance from numerator/denominator
99
+ utils.applyProvenance(newNode, node.numerator, node.denominator, node);
100
+ } catch (e) {
101
+ newNode.provenance = newNode.provenance || [];
102
+ if (data.numeratorNode) newNode.provenance.push(data.numeratorNode.id);
103
+ if (data.denominatorNode) newNode.provenance.push(data.denominatorNode.id);
104
+ newNode.provenance.push(node.id);
105
+ }
106
+ return newNode;
107
+ },
108
+ (originalNode, ruleData, newNode) => {
109
+ return `Divided by -1 cancels unary minus in numerator`;
110
+ },
111
+ 'rational'
112
+ ),
6
113
  // Simplify x/x = 1 (variable divided by itself)
7
114
  SimplificationEngine.createRule("Variable Self Division",
8
115
  (node) => {
@@ -282,6 +389,149 @@ export const rationalRules = [
282
389
  'rational'
283
390
  ),
284
391
 
392
+ // Cancel numeric coefficient when numerator is unary-negative monomial and denominator is negative constant
393
+ SimplificationEngine.createRule("Cancel Negative Coefficient",
394
+ (node) => {
395
+ if (node.type !== 'omdRationalNode') return false;
396
+
397
+ const numerator = SimplificationEngine.unwrapParentheses(node.numerator);
398
+ const denominator = SimplificationEngine.unwrapParentheses(node.denominator);
399
+
400
+ // We expect numerator to be unary minus wrapping a multiplication (e.g., -(2*x) )
401
+ if (!SimplificationEngine.isType(numerator, 'omdUnaryExpressionNode')) return false;
402
+ const inner = numerator.argument || numerator.operand;
403
+ if (!SimplificationEngine.isBinaryOp(inner, 'multiply')) return false;
404
+
405
+ // Find the constant operand inside the multiplication
406
+ const constOp = SimplificationEngine.hasConstantOperand(inner);
407
+ if (!constOp || !constOp.constant.isConstant()) return false;
408
+
409
+ // Denominator should be a constant and negative
410
+ if (!denominator.isConstant()) return false;
411
+ const denVal = denominator.getValue();
412
+ if (denVal >= 0) return false;
413
+
414
+ const coeffVal = constOp.constant.getValue();
415
+
416
+ // If absolute values match, we can cancel the coefficient
417
+ if (Math.abs(coeffVal) === Math.abs(denVal)) {
418
+ return {
419
+ innerMultiplication: inner,
420
+ otherFactor: constOp.other,
421
+ numeratorUnary: numerator,
422
+ denominatorNode: denominator
423
+ };
424
+ }
425
+
426
+ return false;
427
+ },
428
+ (node, data) => {
429
+ // Return the other factor (e.g., x) possibly adjusting sign if needed
430
+ const newNode = data.otherFactor.clone();
431
+ // Preserve granular provenance from the multiplication and the rational node
432
+ try { utils.applyProvenance(newNode, data.innerMultiplication || node.numerator, node.denominator, node); }
433
+ catch (e) {
434
+ newNode.provenance = newNode.provenance || [];
435
+ newNode.provenance.push(data.numeratorUnary.id);
436
+ newNode.provenance.push(data.denominatorNode.id);
437
+ newNode.provenance.push(node.id);
438
+ }
439
+ return newNode;
440
+ },
441
+ (originalNode, ruleData, newNode) => {
442
+ return `Canceled matching numeric factors between numerator and denominator`;
443
+ },
444
+ 'rational'
445
+ ),
446
+
447
+ // Handle negative constant numerator over multiplication with negative constant in denominator: (-a)/(-b * rest) -> a/(b * rest)
448
+ SimplificationEngine.createRule("Negative Constant Over Negative Product",
449
+ (node) => {
450
+ if (node.type !== 'omdRationalNode') return false;
451
+
452
+ const numerator = SimplificationEngine.unwrapParentheses(node.numerator);
453
+ const denominator = SimplificationEngine.unwrapParentheses(node.denominator);
454
+
455
+ if (!numerator.isConstant || !numerator.isConstant()) return false;
456
+ const numVal = numerator.getValue();
457
+ if (numVal >= 0) return false;
458
+
459
+ // Denominator must be a multiplication with a constant operand that's negative
460
+ if (!SimplificationEngine.isBinaryOp(denominator, 'multiply')) return false;
461
+ const constOp = SimplificationEngine.hasConstantOperand(denominator);
462
+ if (!constOp || !constOp.constant.isConstant()) return false;
463
+ const denConstVal = constOp.constant.getValue();
464
+ if (denConstVal >= 0) return false;
465
+
466
+ return {
467
+ numeratorNode: numerator,
468
+ denominatorNode: denominator,
469
+ denominatorConst: constOp.constant,
470
+ denominatorOther: constOp.other
471
+ };
472
+ },
473
+ (node, data) => {
474
+ const fontSize = node.getFontSize();
475
+
476
+ // Build new numerator absolute value
477
+ const newNumConst = SimplificationEngine.createConstant(Math.abs(data.numeratorNode.getValue()), fontSize);
478
+
479
+ // Build new denominator as (abs(denConst) * other)
480
+ const newDenConst = SimplificationEngine.createConstant(Math.abs(data.denominatorConst.getValue()), fontSize);
481
+ const newDenProduct = SimplificationEngine.createBinaryOp(newDenConst, 'multiply', data.denominatorOther.clone(), fontSize);
482
+
483
+ // Construct rational AST
484
+ const ast = {
485
+ type: 'OperatorNode', op: '/', fn: 'divide',
486
+ args: [newNumConst.toMathJSNode(), newDenProduct.toMathJSNode()],
487
+ clone: function() { return { ...this, args: this.args.map(a => a.clone()) }; }
488
+ };
489
+
490
+ const rational = new omdRationalNode(ast);
491
+ rational.setFontSize(fontSize);
492
+ rational.initialize();
493
+
494
+ // Debug: show matching components and their provenance
495
+
496
+ // Preserve provenance from original components and whole node.
497
+ // Attach granular provenance to numerator and denominator children as well as the rational node.
498
+ try {
499
+ // Numerator: if original numerator was a unary expression, use its inner operand (the constant '1')
500
+ const numeratorSource = (data && data.numeratorNode && (data.numeratorNode.argument || data.numeratorNode.operand)) || node.numerator;
501
+ if (rational.numerator && numeratorSource) {
502
+ utils.applyProvenance(rational.numerator, numeratorSource);
503
+ }
504
+
505
+ // Denominator: if original denominator was a multiplication, attach provenance to the left/right children
506
+ const denomSourceBinary = (data && data.denominatorNode) || node.denominator;
507
+ if (rational.denominator && SimplificationEngine.isBinaryOp(denomSourceBinary, 'multiply') && SimplificationEngine.isBinaryOp(rational.denominator, 'multiply')) {
508
+ // Attempt to map original const -> left, other -> right
509
+ const leftSource = (data && data.denominatorConst) || (denomSourceBinary.left || denomSourceBinary.right);
510
+ const rightSource = (data && data.denominatorOther) || ((denomSourceBinary.left === leftSource) ? denomSourceBinary.right : denomSourceBinary.left);
511
+
512
+ if (rational.denominator.left && leftSource) utils.applyProvenance(rational.denominator.left, leftSource);
513
+ if (rational.denominator.right && rightSource) utils.applyProvenance(rational.denominator.right, rightSource);
514
+ } else if (rational.denominator) {
515
+ // Fallback: attach provenance to the whole denominator
516
+ utils.applyProvenance(rational.denominator, node.denominator);
517
+ }
518
+
519
+ // Finally, attach provenance to the rational node itself
520
+ utils.applyProvenance(rational, node.numerator, node.denominator, node);
521
+ } catch (e) {
522
+ // Fallback to copying provenance arrays/ids
523
+ _preserveComponentProvenance(rational, node.numerator, node.denominator);
524
+ if (!rational.provenance.includes(node.id)) rational.provenance.push(node.id);
525
+ }
526
+
527
+ // (debug logs removed)
528
+
529
+ return rational;
530
+ },
531
+ (originalNode, ruleData, newNode) => `Canceled matching negative factors: simplified sign and magnitude`,
532
+ 'rational'
533
+ ),
534
+
285
535
  // Distribute division over addition/subtraction ((4*x+6)/3 → 4/3*x + 2)
286
536
  SimplificationEngine.createRule("Simplify Rational Division",
287
537
  (node) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teachinglab/omd",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "omd",
5
5
  "main": "./index.js",
6
6
  "module": "./index.js",
@@ -40,7 +40,7 @@
40
40
  },
41
41
  "scripts": {
42
42
  "start": "node --env-file=.env server.js",
43
- "dev": "node --env-file=.env server.js"
43
+ "dev": "vite"
44
44
  },
45
45
  "publishConfig": {
46
46
  "access": "public"
@@ -131,6 +131,28 @@ export class omdCoordinatePlane extends jsvgGroup {
131
131
  const lastTick = Math.floor(max);
132
132
  const minLabelSpacing = 10;
133
133
 
134
+ // If forceAllTickLabels is false, choose a label multiple (5,10,15,...) that avoids overlap.
135
+ let labelMultiple = null;
136
+ if (!this.forceAllTickLabels && this.showTickLabels) {
137
+ const span = Math.abs(lastTick - firstTick);
138
+ // try multiples of 5 up to a reasonable cap based on span
139
+ const maxMultiple = Math.max(5, Math.ceil(span / 2) * 5);
140
+ for (let candidate = 5; candidate <= maxMultiple; candidate += 5) {
141
+ const positions = [];
142
+ let lastPos = -Infinity;
143
+ let ok = true;
144
+ for (let v = firstTick; v <= lastTick; v += interval) {
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
+ }
155
+ }
134
156
  let lastLabelPos = -Infinity;
135
157
  let zeroLineDrawn = false;
136
158
 
@@ -148,11 +170,17 @@ export class omdCoordinatePlane extends jsvgGroup {
148
170
  }
149
171
 
150
172
  const isEdgeTick = value === firstTick || value === lastTick;
151
- const shouldShowLabel = this.showTickLabels && (
152
- this.forceAllTickLabels ||
153
- isEdgeTick ||
154
- Math.abs(pos - lastLabelPos) >= minLabelSpacing
155
- );
173
+
174
+ let shouldShowLabel = false;
175
+ if (this.showTickLabels) {
176
+ if (this.forceAllTickLabels) {
177
+ shouldShowLabel = true;
178
+ } else if (labelMultiple) {
179
+ shouldShowLabel = isEdgeTick || Number.isInteger(value / labelMultiple);
180
+ } else {
181
+ shouldShowLabel = isEdgeTick || Math.abs(pos - lastLabelPos) >= minLabelSpacing;
182
+ }
183
+ }
156
184
 
157
185
  if (shouldShowLabel) {
158
186
  this.addTickLabel(gridHolder, isXAxis, pos, value);
@@ -339,8 +367,8 @@ export class omdCoordinatePlane extends jsvgGroup {
339
367
 
340
368
  const compiledExpression = math.compile(expression);
341
369
 
342
- const leftLimit = domain.min;
343
- const rightLimit = domain.max;
370
+ const leftLimit = (domain && typeof domain.min === 'number') ? domain.min : this.xMin;
371
+ const rightLimit = (domain && typeof domain.max === 'number') ? domain.max : this.xMax;
344
372
 
345
373
  const step = Math.abs(rightLimit - leftLimit) / 1000;
346
374
  for (let x = leftLimit; x <= rightLimit; x += step) {