@classytic/stage 0.1.0 → 0.3.0

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 (110) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +7 -3
  3. package/dist/assets/index.mjs +0 -1
  4. package/dist/assets/kit/glyphs.d.mts +5 -5
  5. package/dist/assets/kit/glyphs.mjs +4 -4
  6. package/dist/builder/Palette.mjs +50 -89
  7. package/dist/builder/SceneBuilder.mjs +18 -76
  8. package/dist/builder/editor.d.mts +2 -1
  9. package/dist/builder/editor.mjs +14 -4
  10. package/dist/builder/tools.d.mts +1 -1
  11. package/dist/builder/tools.mjs +1 -1
  12. package/dist/chem/index.d.mts +5 -5
  13. package/dist/chem/index.mjs +4 -4
  14. package/dist/circuit/index.d.mts +92 -0
  15. package/dist/circuit/index.mjs +333 -0
  16. package/dist/core/clock.d.mts +1 -1
  17. package/dist/core/clock.mjs +3 -3
  18. package/dist/core/control.d.mts +1 -1
  19. package/dist/core/control.mjs +2 -2
  20. package/dist/core/coords.d.mts +4 -4
  21. package/dist/core/coords.mjs +1 -1
  22. package/dist/core/index.d.mts +2 -1
  23. package/dist/core/index.mjs +2 -1
  24. package/dist/core/learner.d.mts +1 -1
  25. package/dist/core/learner.mjs +1 -1
  26. package/dist/core/math.d.mts +26 -0
  27. package/dist/core/math.mjs +37 -0
  28. package/dist/core/motion.d.mts +6 -6
  29. package/dist/core/motion.mjs +6 -6
  30. package/dist/core/richText.d.mts +2 -2
  31. package/dist/core/vec.d.mts +1 -1
  32. package/dist/field/index.d.mts +11 -3
  33. package/dist/field/index.mjs +20 -3
  34. package/dist/finance/bizsim.d.mts +93 -0
  35. package/dist/finance/bizsim.mjs +117 -0
  36. package/dist/finance/index.d.mts +118 -0
  37. package/dist/finance/index.mjs +203 -0
  38. package/dist/index.d.mts +6 -3
  39. package/dist/index.mjs +6 -4
  40. package/dist/interaction/MovableDot.d.mts +17 -1
  41. package/dist/interaction/MovableDot.mjs +160 -43
  42. package/dist/interaction/index.d.mts +2 -1
  43. package/dist/interaction/index.mjs +2 -1
  44. package/dist/interaction/useDraggable.d.mts +1 -1
  45. package/dist/interaction/useDraggable.mjs +55 -5
  46. package/dist/interaction/usePressSpring.d.mts +24 -0
  47. package/dist/interaction/usePressSpring.mjs +67 -0
  48. package/dist/logic/ast.d.mts +1 -1
  49. package/dist/logic/index.mjs +8 -3
  50. package/dist/logic/minimize.d.mts +2 -2
  51. package/dist/logic/minimize.mjs +56 -18
  52. package/dist/logic/table.mjs +1 -1
  53. package/dist/math/ast.d.mts +1 -1
  54. package/dist/math/ast.mjs +1 -1
  55. package/dist/math/calculus.d.mts +22 -1
  56. package/dist/math/calculus.mjs +224 -11
  57. package/dist/math/compile.d.mts +8 -0
  58. package/dist/math/compile.mjs +40 -0
  59. package/dist/math/defs.mjs +1 -1
  60. package/dist/math/index.d.mts +5 -4
  61. package/dist/math/index.mjs +5 -4
  62. package/dist/math/latex.mjs +8 -0
  63. package/dist/math/parse.mjs +2 -2
  64. package/dist/math/tokenize.mjs +1 -1
  65. package/dist/primitives/CanvasLayer.mjs +3 -3
  66. package/dist/primitives/Dot.d.mts +2 -15
  67. package/dist/primitives/Dot.mjs +6 -4
  68. package/dist/primitives/Grid.d.mts +41 -14
  69. package/dist/primitives/Grid.mjs +100 -22
  70. package/dist/primitives/Label.d.mts +2 -15
  71. package/dist/primitives/Label.mjs +4 -3
  72. package/dist/primitives/Lines.d.mts +4 -32
  73. package/dist/primitives/Lines.mjs +10 -8
  74. package/dist/primitives/Shapes.d.mts +5 -43
  75. package/dist/primitives/Shapes.mjs +12 -10
  76. package/dist/primitives/Tex.mjs +2 -2
  77. package/dist/primitives/index.d.mts +2 -2
  78. package/dist/primitives/index.mjs +2 -2
  79. package/dist/primitives/props.mjs +31 -0
  80. package/dist/scene/Scene.d.mts +6 -1
  81. package/dist/scene/Scene.mjs +16 -41
  82. package/dist/scene/assets.d.mts +2 -2
  83. package/dist/scene/assets.mjs +2 -2
  84. package/dist/scene/commands.d.mts +10 -1
  85. package/dist/scene/commands.mjs +42 -2
  86. package/dist/scene/evaluators.mjs +2 -2
  87. package/dist/scene/index.d.mts +2 -2
  88. package/dist/scene/index.mjs +2 -2
  89. package/dist/scene/migrate.mjs +1 -1
  90. package/dist/scene/sims.mjs +1 -1
  91. package/dist/scene/types.d.mts +4 -4
  92. package/dist/sim/particles.d.mts +2 -2
  93. package/dist/sim/rate.d.mts +4 -4
  94. package/dist/sim/rate.mjs +1 -1
  95. package/dist/sim/registry.d.mts +1 -1
  96. package/dist/sim/sampler.d.mts +5 -5
  97. package/dist/sim/thermal.d.mts +3 -3
  98. package/dist/sim/thermal.mjs +2 -2
  99. package/dist/sim/types.d.mts +2 -2
  100. package/dist/sim/wave.d.mts +2 -2
  101. package/dist/sim/wave.mjs +2 -2
  102. package/dist/steps/index.d.mts +1 -1
  103. package/dist/steps/index.mjs +3 -3
  104. package/dist/thermo/index.d.mts +1 -1
  105. package/dist/thermo/index.mjs +1 -1
  106. package/dist/view/Stage.mjs +6 -13
  107. package/dist/view/useInView.mjs +1 -1
  108. package/package.json +37 -25
  109. package/styles.css +141 -1
  110. package/dist/assets/kit/index.mjs +0 -4
@@ -3,9 +3,9 @@ import { truthTable } from "./table.mjs";
3
3
 
4
4
  //#region src/logic/minimize.ts
5
5
  /**
6
- * Boolean minimisation Quine–McCluskey: the algorithm a Karnaugh map performs
6
+ * Boolean minimisation, Quine–McCluskey: the algorithm a Karnaugh map performs
7
7
  * by eye. Given a function's minterms (the rows where it is 1) over n variables,
8
- * it finds the PRIME IMPLICANTS (maximal "don't-care cubes" groups on the
8
+ * it finds the PRIME IMPLICANTS (maximal "don't-care cubes", groups on the
9
9
  * K-map) and a minimal SUM-OF-PRODUCTS cover (essential PIs + a greedy fill).
10
10
  * Pure + dependency-free, the boolean sibling of `simplify` in the expr engine;
11
11
  * the K-map lab renders these cubes as the coloured groups, and the same routine
@@ -31,7 +31,7 @@ function cubeCovers(c, n) {
31
31
  for (let m = 0; m < total; m++) if ((m & c.mask) === c.value) out.push(m);
32
32
  return out;
33
33
  }
34
- /** The product term for a cube a literal per FIXED bit (MSB = first var). */
34
+ /** The product term for a cube, a literal per FIXED bit (MSB = first var). */
35
35
  function cubeTerm(c, vars, neg = "¬", join = " ∧ ") {
36
36
  const n = vars.length;
37
37
  const lits = [];
@@ -86,7 +86,56 @@ function coverageOf(pis, required) {
86
86
  });
87
87
  return map;
88
88
  }
89
- /** A minimal SOP cover: essential prime implicants + a greedy fill of the rest. */
89
+ /** Keep only the minimal PI-sets (drop any that is a strict superset of another). */
90
+ function absorb(products) {
91
+ const uniq = /* @__PURE__ */ new Map();
92
+ for (const p of products) {
93
+ const s = new Set(p);
94
+ uniq.set([...s].sort((a, b) => a - b).join(","), s);
95
+ }
96
+ const sets = [...uniq.values()];
97
+ return sets.filter((s) => !sets.some((o) => o !== s && o.size < s.size && [...o].every((x) => s.has(x)))).map((s) => [...s]);
98
+ }
99
+ /** Greedy fallback: take the PI covering the most still-uncovered minterms, repeat. */
100
+ function greedyCover(uncovered, cov, candidates) {
101
+ const left = new Set(uncovered);
102
+ const picked = [];
103
+ while (left.size) {
104
+ let best = -1, bestCount = 0;
105
+ for (const i of candidates) {
106
+ if (picked.includes(i)) continue;
107
+ const c = cov.get(i).filter((m) => left.has(m)).length;
108
+ if (c > bestCount) {
109
+ bestCount = c;
110
+ best = i;
111
+ }
112
+ }
113
+ if (best < 0) break;
114
+ picked.push(best);
115
+ for (const m of cov.get(best)) left.delete(m);
116
+ }
117
+ return picked;
118
+ }
119
+ /**
120
+ * Petrick's method: the EXACT minimum-size cover of the remaining minterms. Each minterm
121
+ * contributes a clause (the PIs covering it); multiplying the clauses out (with absorption)
122
+ * enumerates every irredundant cover, and we keep the one with the fewest PIs. Falls back to
123
+ * greedy if the residual is large enough that the expansion could blow up (K-map labs are small).
124
+ */
125
+ function petrickCover(uncovered, cov, candidates) {
126
+ if (candidates.length > 18) return greedyCover(uncovered, cov, candidates);
127
+ let products = [[]];
128
+ for (const m of uncovered) {
129
+ const clause = candidates.filter((i) => cov.get(i).includes(m));
130
+ if (clause.length === 0) return greedyCover(uncovered, cov, candidates);
131
+ const next = [];
132
+ for (const p of products) for (const pi of clause) next.push(p.includes(pi) ? p : [...p, pi]);
133
+ products = absorb(next);
134
+ if (products.length > 4e3) return greedyCover(uncovered, cov, candidates);
135
+ }
136
+ return products.reduce((best, p) => p.length < best.length ? p : best, products[0] ?? []);
137
+ }
138
+ /** A minimum SOP cover: essential prime implicants + Petrick's exact cover of the rest. */
90
139
  function minimalCover(minterms, n, dontCares = []) {
91
140
  const required = new Set(minterms);
92
141
  if (required.size === 0) return [];
@@ -100,20 +149,9 @@ function minimalCover(minterms, n, dontCares = []) {
100
149
  if (owners.length === 1) chosen.add(owners[0]);
101
150
  }
102
151
  for (const i of chosen) for (const m of cov.get(i)) uncovered.delete(m);
103
- while (uncovered.size) {
104
- let best = -1, bestCount = -1;
105
- pis.forEach((_, i) => {
106
- if (chosen.has(i)) return;
107
- const c = cov.get(i).filter((m) => uncovered.has(m)).length;
108
- if (c > bestCount) {
109
- bestCount = c;
110
- best = i;
111
- }
112
- });
113
- if (best < 0 || bestCount <= 0) break;
114
- chosen.add(best);
115
- for (const m of cov.get(best)) uncovered.delete(m);
116
- }
152
+ if (uncovered.size === 0) return [...chosen].map((i) => pis[i]);
153
+ const candidates = pis.map((_, i) => i).filter((i) => !chosen.has(i));
154
+ for (const i of petrickCover([...uncovered], cov, candidates)) chosen.add(i);
117
155
  return [...chosen].map((i) => pis[i]);
118
156
  }
119
157
  /** Full minimisation of a formula (or explicit minterms) → cover + SOP string. */
@@ -4,7 +4,7 @@ import { evalBool, freeVars } from "./ast.mjs";
4
4
  /**
5
5
  * Truth tables + the things derived from them: classification (tautology /
6
6
  * contradiction / contingency), logical equivalence, and DNF/CNF. Everything is
7
- * a fold over the 2ⁿ assignments the truth table is the source of truth, so
7
+ * a fold over the 2ⁿ assignments, the truth table is the source of truth, so
8
8
  * equivalence is just "same output column" (no algebraic law engine needed).
9
9
  */
10
10
  /** Enumerate all 2^vars assignments (MSB = first var), evaluating `n` on each. */
@@ -1,6 +1,6 @@
1
1
  //#region src/math/ast.d.ts
2
2
  /**
3
- * expr/ast the Abstract Syntax Tree: node types, evaluation, free-variable
3
+ * expr/ast, the Abstract Syntax Tree: node types, evaluation, free-variable
4
4
  * collection, and small builder helpers. The AST is the shared representation
5
5
  * that evaluation, differentiation, simplification, and LaTeX printing all
6
6
  * operate on.
package/dist/math/ast.mjs CHANGED
@@ -2,7 +2,7 @@ import { CONSTANTS, FN1, FN2 } from "./defs.mjs";
2
2
 
3
3
  //#region src/math/ast.ts
4
4
  /**
5
- * expr/ast the Abstract Syntax Tree: node types, evaluation, free-variable
5
+ * expr/ast, the Abstract Syntax Tree: node types, evaluation, free-variable
6
6
  * collection, and small builder helpers. The AST is the shared representation
7
7
  * that evaluation, differentiation, simplification, and LaTeX printing all
8
8
  * operate on.
@@ -3,7 +3,28 @@ import { Node } from "./ast.mjs";
3
3
  //#region src/math/calculus.d.ts
4
4
  /** Exact symbolic derivative, or `null` if a non-differentiable node is present. */
5
5
  declare function differentiate(node: Node, x: string): Node | null;
6
+ /**
7
+ * Exact symbolic antiderivative with respect to `x`, or `null` when this engine cannot do it.
8
+ *
9
+ * The constant of integration is NOT included. That is deliberate: `+ c` is the single most
10
+ * commonly dropped mark in the topic, so it belongs in the lesson's own working where a learner
11
+ * has to write it, not silently bolted on by the engine.
12
+ *
13
+ * Covers the Cambridge P1/P3 toolkit: linearity, the power rule including the `1/x → ln|x|`
14
+ * exception, and any of the above composed with a LINEAR inner function. Returns null for
15
+ * integration by parts, by non-linear substitution, and partial fractions, so a caller can fall
16
+ * back to the numerical `integrate` in `core/numeric` and show area rather than a wrong formula.
17
+ */
18
+ declare function integrate(node: Node, x: string): Node | null;
19
+ /**
20
+ * A definite integral evaluated exactly: `F(b) - F(a)`.
21
+ *
22
+ * Returned as a NUMBER rather than a node, because that is what a lesson compares a learner's
23
+ * answer against. Null when the antiderivative is not elementary, or when either limit lands
24
+ * somewhere the expression is not defined.
25
+ */
26
+ declare function definiteIntegral(node: Node, x: string, lower: number, upper: number): number | null;
6
27
  /** Fold constants and trivial identities so derivative output is readable. */
7
28
  declare function simplify(node: Node): Node;
8
29
  //#endregion
9
- export { differentiate, simplify };
30
+ export { definiteIntegral, differentiate, integrate, simplify };
@@ -1,9 +1,9 @@
1
1
  import { FN1, FN2 } from "./defs.mjs";
2
- import { add, call, div, mul, neg, num, pow, sub } from "./ast.mjs";
2
+ import { add, call, div, evaluate, mul, neg, num, pow, sub, variable } from "./ast.mjs";
3
3
 
4
4
  //#region src/math/calculus.ts
5
5
  /**
6
- * expr/calculus symbolic differentiation + algebraic simplification on the AST.
6
+ * expr/calculus, symbolic differentiation + algebraic simplification on the AST.
7
7
  *
8
8
  * `differentiate(node, x)` returns the exact derivative as a new AST, or `null`
9
9
  * when the expression contains something not symbolically differentiable
@@ -70,7 +70,201 @@ function differentiate(node, x) {
70
70
  default: return null;
71
71
  }
72
72
  }
73
+ /**
74
+ * Outer antiderivative of a 1-arg function, given its argument node `u`.
75
+ *
76
+ * Mirrors DERIV above. Only the functions whose antiderivative is itself elementary appear:
77
+ * `ln` and the inverse trig functions integrate by parts into forms this table cannot express,
78
+ * so they are absent and `integrate` returns null for them rather than guessing.
79
+ */
80
+ const INTEG = {
81
+ sin: (u) => neg(call("cos", u)),
82
+ cos: (u) => call("sin", u),
83
+ exp: (u) => call("exp", u),
84
+ sinh: (u) => call("cosh", u),
85
+ cosh: (u) => call("sinh", u)
86
+ };
87
+ /** `true` when `node` holds no occurrence of `x`, so it is a constant for this integral. */
88
+ function isConstantIn(node, x) {
89
+ return !freeVarsHas(node, x);
90
+ }
91
+ function freeVarsHas(node, x) {
92
+ switch (node.type) {
93
+ case "num": return false;
94
+ case "var": return node.name === x;
95
+ case "neg": return freeVarsHas(node.arg, x);
96
+ case "binary": return freeVarsHas(node.left, x) || freeVarsHas(node.right, x);
97
+ case "call": return node.args.some((a) => freeVarsHas(a, x));
98
+ default: return true;
99
+ }
100
+ }
101
+ /**
102
+ * The constant `a` when `u` is linear in `x` (that is, `u = a·x + b`), else null.
103
+ *
104
+ * This is what makes the whole linear-substitution family work with one rule instead of a pattern
105
+ * per case: if `du/dx` simplifies to a non-zero NUMBER, then `∫f(u) dx = F(u)/a`. Deriving it from
106
+ * `differentiate` rather than matching `a*x+b` by shape means `(3-2x)^5` and `(-2x+3)^5` and
107
+ * `3-2*x` all work without three separate matchers.
108
+ */
109
+ function linearFactor(u, x) {
110
+ const d = differentiate(u, x);
111
+ if (!d) return null;
112
+ const s = simplify(d);
113
+ return s.type === "num" && s.value !== 0 ? s.value : null;
114
+ }
115
+ /** Divide by a constant, without emitting the noise of `expr / 1`. */
116
+ const overConst = (node, a) => a === 1 ? node : div(node, num(a));
117
+ /**
118
+ * Exact symbolic antiderivative with respect to `x`, or `null` when this engine cannot do it.
119
+ *
120
+ * The constant of integration is NOT included. That is deliberate: `+ c` is the single most
121
+ * commonly dropped mark in the topic, so it belongs in the lesson's own working where a learner
122
+ * has to write it, not silently bolted on by the engine.
123
+ *
124
+ * Covers the Cambridge P1/P3 toolkit: linearity, the power rule including the `1/x → ln|x|`
125
+ * exception, and any of the above composed with a LINEAR inner function. Returns null for
126
+ * integration by parts, by non-linear substitution, and partial fractions, so a caller can fall
127
+ * back to the numerical `integrate` in `core/numeric` and show area rather than a wrong formula.
128
+ */
129
+ function integrate(node, x) {
130
+ if (isConstantIn(node, x)) return mul(node, variable(x));
131
+ switch (node.type) {
132
+ case "var": return div(pow(variable(x), num(2)), num(2));
133
+ case "neg": {
134
+ const inner = integrate(node.arg, x);
135
+ return inner && neg(inner);
136
+ }
137
+ case "binary": {
138
+ const { op, left, right } = node;
139
+ if (op === "+" || op === "-") {
140
+ const l = integrate(left, x);
141
+ const r = integrate(right, x);
142
+ return l && r && (op === "+" ? add(l, r) : sub(l, r));
143
+ }
144
+ if (op === "*") {
145
+ if (isConstantIn(left, x)) {
146
+ const r = integrate(right, x);
147
+ return r && mul(left, r);
148
+ }
149
+ if (isConstantIn(right, x)) {
150
+ const l = integrate(left, x);
151
+ return l && mul(right, l);
152
+ }
153
+ return null;
154
+ }
155
+ if (op === "/") {
156
+ if (isConstantIn(right, x)) {
157
+ const l = integrate(left, x);
158
+ return l && div(l, right);
159
+ }
160
+ if (isConstantIn(left, x)) {
161
+ const a = linearFactor(right, x);
162
+ return a === null ? null : overConst(mul(left, call("ln", call("abs", right))), a);
163
+ }
164
+ return null;
165
+ }
166
+ if (op === "^") {
167
+ const exponent = simplify(right);
168
+ if (exponent.type !== "num") return null;
169
+ const n = exponent.value;
170
+ const a = linearFactor(left, x);
171
+ if (a === null) return null;
172
+ if (n === -1) return overConst(call("ln", call("abs", left)), a);
173
+ return overConst(div(pow(left, num(n + 1)), num(n + 1)), a);
174
+ }
175
+ return null;
176
+ }
177
+ case "call": {
178
+ const u = node.args[0];
179
+ const rule = INTEG[node.fn];
180
+ if (!rule || !u || node.args.length !== 1) return null;
181
+ const a = linearFactor(u, x);
182
+ return a === null ? null : overConst(rule(u), a);
183
+ }
184
+ default: return null;
185
+ }
186
+ }
187
+ /**
188
+ * A definite integral evaluated exactly: `F(b) - F(a)`.
189
+ *
190
+ * Returned as a NUMBER rather than a node, because that is what a lesson compares a learner's
191
+ * answer against. Null when the antiderivative is not elementary, or when either limit lands
192
+ * somewhere the expression is not defined.
193
+ */
194
+ function definiteIntegral(node, x, lower, upper) {
195
+ const F = integrate(node, x);
196
+ if (!F) return null;
197
+ const at = (v) => evaluate(F, { [x]: v });
198
+ const result = at(upper) - at(lower);
199
+ return Number.isFinite(result) ? result : null;
200
+ }
73
201
  const isNum = (n, v) => n.type === "num" && (v === void 0 || n.value === v);
202
+ /** A stable structural key, so identical cores group (x and x; x² and x²). */
203
+ function nodeKey(n) {
204
+ switch (n.type) {
205
+ case "num": return `#${n.value}`;
206
+ case "var": return `$${n.name}`;
207
+ case "neg": return `-${nodeKey(n.arg)}`;
208
+ case "binary": return `(${nodeKey(n.left)}${n.op}${nodeKey(n.right)})`;
209
+ case "call": return `${n.fn}(${n.args.map(nodeKey).join(",")})`;
210
+ default: return "?";
211
+ }
212
+ }
213
+ /** Split a term into (numeric coefficient, the rest); core null = a pure number. */
214
+ function splitCoeff(n) {
215
+ if (n.type === "num") return [n.value, null];
216
+ if (n.type === "neg") {
217
+ const [c, core] = splitCoeff(n.arg);
218
+ return [-c, core];
219
+ }
220
+ if (n.type === "binary" && n.op === "*") {
221
+ if (n.left.type === "num") return [n.left.value, n.right];
222
+ if (n.right.type === "num") return [n.right.value, n.left];
223
+ }
224
+ return [1, n];
225
+ }
226
+ /** Flatten a +/- chain into signed (coeff, core) terms. */
227
+ function flattenTerms(node, s, out) {
228
+ if (node.type === "binary" && (node.op === "+" || node.op === "-")) {
229
+ flattenTerms(node.left, s, out);
230
+ flattenTerms(node.right, node.op === "-" ? -s : s, out);
231
+ return;
232
+ }
233
+ if (node.type === "neg") {
234
+ flattenTerms(node.arg, -s, out);
235
+ return;
236
+ }
237
+ const [coeff, core] = splitCoeff(node);
238
+ out.push({
239
+ coeff: coeff * s,
240
+ core
241
+ });
242
+ }
243
+ /** Combine like terms across `l op r`: 2x + 3x → 5x, x − x → 0, constants merge. */
244
+ function combineLikeTerms(l, r, op) {
245
+ const list = [];
246
+ flattenTerms(l, 1, list);
247
+ flattenTerms(r, op === "-" ? -1 : 1, list);
248
+ const groups = /* @__PURE__ */ new Map();
249
+ for (const t of list) {
250
+ const k = t.core ? nodeKey(t.core) : "";
251
+ const g = groups.get(k);
252
+ if (g) g.coeff += t.coeff;
253
+ else groups.set(k, {
254
+ coeff: t.coeff,
255
+ core: t.core
256
+ });
257
+ }
258
+ let result = null;
259
+ for (const { coeff, core } of groups.values()) {
260
+ if (Math.abs(coeff) < 1e-12) continue;
261
+ const mag = Math.abs(coeff);
262
+ const body = core === null ? num(mag) : mag === 1 ? core : mul(num(mag), core);
263
+ if (result === null) result = coeff < 0 ? neg(body) : body;
264
+ else result = coeff < 0 ? sub(result, body) : add(result, body);
265
+ }
266
+ return result ?? num(0);
267
+ }
74
268
  /** Fold constants and trivial identities so derivative output is readable. */
75
269
  function simplify(node) {
76
270
  switch (node.type) {
@@ -90,15 +284,8 @@ function simplify(node) {
90
284
  const v = fold(op, l.value, r.value);
91
285
  if (v !== null && Number.isFinite(v)) return num(v);
92
286
  }
287
+ if (op === "+" || op === "-") return combineLikeTerms(l, r, op);
93
288
  switch (op) {
94
- case "+":
95
- if (isNum(l, 0)) return r;
96
- if (isNum(r, 0)) return l;
97
- break;
98
- case "-":
99
- if (isNum(r, 0)) return l;
100
- if (isNum(l, 0)) return simplify(neg(r));
101
- break;
102
289
  case "*":
103
290
  if (isNum(l, 0) || isNum(r, 0)) return num(0);
104
291
  if (isNum(l, 1)) return r;
@@ -133,10 +320,36 @@ function simplify(node) {
133
320
  right: l.left
134
321
  };
135
322
  }
323
+ /**
324
+ * Cancel a constant against a fraction's denominator: c·(a/b) → (c/b)·a.
325
+ *
326
+ * `∫3x² dx` is the plainest case. The constant comes out of the integral and the power
327
+ * rule divides by 3, so the honest answer is `3·(x³/3)`. A learner expects `x³`, and
328
+ * only the fully cancelled form is worth showing as the ANSWER.
329
+ */
330
+ if (l.type === "num" && r.type === "binary" && r.op === "/" && r.right.type === "num") {
331
+ const k = l.value / r.right.value;
332
+ if (Number.isInteger(k)) return k === 1 ? r.left : simplify(mul(num(k), r.left));
333
+ }
334
+ if (r.type === "num" && l.type === "binary" && l.op === "/" && l.right.type === "num") {
335
+ const k = r.value / l.right.value;
336
+ if (Number.isInteger(k)) return k === 1 ? l.left : simplify(mul(num(k), l.left));
337
+ }
136
338
  break;
137
339
  case "/":
138
340
  if (isNum(r, 1)) return l;
139
341
  if (isNum(l, 0)) return num(0);
342
+ /**
343
+ * Collapse a nested fraction: (a/b)/c → a/(bc).
344
+ *
345
+ * Integration produces these constantly, because the power rule divides by n+1 and the
346
+ * linear-substitution rule then divides by a. Left alone, ∫(2x+1)³dx renders as a
347
+ * fraction inside a fraction, which is correct and unreadable, and unreadable is the
348
+ * complaint this engine exists to answer.
349
+ */
350
+ if (r.type === "num" && l.type === "binary" && l.op === "/" && l.right.type === "num") return simplify(div(l.left, num(l.right.value * r.value)));
351
+ if (r.type === "num" && r.value < 0) return simplify(neg(div(l, num(-r.value))));
352
+ if (l.type === "neg") return neg(simplify(div(l.arg, r)));
140
353
  break;
141
354
  case "^":
142
355
  if (isNum(r, 1)) return l;
@@ -185,4 +398,4 @@ function fold(op, a, b) {
185
398
  }
186
399
 
187
400
  //#endregion
188
- export { differentiate, simplify };
401
+ export { definiteIntegral, differentiate, integrate, simplify };
@@ -0,0 +1,8 @@
1
+ import { Node } from "./ast.mjs";
2
+
3
+ //#region src/math/compile.d.ts
4
+ type CompiledFn = (scope?: Record<string, number>) => number;
5
+ /** Compile an AST to a fast `(scope) => number`. */
6
+ declare function compile(node: Node): CompiledFn;
7
+ //#endregion
8
+ export { CompiledFn, compile };
@@ -0,0 +1,40 @@
1
+ import { CONSTANTS, FN1, FN2 } from "./defs.mjs";
2
+
3
+ //#region src/math/compile.ts
4
+ const JS_OP = {
5
+ "+": "+",
6
+ "-": "-",
7
+ "*": "*",
8
+ "/": "/",
9
+ "%": "%",
10
+ "^": "**"
11
+ };
12
+ /** Emit a JS expression string (every node fully parenthesised, so `**` and unary
13
+ * minus never collide — `(-x)**2` is valid, `-x**2` is a JS SyntaxError). */
14
+ function gen(node) {
15
+ switch (node.type) {
16
+ case "num": return `(${node.value})`;
17
+ case "var": {
18
+ const lc = node.name.toLowerCase();
19
+ if (lc in CONSTANTS) return `(${CONSTANTS[lc]})`;
20
+ return `(s[${JSON.stringify(node.name)}]??NaN)`;
21
+ }
22
+ case "neg": return `(-${gen(node.arg)})`;
23
+ case "binary": return `(${gen(node.left)}${JS_OP[node.op] ?? "+"}${gen(node.right)})`;
24
+ case "call": {
25
+ const a = node.args.map(gen);
26
+ if (node.fn in FN1 && a[0]) return `F1[${JSON.stringify(node.fn)}](${a[0]})`;
27
+ if (node.fn in FN2 && a[0] && a[1]) return `F2[${JSON.stringify(node.fn)}](${a[0]},${a[1]})`;
28
+ return "NaN";
29
+ }
30
+ default: return "NaN";
31
+ }
32
+ }
33
+ /** Compile an AST to a fast `(scope) => number`. */
34
+ function compile(node) {
35
+ const f = new Function("s", "F1", "F2", `return ${gen(node)};`);
36
+ return (scope = {}) => f(scope, FN1, FN2);
37
+ }
38
+
39
+ //#endregion
40
+ export { compile };
@@ -1,6 +1,6 @@
1
1
  //#region src/math/defs.ts
2
2
  /**
3
- * expr/defs the function & constant tables shared by the tokenizer, evaluator,
3
+ * expr/defs, the function & constant tables shared by the tokenizer, evaluator,
4
4
  * differentiator, and LaTeX printer. Pure data; no AST knowledge here.
5
5
  */
6
6
  const CONSTANTS = {
@@ -1,6 +1,7 @@
1
1
  import { BinOp, Node, compileNode, evaluate, freeVars } from "./ast.mjs";
2
2
  import { parse } from "./parse.mjs";
3
- import { differentiate, simplify } from "./calculus.mjs";
3
+ import { definiteIntegral, differentiate, integrate, simplify } from "./calculus.mjs";
4
+ import { CompiledFn, compile } from "./compile.mjs";
4
5
  import { toLatex } from "./latex.mjs";
5
6
 
6
7
  //#region src/math/index.d.ts
@@ -9,7 +10,7 @@ interface CompiledExpr {
9
10
  readonly fn: (scope: Record<string, number>) => number;
10
11
  /** Free variables referenced (excludes constants & functions), e.g. `['x','a']`. */
11
12
  readonly vars: string[];
12
- /** The parsed syntax tree for differentiation, LaTeX, or analysis. */
13
+ /** The parsed syntax tree, for differentiation, LaTeX, or analysis. */
13
14
  readonly ast: Node;
14
15
  readonly error?: undefined;
15
16
  }
@@ -20,7 +21,7 @@ interface ExprError {
20
21
  readonly ast?: undefined;
21
22
  }
22
23
  type ExprResult = CompiledExpr | ExprError;
23
- /** Compile a formula string. Returns `{ fn, vars, ast }` or `{ error }` never throws. */
24
+ /** Compile a formula string. Returns `{ fn, vars, ast }` or `{ error }`, never throws. */
24
25
  declare function compileExpr(src: string): ExprResult;
25
26
  //#endregion
26
- export { type BinOp, CompiledExpr, ExprError, ExprResult, type Node, compileExpr, compileNode, differentiate, evaluate, freeVars, parse, simplify, toLatex };
27
+ export { type BinOp, CompiledExpr, type CompiledFn, ExprError, ExprResult, type Node, compile, compileExpr, compileNode, definiteIntegral, differentiate, evaluate, freeVars, integrate, parse, simplify, toLatex };
@@ -1,11 +1,12 @@
1
1
  import { compileNode, evaluate, freeVars } from "./ast.mjs";
2
2
  import { parse } from "./parse.mjs";
3
- import { differentiate, simplify } from "./calculus.mjs";
3
+ import { definiteIntegral, differentiate, integrate, simplify } from "./calculus.mjs";
4
+ import { compile } from "./compile.mjs";
4
5
  import { toLatex } from "./latex.mjs";
5
6
 
6
7
  //#region src/math/index.ts
7
8
  /**
8
- * expr a tiny, safe, dependency-free symbolic math engine.
9
+ * expr, a tiny, safe, dependency-free symbolic math engine.
9
10
  *
10
11
  * Pipeline: `tokenize → parse (AST) → evaluate`. On top of the AST it offers
11
12
  * exact `differentiate`, `simplify`, and `toLatex`, so the same typed formula
@@ -22,7 +23,7 @@ import { toLatex } from "./latex.mjs";
22
23
  * csc asin acos atan sinh cosh tanh sqrt cbrt abs exp ln log log2 log10 floor
23
24
  * ceil round sign` (+ 2-arg `pow atan2 min max mod hypot`).
24
25
  */
25
- /** Compile a formula string. Returns `{ fn, vars, ast }` or `{ error }` never throws. */
26
+ /** Compile a formula string. Returns `{ fn, vars, ast }` or `{ error }`, never throws. */
26
27
  function compileExpr(src) {
27
28
  if (!src || !src.trim()) return { error: "Empty expression" };
28
29
  let ast;
@@ -39,4 +40,4 @@ function compileExpr(src) {
39
40
  }
40
41
 
41
42
  //#endregion
42
- export { compileExpr, compileNode, differentiate, evaluate, freeVars, parse, simplify, toLatex };
43
+ export { compile, compileExpr, compileNode, definiteIntegral, differentiate, evaluate, freeVars, integrate, parse, simplify, toLatex };
@@ -103,6 +103,14 @@ function texCall(node) {
103
103
  if (node.fn === "cbrt") return `\\sqrt[3]{${wrap(a0, 0)}}`;
104
104
  if (node.fn === "abs") return `\\left|${wrap(a0, 0)}\\right|`;
105
105
  if (node.fn === "exp") return `e^{${wrap(a0, 0)}}`;
106
+ /**
107
+ * `ln|x|`, not `ln(|x|)`.
108
+ *
109
+ * The modulus bars already delimit the argument, so the usual parentheses double up. Every
110
+ * textbook and mark scheme writes the integral of 1/x as ln|x|, and that is the form a learner
111
+ * has to reproduce, so it is the form to show.
112
+ */
113
+ if ((node.fn === "ln" || node.fn === "log") && a0.type === "call" && a0.fn === "abs" && a0.args[0]) return `\\${node.fn}\\left|${wrap(a0.args[0], 0)}\\right|`;
106
114
  const args = node.args.map((a) => wrap(a, 0)).join(", ");
107
115
  return `${NAMED_FN.has(node.fn) ? `\\${node.fn}` : `\\operatorname{${node.fn}}`}\\left(${args}\\right)`;
108
116
  }
@@ -4,11 +4,11 @@ import { bin, call, neg, num, variable } from "./ast.mjs";
4
4
 
5
5
  //#region src/math/parse.ts
6
6
  /**
7
- * expr/parse token stream → AST, via precedence-climbing (Pratt).
7
+ * expr/parse, token stream → AST, via precedence-climbing (Pratt).
8
8
  *
9
9
  * Binary precedence: `+ -` < `* / %` < `^` (right-assoc). Unary minus binds
10
10
  * looser than `^` (so `-2^2 = -(2^2) = -4`) but tighter than `*`. Throws a
11
- * descriptive Error on malformed input the parser is the single source of
11
+ * descriptive Error on malformed input, the parser is the single source of
12
12
  * truth for "is this expression well-formed?".
13
13
  */
14
14
  const BIN_PREC = {
@@ -2,7 +2,7 @@ import { isFunction } from "./defs.mjs";
2
2
 
3
3
  //#region src/math/tokenize.ts
4
4
  /**
5
- * expr/tokenize source string → token stream.
5
+ * expr/tokenize, source string → token stream.
6
6
  *
7
7
  * Handles numbers (incl. `1.5e3`, rejecting malformed `2..3`), identifiers
8
8
  * (classified as function vs variable), operators, parens, and commas. Inserts
@@ -7,16 +7,16 @@ import { jsx } from "react/jsx-runtime";
7
7
 
8
8
  //#region src/primitives/CanvasLayer.tsx
9
9
  /**
10
- * <CanvasLayer> the engine's HIGH-ELEMENT-COUNT escape hatch: a zero-dependency
10
+ * <CanvasLayer>, the engine's HIGH-ELEMENT-COUNT escape hatch: a zero-dependency
11
11
  * HiDPI <canvas> that shares the engine's coordinate system + clock. Use it when
12
12
  * a lab needs a heatmap, a particle/gas sim, or thousands of moving dots at 60fps
13
- * cases where one retained SVG node per element would drop frames. Declarative,
13
+ *, cases where one retained SVG node per element would drop frames. Declarative,
14
14
  * low-count, accessible figures should stay on <Stage> (SVG); this is the
15
15
  * deliberate, bundle-free alternative to a WebGL engine (no Pixi/three dependency)
16
16
  * until a lab MEASURABLY needs shaders/tens-of-thousands of textured sprites.
17
17
  *
18
18
  * `draw(ctx, coords)` runs in CSS-pixel space (the context is pre-scaled for dpr)
19
- * on mount, on resize, and whenever `draw`'s identity changes so memoize it on
19
+ * on mount, on resize, and whenever `draw`'s identity changes, so memoize it on
20
20
  * your state (or bump it from a useFrameLoop tick) to drive animation. Overlay a
21
21
  * sibling <Stage> with the SAME view for accessible SVG handles/labels on top.
22
22
  */
@@ -1,5 +1,4 @@
1
1
  import { StyleProps } from "./props.mjs";
2
- import { ReactNode } from "react";
3
2
 
4
3
  //#region src/primitives/Dot.d.ts
5
4
  interface DotProps extends StyleProps {
@@ -8,20 +7,8 @@ interface DotProps extends StyleProps {
8
7
  /** Pixel radius (never scaled). */
9
8
  r?: number;
10
9
  }
11
- declare function Dot({
12
- x,
13
- y,
14
- r,
15
- color,
16
- opacity
17
- }: DotProps): ReactNode;
10
+ declare const Dot: import("react").NamedExoticComponent<DotProps>;
18
11
  /** A point with a contrast ring (reads clearly over any background). */
19
- declare function Point({
20
- x,
21
- y,
22
- r,
23
- color,
24
- opacity
25
- }: DotProps): ReactNode;
12
+ declare const Point: import("react").NamedExoticComponent<DotProps>;
26
13
  //#endregion
27
14
  export { Dot, DotProps, Point };