@johnhenry/math 0.0.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 (242) hide show
  1. package/README.md +165 -0
  2. package/dist/Bivector4.d.ts +52 -0
  3. package/dist/Bivector4.d.ts.map +1 -0
  4. package/dist/Bivector4.js +105 -0
  5. package/dist/Bivector4.js.map +1 -0
  6. package/dist/CellGraph.d.ts +259 -0
  7. package/dist/CellGraph.d.ts.map +1 -0
  8. package/dist/CellGraph.js +637 -0
  9. package/dist/CellGraph.js.map +1 -0
  10. package/dist/Clifford4Internal.d.ts +35 -0
  11. package/dist/Clifford4Internal.d.ts.map +1 -0
  12. package/dist/Clifford4Internal.js +86 -0
  13. package/dist/Clifford4Internal.js.map +1 -0
  14. package/dist/Combinatorics.d.ts +61 -0
  15. package/dist/Combinatorics.d.ts.map +1 -0
  16. package/dist/Combinatorics.js +186 -0
  17. package/dist/Combinatorics.js.map +1 -0
  18. package/dist/CombinatoricsGenerators.d.ts +44 -0
  19. package/dist/CombinatoricsGenerators.d.ts.map +1 -0
  20. package/dist/CombinatoricsGenerators.js +148 -0
  21. package/dist/CombinatoricsGenerators.js.map +1 -0
  22. package/dist/ComplexMath.d.ts +46 -0
  23. package/dist/ComplexMath.d.ts.map +1 -0
  24. package/dist/ComplexMath.js +98 -0
  25. package/dist/ComplexMath.js.map +1 -0
  26. package/dist/ComplexNumber.d.ts +174 -0
  27. package/dist/ComplexNumber.d.ts.map +1 -0
  28. package/dist/ComplexNumber.js +443 -0
  29. package/dist/ComplexNumber.js.map +1 -0
  30. package/dist/Cycle.d.ts +42 -0
  31. package/dist/Cycle.d.ts.map +1 -0
  32. package/dist/Cycle.js +120 -0
  33. package/dist/Cycle.js.map +1 -0
  34. package/dist/Decimal.d.ts +45 -0
  35. package/dist/Decimal.d.ts.map +1 -0
  36. package/dist/Decimal.js +135 -0
  37. package/dist/Decimal.js.map +1 -0
  38. package/dist/Distributions.d.ts +55 -0
  39. package/dist/Distributions.d.ts.map +1 -0
  40. package/dist/Distributions.js +222 -0
  41. package/dist/Distributions.js.map +1 -0
  42. package/dist/DualNumber.d.ts +42 -0
  43. package/dist/DualNumber.d.ts.map +1 -0
  44. package/dist/DualNumber.js +91 -0
  45. package/dist/DualNumber.js.map +1 -0
  46. package/dist/Environment.d.ts +44 -0
  47. package/dist/Environment.d.ts.map +1 -0
  48. package/dist/Environment.js +129 -0
  49. package/dist/Environment.js.map +1 -0
  50. package/dist/Expression.d.ts +19 -0
  51. package/dist/Expression.d.ts.map +1 -0
  52. package/dist/Expression.js +43 -0
  53. package/dist/Expression.js.map +1 -0
  54. package/dist/FFT.d.ts +21 -0
  55. package/dist/FFT.d.ts.map +1 -0
  56. package/dist/FFT.js +136 -0
  57. package/dist/FFT.js.map +1 -0
  58. package/dist/GeometricAlgebra4.d.ts +73 -0
  59. package/dist/GeometricAlgebra4.d.ts.map +1 -0
  60. package/dist/GeometricAlgebra4.js +115 -0
  61. package/dist/GeometricAlgebra4.js.map +1 -0
  62. package/dist/Geometry.d.ts +41 -0
  63. package/dist/Geometry.d.ts.map +1 -0
  64. package/dist/Geometry.js +167 -0
  65. package/dist/Geometry.js.map +1 -0
  66. package/dist/Graph.d.ts +72 -0
  67. package/dist/Graph.d.ts.map +1 -0
  68. package/dist/Graph.js +337 -0
  69. package/dist/Graph.js.map +1 -0
  70. package/dist/Graph3DUtils.d.ts +80 -0
  71. package/dist/Graph3DUtils.d.ts.map +1 -0
  72. package/dist/Graph3DUtils.js +156 -0
  73. package/dist/Graph3DUtils.js.map +1 -0
  74. package/dist/GraphUtils.d.ts +67 -0
  75. package/dist/GraphUtils.d.ts.map +1 -0
  76. package/dist/GraphUtils.js +72 -0
  77. package/dist/GraphUtils.js.map +1 -0
  78. package/dist/GroupTheory.d.ts +73 -0
  79. package/dist/GroupTheory.d.ts.map +1 -0
  80. package/dist/GroupTheory.js +194 -0
  81. package/dist/GroupTheory.js.map +1 -0
  82. package/dist/IntUtils.d.ts +33 -0
  83. package/dist/IntUtils.d.ts.map +1 -0
  84. package/dist/IntUtils.js +185 -0
  85. package/dist/IntUtils.js.map +1 -0
  86. package/dist/Interval.d.ts +44 -0
  87. package/dist/Interval.d.ts.map +1 -0
  88. package/dist/Interval.js +165 -0
  89. package/dist/Interval.js.map +1 -0
  90. package/dist/Lattice.d.ts +48 -0
  91. package/dist/Lattice.d.ts.map +1 -0
  92. package/dist/Lattice.js +65 -0
  93. package/dist/Lattice.js.map +1 -0
  94. package/dist/Logic.d.ts +22 -0
  95. package/dist/Logic.d.ts.map +1 -0
  96. package/dist/Logic.js +34 -0
  97. package/dist/Logic.js.map +1 -0
  98. package/dist/MatrixMath.d.ts +71 -0
  99. package/dist/MatrixMath.d.ts.map +1 -0
  100. package/dist/MatrixMath.js +302 -0
  101. package/dist/MatrixMath.js.map +1 -0
  102. package/dist/NumberTheory.d.ts +49 -0
  103. package/dist/NumberTheory.d.ts.map +1 -0
  104. package/dist/NumberTheory.js +230 -0
  105. package/dist/NumberTheory.js.map +1 -0
  106. package/dist/Numerical.d.ts +85 -0
  107. package/dist/Numerical.d.ts.map +1 -0
  108. package/dist/Numerical.js +343 -0
  109. package/dist/Numerical.js.map +1 -0
  110. package/dist/Permutation.d.ts +43 -0
  111. package/dist/Permutation.d.ts.map +1 -0
  112. package/dist/Permutation.js +142 -0
  113. package/dist/Permutation.js.map +1 -0
  114. package/dist/Polygon.d.ts +64 -0
  115. package/dist/Polygon.d.ts.map +1 -0
  116. package/dist/Polygon.js +221 -0
  117. package/dist/Polygon.js.map +1 -0
  118. package/dist/PolynomialRing.d.ts +74 -0
  119. package/dist/PolynomialRing.d.ts.map +1 -0
  120. package/dist/PolynomialRing.js +231 -0
  121. package/dist/PolynomialRing.js.map +1 -0
  122. package/dist/Quaternion.d.ts +37 -0
  123. package/dist/Quaternion.d.ts.map +1 -0
  124. package/dist/Quaternion.js +106 -0
  125. package/dist/Quaternion.js.map +1 -0
  126. package/dist/Rational.d.ts +34 -0
  127. package/dist/Rational.d.ts.map +1 -0
  128. package/dist/Rational.js +136 -0
  129. package/dist/Rational.js.map +1 -0
  130. package/dist/RealMath.d.ts +112 -0
  131. package/dist/RealMath.d.ts.map +1 -0
  132. package/dist/RealMath.js +262 -0
  133. package/dist/RealMath.js.map +1 -0
  134. package/dist/Rotor4.d.ts +180 -0
  135. package/dist/Rotor4.d.ts.map +1 -0
  136. package/dist/Rotor4.js +388 -0
  137. package/dist/Rotor4.js.map +1 -0
  138. package/dist/SpecialFunctions.d.ts +32 -0
  139. package/dist/SpecialFunctions.d.ts.map +1 -0
  140. package/dist/SpecialFunctions.js +161 -0
  141. package/dist/SpecialFunctions.js.map +1 -0
  142. package/dist/SpecialOperator.d.ts +21 -0
  143. package/dist/SpecialOperator.d.ts.map +1 -0
  144. package/dist/SpecialOperator.js +26 -0
  145. package/dist/SpecialOperator.js.map +1 -0
  146. package/dist/Statistics.d.ts +50 -0
  147. package/dist/Statistics.d.ts.map +1 -0
  148. package/dist/Statistics.js +131 -0
  149. package/dist/Statistics.js.map +1 -0
  150. package/dist/StringEvaluator.d.ts +48 -0
  151. package/dist/StringEvaluator.d.ts.map +1 -0
  152. package/dist/StringEvaluator.js +190 -0
  153. package/dist/StringEvaluator.js.map +1 -0
  154. package/dist/Structure.d.ts +130 -0
  155. package/dist/Structure.d.ts.map +1 -0
  156. package/dist/Structure.js +394 -0
  157. package/dist/Structure.js.map +1 -0
  158. package/dist/Symbolic.d.ts +501 -0
  159. package/dist/Symbolic.d.ts.map +1 -0
  160. package/dist/Symbolic.js +4077 -0
  161. package/dist/Symbolic.js.map +1 -0
  162. package/dist/Type.d.ts +31 -0
  163. package/dist/Type.d.ts.map +1 -0
  164. package/dist/Type.js +47 -0
  165. package/dist/Type.js.map +1 -0
  166. package/dist/Utilities.d.ts +27 -0
  167. package/dist/Utilities.d.ts.map +1 -0
  168. package/dist/Utilities.js +47 -0
  169. package/dist/Utilities.js.map +1 -0
  170. package/dist/Vec4.d.ts +39 -0
  171. package/dist/Vec4.d.ts.map +1 -0
  172. package/dist/Vec4.js +82 -0
  173. package/dist/Vec4.js.map +1 -0
  174. package/dist/Vector.d.ts +83 -0
  175. package/dist/Vector.d.ts.map +1 -0
  176. package/dist/Vector.js +173 -0
  177. package/dist/Vector.js.map +1 -0
  178. package/dist/VectorCalculus.d.ts +42 -0
  179. package/dist/VectorCalculus.d.ts.map +1 -0
  180. package/dist/VectorCalculus.js +82 -0
  181. package/dist/VectorCalculus.js.map +1 -0
  182. package/dist/VectorUtils.d.ts +172 -0
  183. package/dist/VectorUtils.d.ts.map +1 -0
  184. package/dist/VectorUtils.js +706 -0
  185. package/dist/VectorUtils.js.map +1 -0
  186. package/dist/differentiation-rules.d.ts +21 -0
  187. package/dist/differentiation-rules.d.ts.map +1 -0
  188. package/dist/differentiation-rules.js +52 -0
  189. package/dist/differentiation-rules.js.map +1 -0
  190. package/dist/index.d.ts +52 -0
  191. package/dist/index.d.ts.map +1 -0
  192. package/dist/index.js +66 -0
  193. package/dist/index.js.map +1 -0
  194. package/package.json +68 -0
  195. package/src/Bivector4.ts +136 -0
  196. package/src/CellGraph.ts +669 -0
  197. package/src/Clifford4Internal.ts +90 -0
  198. package/src/Combinatorics.ts +183 -0
  199. package/src/CombinatoricsGenerators.ts +144 -0
  200. package/src/ComplexMath.ts +162 -0
  201. package/src/ComplexNumber.ts +491 -0
  202. package/src/Cycle.ts +126 -0
  203. package/src/Decimal.ts +145 -0
  204. package/src/Distributions.ts +269 -0
  205. package/src/DualNumber.ts +105 -0
  206. package/src/Environment.ts +127 -0
  207. package/src/Expression.ts +48 -0
  208. package/src/FFT.ts +146 -0
  209. package/src/GeometricAlgebra4.ts +157 -0
  210. package/src/Geometry.ts +173 -0
  211. package/src/Graph.ts +337 -0
  212. package/src/Graph3DUtils.ts +269 -0
  213. package/src/GraphUtils.ts +159 -0
  214. package/src/GroupTheory.ts +214 -0
  215. package/src/IntUtils.ts +200 -0
  216. package/src/Interval.ts +171 -0
  217. package/src/Lattice.ts +87 -0
  218. package/src/Logic.ts +38 -0
  219. package/src/MatrixMath.ts +326 -0
  220. package/src/NumberTheory.ts +228 -0
  221. package/src/Numerical.ts +390 -0
  222. package/src/Permutation.ts +148 -0
  223. package/src/Polygon.ts +229 -0
  224. package/src/PolynomialRing.ts +242 -0
  225. package/src/Quaternion.ts +125 -0
  226. package/src/Rational.ts +148 -0
  227. package/src/RealMath.ts +346 -0
  228. package/src/Rotor4.ts +440 -0
  229. package/src/SpecialFunctions.ts +152 -0
  230. package/src/SpecialOperator.ts +28 -0
  231. package/src/Statistics.ts +170 -0
  232. package/src/StringEvaluator.ts +208 -0
  233. package/src/Structure.ts +481 -0
  234. package/src/Symbolic.ts +4313 -0
  235. package/src/Type.ts +48 -0
  236. package/src/Utilities.ts +49 -0
  237. package/src/Vec4.ts +98 -0
  238. package/src/Vector.ts +189 -0
  239. package/src/VectorCalculus.ts +87 -0
  240. package/src/VectorUtils.ts +797 -0
  241. package/src/differentiation-rules.ts +73 -0
  242. package/src/index.ts +140 -0
@@ -0,0 +1,669 @@
1
+ /**
2
+ * CellGraph — a reactive, pull-based dependency graph. Cells hold typed
3
+ * values (numbers, points, curves, whatever) rather than flattened
4
+ * primitives; a derived cell's `compute` reads other cells via `graph.get`,
5
+ * and the graph records the resulting edges automatically (no manual tag
6
+ * declarations, unlike mcp-query's cache).
7
+ *
8
+ * Writes (`set`) mark transitive dependents dirty and bump their version
9
+ * counters immediately — cheap, no recompute. Recomputation happens lazily,
10
+ * the next time a dirty cell is `get`, and a structural-equality check skips
11
+ * the version bump (and further propagation) when a recompute produces a
12
+ * value that's deep-equal to what was already cached, so unaffected
13
+ * downstream consumers don't re-render.
14
+ *
15
+ * Promoted here from johnhenry/mallory (originally `src/lib/cell-
16
+ * graph.ts`, powering every panel there via `useCell`/`useSyncExternalStore`)
17
+ * once johnhenry/math-grapher became a second consumer of a frozen
18
+ * vendored copy — two consumers is this monorepo's own extract-to-a-package
19
+ * trigger. See johnhenry/math#56. UI-framework-agnostic and dependency-
20
+ * free by design: nothing here references React or any host application.
21
+ */
22
+
23
+ type Listener = () => void;
24
+ type ComputeFn<T> = () => T;
25
+
26
+ interface CellRecord<T = unknown> {
27
+ value: T | undefined;
28
+ hasValue: boolean;
29
+ version: number;
30
+ dirty: boolean;
31
+ compute?: ComputeFn<T>;
32
+ dependencies: Set<string>;
33
+ dependents: Set<string>;
34
+ auxiliary: boolean;
35
+ /**
36
+ * Set when `compute` most recently threw, so a failing (possibly
37
+ * expensive) compute doesn't get re-run on every subsequent `get()` --
38
+ * see {@link CellGraph.get} and {@link CellGraph.recomputeAndEmit}.
39
+ * Cleared by anything that also clears `dirty`'s stale-value equivalent:
40
+ * a fresh `set()`/successful recompute.
41
+ */
42
+ hasError: boolean;
43
+ error?: unknown;
44
+ }
45
+
46
+ export type CellRole = "free" | "dependent" | "unknown";
47
+
48
+ export class CircularDependencyError extends Error {
49
+ constructor(path: string[]) {
50
+ super(`Circular dependency detected in cell graph: ${path.join(" -> ")}`);
51
+ this.name = "CircularDependencyError";
52
+ }
53
+ }
54
+
55
+ export class CellGraph {
56
+ private cells = new Map<string, CellRecord>();
57
+ private listeners = new Map<string, Set<Listener>>();
58
+ private globalListeners = new Set<Listener>();
59
+ private writeListeners = new Set<(id: string, value: unknown) => void>();
60
+ private stack: string[] = [];
61
+ private emitting = new Set<string>();
62
+ /**
63
+ * Nesting depth of the current "logical write" -- incremented around the
64
+ * body of every top-level mutating entry point ({@link set}, {@link
65
+ * define}, {@link delete}) and decremented in a `finally` when it returns.
66
+ * A single `set()` on a root of a diamond-shaped dependency graph cascades
67
+ * through `propagateDirty` and calls `emit()` once per cell it reaches
68
+ * (root, then each newly-dirtied dependent) -- all of those belong to the
69
+ * SAME logical write and must produce exactly one `subscribeAll`
70
+ * notification, not one per cell. While this is above zero, a request to
71
+ * notify global listeners (see {@link scheduleGlobalNotify}) is deferred
72
+ * instead of firing immediately; it only actually fires once depth
73
+ * unwinds back to zero, i.e. once the outermost `set`/`define`/`delete`
74
+ * call (including any cells it transitively dirtied) has fully finished.
75
+ */
76
+ private globalNotifyDepth = 0;
77
+ /** Set by {@link scheduleGlobalNotify} while `globalNotifyDepth > 0`, so the deferred notification isn't lost once depth returns to zero. */
78
+ private globalNotifyPending = false;
79
+ /**
80
+ * Guards the global-listener-invocation loop itself against being
81
+ * re-entered while it's already running -- e.g. a global listener that
82
+ * synchronously calls `set()` on a DIFFERENT cell from inside its own
83
+ * callback. Unlike the shared boolean this replaces, a reentrant request
84
+ * that arrives while this is `true` is NOT dropped: it re-arms {@link
85
+ * globalNotifyPending}, which is drained (fired again) right after the
86
+ * in-progress loop finishes -- see {@link flushGlobalNotify}. This is what
87
+ * keeps a reentrant write to a different cell from being silently
88
+ * swallowed (issue #234), while still bounding recursion depth to one
89
+ * extra level per reentrant write, the same way `emitting` bounds it for
90
+ * same-id reentrancy on local listeners.
91
+ */
92
+ private notifyingGlobalListeners = false;
93
+
94
+ /**
95
+ * Write a raw source-of-truth value (e.g. a slider drag or text input).
96
+ * Structurally, a cell written via `set` (no `compute` fn) is what makes
97
+ * it "free" -- see {@link role}.
98
+ *
99
+ * @param options.auxiliary Marks this cell hidden-by-default in an
100
+ * Algebra-view-style listing (see {@link list}) -- e.g. an internal
101
+ * sampling parameter that isn't itself meaningful to show the user,
102
+ * distinct from a top-level named object. Only applied when the cell
103
+ * doesn't already exist, or is transitioning from a compute-backed
104
+ * (dependent) cell to a free one; an existing free cell's auxiliary flag
105
+ * is left as originally set.
106
+ */
107
+ set<T>(id: string, value: T, options?: { auxiliary?: boolean }): void {
108
+ this.runBatched(() => {
109
+ const cell = this.ensure<T>(id);
110
+ const wasCompute = cell.compute !== undefined;
111
+ cell.compute = undefined;
112
+ if (wasCompute) {
113
+ // This cell is transitioning from dependent (had a compute fn) to
114
+ // free -- detach its recorded dependency edges the same way
115
+ // recomputeAndEmit() and delete() already do, so former upstream
116
+ // cells stop spuriously dirtying/emitting a cell they no longer feed
117
+ // into.
118
+ for (const depId of cell.dependencies) this.cells.get(depId)?.dependents.delete(id);
119
+ cell.dependencies = new Set();
120
+ }
121
+ if (options?.auxiliary !== undefined && (wasCompute || !cell.hasValue)) cell.auxiliary = options.auxiliary;
122
+ cell.hasError = false;
123
+ cell.error = undefined;
124
+ const unchanged = cell.hasValue && structuralEqual(cell.value, value);
125
+ cell.dirty = false;
126
+ if (unchanged) return;
127
+ // Only replace the cached reference on a real change, so a write that's
128
+ // structurally equal to the old value never disturbs downstream identity.
129
+ cell.value = value;
130
+ cell.hasValue = true;
131
+ cell.version++;
132
+ this.emitWrite(id, value);
133
+ this.emit(id);
134
+ this.propagateDirty(id);
135
+ });
136
+ }
137
+
138
+ /**
139
+ * Subscribe to raw `set()` writes -- id plus the new value -- across
140
+ * every cell in the graph (issue #47's live-collaboration groundwork).
141
+ * Deliberately narrower than `subscribeAll`: it fires ONLY for an actual
142
+ * `set()` call with a real (structurally-different) value, never for a
143
+ * `define()`-driven recompute cascade. That's the exact shape a
144
+ * broadcast-to-other-peers use case needs -- each peer re-derives its
145
+ * own dependent cells locally once a base write arrives over the wire
146
+ * and gets applied via `set()` there too, so only the raw user-initiated
147
+ * writes need to cross the network, not every recomputed cell downstream
148
+ * of them.
149
+ */
150
+ subscribeWrites(fn: (id: string, value: unknown) => void): () => void {
151
+ this.writeListeners.add(fn);
152
+ return () => this.writeListeners.delete(fn);
153
+ }
154
+
155
+ private emitWrite(id: string, value: unknown): void {
156
+ for (const fn of this.writeListeners) fn(id, value);
157
+ }
158
+
159
+ /**
160
+ * Define (or redefine) a derived cell computed from other cells.
161
+ * Structurally, a cell written via `define` (has a `compute` fn) is what
162
+ * makes it "dependent" -- see {@link role}.
163
+ *
164
+ * @param options.auxiliary See {@link set}'s equivalent option; applied
165
+ * the same way (only on first definition, or a transition from free to
166
+ * dependent).
167
+ */
168
+ define<T>(id: string, compute: ComputeFn<T>, options?: { auxiliary?: boolean }): void {
169
+ this.runBatched(() => this.defineImpl(id, compute, options));
170
+ }
171
+
172
+ private defineImpl<T>(id: string, compute: ComputeFn<T>, options?: { auxiliary?: boolean }): void {
173
+ const cell = this.ensure<T>(id);
174
+ const wasFree = cell.compute === undefined && cell.hasValue;
175
+ const isRedefine = cell.hasValue;
176
+ if (options?.auxiliary !== undefined && (wasFree || cell.compute === undefined)) cell.auxiliary = options.auxiliary;
177
+ cell.compute = compute;
178
+ cell.dirty = true;
179
+ cell.hasError = false;
180
+ cell.error = undefined;
181
+ // A cell being defined for the first time (no prior value) is
182
+ // guaranteed to change once computed -- hasValue flips false -> true
183
+ // regardless of what the compute returns -- so it's safe to eagerly
184
+ // bump its version and notify its own subscribers now, same as before,
185
+ // with no need to actually run the compute early.
186
+ //
187
+ // A cell that already has a value is being *redefined*: unlike set(),
188
+ // define() doesn't have the new value in hand to structuralEqual-check
189
+ // synchronously -- only a function. The fix for #15 is NOT "defer the
190
+ // notification to some future get()": nothing guarantees a future
191
+ // get() ever happens. useSyncExternalStore's subscribe callback (see
192
+ // use-cell.ts) is what schedules a component's next render in the
193
+ // first place, and that callback only fires from emit() -- so an
194
+ // un-emitted genuine change would sit invisible until something
195
+ // UNRELATED happens to re-render the same subscribed component. That's
196
+ // a missed update, strictly worse than the unwanted-churn bug #15 set
197
+ // out to fix. Instead, resolve it the same way set() does: eagerly,
198
+ // synchronously, right here -- via recomputeAndEmit, which already
199
+ // implements exactly the "recompute, structuralEqual-gate the version
200
+ // bump/emit" logic this needs (and already detaches/rebuilds
201
+ // `dependencies` correctly as part of that). A throwing new compute is
202
+ // swallowed here (recomputeAndEmit already cached it on the cell via
203
+ // #14's hasError/error fields before rethrowing) so define() keeps its
204
+ // pre-existing never-throws-synchronously contract; the cached error
205
+ // surfaces through the normal get()-throws path on the next read.
206
+ if (isRedefine) {
207
+ try {
208
+ this.recomputeAndEmit(id, cell);
209
+ } catch {
210
+ // Cached on the cell already; surfaced via get(), not here.
211
+ }
212
+ } else {
213
+ cell.version++;
214
+ this.emit(id);
215
+ }
216
+ this.propagateDirty(id);
217
+ }
218
+
219
+ /**
220
+ * Whether `id` is "free" (writable directly via `set`, no `compute` fn --
221
+ * e.g. a slider or text input), "dependent" (computed via `define` from
222
+ * other cells), or "unknown" (`id` has never been `set`/`define`d, only
223
+ * read, or doesn't exist at all).
224
+ */
225
+ role(id: string): CellRole {
226
+ const cell = this.cells.get(id);
227
+ if (!cell?.hasValue) return "unknown";
228
+ return cell.compute ? "dependent" : "free";
229
+ }
230
+
231
+ /** Whether `id` was marked `auxiliary` (hidden-by-default in an Algebra-view-style listing) -- see {@link set}/{@link define}. */
232
+ isAuxiliary(id: string): boolean {
233
+ return this.cells.get(id)?.auxiliary ?? false;
234
+ }
235
+
236
+ /**
237
+ * Every cell currently in the graph, with its role and auxiliary flag --
238
+ * the basis for an Algebra-view-style listing (GeoGebra's free/dependent/
239
+ * auxiliary object model). Includes cells with no value yet (role
240
+ * "unknown") since a caller may still want to know such an id exists
241
+ * (e.g. was read but never written).
242
+ */
243
+ list(): Array<{ id: string; role: CellRole; auxiliary: boolean; hasValue: boolean }> {
244
+ return [...this.cells.entries()].map(([id, cell]) => ({
245
+ id,
246
+ role: this.role(id),
247
+ auxiliary: cell.auxiliary,
248
+ hasValue: cell.hasValue,
249
+ }));
250
+ }
251
+
252
+ /** Read a cell's current value, recomputing if stale. Auto-tracks dependency edges. */
253
+ get<T>(id: string): T {
254
+ const cell = this.ensure<T>(id);
255
+
256
+ // The cell currently being computed (if any) reads `id` — record the edge.
257
+ const caller = this.stack.at(-1);
258
+ if (caller !== undefined) {
259
+ this.cells.get(caller)?.dependencies.add(id);
260
+ cell.dependents.add(caller);
261
+ }
262
+
263
+ if (cell.dirty && cell.compute) {
264
+ if (this.stack.includes(id)) throw new CircularDependencyError([...this.stack, id]);
265
+
266
+ this.recomputeAndEmit(id, cell);
267
+ }
268
+
269
+ // A cached compute failure (see recomputeAndEmit's catch) is rethrown
270
+ // here on every subsequent get() -- without re-running the compute --
271
+ // until the same conditions that would invalidate a cached value (a
272
+ // fresh set()/define(), or a real upstream change marking this cell
273
+ // dirty again) clear it.
274
+ if (cell.hasError) throw cell.error;
275
+
276
+ return cell.value as T;
277
+ }
278
+
279
+ private recomputeAndEmit<T>(id: string, cell: CellRecord<T>): void {
280
+ // Dependencies may differ between evaluations (e.g. a conditional
281
+ // expression) — detach from the old set before recomputing fresh.
282
+ for (const depId of cell.dependencies) this.cells.get(depId)?.dependents.delete(id);
283
+ cell.dependencies = new Set();
284
+
285
+ this.stack.push(id);
286
+ let next: T;
287
+ try {
288
+ next = cell.compute!() as T;
289
+ } catch (err) {
290
+ // Cache the failure so a subsequent get() (another render pass, a
291
+ // sibling compute reading this cell again, etc.) rethrows the same
292
+ // cached error instead of re-running a compute that's going to fail
293
+ // identically. `dirty` is still cleared here: this recompute attempt
294
+ // did happen and did resolve (to a failure), and staying dirty would
295
+ // otherwise force every future get() back through the compute anyway.
296
+ cell.dirty = false;
297
+ cell.hasError = true;
298
+ cell.error = err;
299
+ throw err;
300
+ } finally {
301
+ this.stack.pop();
302
+ }
303
+
304
+ cell.dirty = false;
305
+ cell.hasError = false;
306
+ cell.error = undefined;
307
+
308
+ const unchanged = cell.hasValue && structuralEqual(cell.value, next);
309
+
310
+ if (!unchanged) {
311
+ // Reassign only on a real change, preserving the old reference on a
312
+ // no-op recompute — this is what lets a downstream Object.is check
313
+ // (e.g. React's useSyncExternalStore, or React.memo) bail out.
314
+ cell.value = next;
315
+ cell.hasValue = true;
316
+ cell.version++;
317
+ this.emit(id);
318
+ }
319
+ }
320
+
321
+ /** The value useSyncExternalStore observes for this cell. */
322
+ getVersion(id: string): number {
323
+ return this.cells.get(id)?.version ?? 0;
324
+ }
325
+
326
+ /** Subscribe to changes on one cell. Returns an unsubscribe function. */
327
+ subscribe(id: string, fn: Listener): () => void {
328
+ let set = this.listeners.get(id);
329
+ if (!set) {
330
+ set = new Set();
331
+ this.listeners.set(id, set);
332
+ }
333
+ set.add(fn);
334
+ this.ensure(id);
335
+ return () => {
336
+ set.delete(fn);
337
+ if (set.size !== 0) return;
338
+ // Last subscriber for this id gone -- drop the now-empty Set instead
339
+ // of leaving it behind forever (an id that's subscribed-to-then-
340
+ // unsubscribed-from many times over a session, e.g. a dynamically
341
+ // created/destroyed notebook row, would otherwise accumulate one dead
342
+ // empty Set per id in `listeners`).
343
+ this.listeners.delete(id);
344
+ // If this cell was never actually given a value or a compute (i.e.
345
+ // the only reason it exists in `cells` at all is the ensure() call a
346
+ // few lines up) and nothing else in the graph references it (no
347
+ // dependents, no recorded dependencies), there's nothing left to
348
+ // justify keeping that phantom record around either -- clean it up
349
+ // too. A genuinely "live" cell (has a real value, is define()d, or
350
+ // participates in the dependency graph as someone's dependency/
351
+ // dependent) is untouched here; only explicit delete() removes those,
352
+ // per its own documented semantics -- this is deliberately narrower
353
+ // than delete() and never fires the "former dependents" notification
354
+ // delete() does, since there's no real removal of live state here.
355
+ const cell = this.cells.get(id);
356
+ if (
357
+ cell &&
358
+ !cell.hasValue &&
359
+ cell.compute === undefined &&
360
+ cell.dependents.size === 0 &&
361
+ cell.dependencies.size === 0
362
+ ) {
363
+ this.cells.delete(id);
364
+ }
365
+ };
366
+ }
367
+
368
+ /** Subscribe to changes on any cell (e.g. to drive a canvas render loop). */
369
+ subscribeAll(fn: Listener): () => void {
370
+ this.globalListeners.add(fn);
371
+ return () => this.globalListeners.delete(fn);
372
+ }
373
+
374
+ /**
375
+ * Subscribe to changes on a fixed, known set of specific cells -- e.g.
376
+ * every cell a serialized URL fragment reads. Narrower than
377
+ * `subscribeAll`: `fn` fires only for a write on one of `ids`, not for
378
+ * literally any cell in the graph (issue #235 -- several panels' URL-sync
379
+ * effects used `subscribeAll` for exactly this "a handful of specific
380
+ * cells" shape, so they also fired on unrelated high-frequency writes
381
+ * elsewhere in the same graph -- an RAF-driven playback clock, a live
382
+ * drag preview, a per-epoch training metric -- that the listener's own
383
+ * output never actually depended on). Just sugar over one `subscribe`
384
+ * call per id; not appropriate when the relevant id set is itself dynamic
385
+ * (e.g. "every currently-visible row's cells", where the row list can
386
+ * grow/shrink) -- that shape still needs `subscribeAll` (ideally
387
+ * debounced) or per-id `subscribe` calls re-issued as the id set changes.
388
+ */
389
+ subscribeMany(ids: string[], fn: Listener): () => void {
390
+ const unsubscribes = ids.map((id) => this.subscribe(id, fn));
391
+ return () => {
392
+ for (const unsubscribe of unsubscribes) unsubscribe();
393
+ };
394
+ }
395
+
396
+ has(id: string): boolean {
397
+ return this.cells.has(id);
398
+ }
399
+
400
+ /**
401
+ * Whether `id` has a real value yet, as opposed to merely existing as an
402
+ * empty record (`has()` returns true for a cell the instant anything reads
403
+ * it via `get`, even before it's ever been `set` or `define`d -- not a
404
+ * reliable "should I seed this?" check from a post-render effect that runs
405
+ * after a sibling compute has already read-and-thus-created it).
406
+ */
407
+ hasValue(id: string): boolean {
408
+ return this.cells.get(id)?.hasValue ?? false;
409
+ }
410
+
411
+ /**
412
+ * Remove a cell entirely. Former *dependents* are marked dirty and
413
+ * notified (same as a `set()` would), so a compute that read the deleted
414
+ * cell re-runs and can fall back to whatever "this cell doesn't exist"
415
+ * means for it -- without this, a dependent kept its stale cached value
416
+ * forever, AND (because its dependency edges only rebuild during a
417
+ * recompute that never came) writes to its other dependencies stopped
418
+ * reaching it too. The notification happens *after* the cell is gone, so
419
+ * the reentrant recompute a listener may trigger sees the post-delete
420
+ * world: a `get()` on the deleted id re-creates an empty record
421
+ * (`hasValue: false`), exactly the "never existed" semantics callers
422
+ * like ExpressionRow's params compute already handle.
423
+ */
424
+ delete(id: string): void {
425
+ this.runBatched(() => {
426
+ const cell = this.cells.get(id);
427
+ if (!cell) return;
428
+ const formerDependents = [...cell.dependents];
429
+ for (const depId of cell.dependencies) this.cells.get(depId)?.dependents.delete(id);
430
+ for (const depId of cell.dependents) this.cells.get(depId)?.dependencies.delete(id);
431
+ this.cells.delete(id);
432
+ this.listeners.delete(id);
433
+ for (const depId of formerDependents) {
434
+ const dep = this.cells.get(depId);
435
+ if (!dep || dep.dirty) continue;
436
+ dep.dirty = true;
437
+ this.emit(depId);
438
+ this.propagateDirty(depId);
439
+ }
440
+ });
441
+ }
442
+
443
+ private ensure<T>(id: string): CellRecord<T> {
444
+ let cell = this.cells.get(id) as CellRecord<T> | undefined;
445
+ if (!cell) {
446
+ cell = {
447
+ value: undefined,
448
+ hasValue: false,
449
+ version: 0,
450
+ dirty: true,
451
+ dependencies: new Set(),
452
+ dependents: new Set(),
453
+ auxiliary: false,
454
+ hasError: false,
455
+ error: undefined,
456
+ };
457
+ this.cells.set(id, cell);
458
+ }
459
+ return cell;
460
+ }
461
+
462
+ private propagateDirty(id: string): void {
463
+ const cell = this.cells.get(id);
464
+ if (!cell) return;
465
+ // Snapshot before iterating: `emit` below can synchronously trigger a
466
+ // nested recompute (via useSyncExternalStore's listener) that detaches
467
+ // and re-adds an entry to this very `dependents` set. Iterating the live
468
+ // Set would then revisit that re-added entry within the same pass,
469
+ // looping forever between cells that share this dependency.
470
+ for (const depId of [...cell.dependents]) {
471
+ const dep = this.cells.get(depId);
472
+ if (!dep || dep.dirty) continue; // already dirty -> already propagated past this point
473
+ dep.dirty = true;
474
+ // Notify listeners so a subscriber (e.g. useSyncExternalStore) re-reads
475
+ // via get(), which lazily recomputes. No version bump here — that only
476
+ // happens in get()/set() once a recompute confirms a real value change,
477
+ // which is what lets an unaffected downstream branch skip its redraw.
478
+ this.emit(depId);
479
+ this.propagateDirty(depId);
480
+ }
481
+ }
482
+
483
+ /**
484
+ * Notify `id`'s listeners that it may have changed. Guarded against
485
+ * reentrancy per-id: a `useSyncExternalStore` listener synchronously
486
+ * calls `getSnapshot` (React's own tearing check) as soon as it's
487
+ * notified, which re-enters `get()` and, on a real recompute, calls back
488
+ * into `emit(id)` for the very same id before this call has returned.
489
+ * Without the guard that nested call re-invokes every listener again
490
+ * (including the one currently on the stack), which re-triggers the same
491
+ * reentrant read, forever -- an unbounded synchronous storm that pins the
492
+ * CPU and eventually OOMs the JS heap. It's always safe to drop the
493
+ * nested notification: any consumer notified mid-flight still reads the
494
+ * freshest value the next time it calls `get()`, so no update is lost by
495
+ * collapsing repeat notifications for the same id into one.
496
+ *
497
+ * The global (`subscribeAll`) notification is requested here but not
498
+ * fired directly -- see {@link scheduleGlobalNotify}: a single logical
499
+ * write (e.g. one `set()` on the root of a diamond-shaped dependency
500
+ * graph) calls `emit()` once per cell it transitively dirties, but must
501
+ * still only produce ONE `subscribeAll` notification (issue #234).
502
+ */
503
+ private emit(id: string): void {
504
+ if (this.emitting.has(id)) return;
505
+ this.emitting.add(id);
506
+ try {
507
+ for (const fn of this.listeners.get(id) ?? []) fn();
508
+ this.scheduleGlobalNotify();
509
+ } finally {
510
+ this.emitting.delete(id);
511
+ }
512
+ }
513
+
514
+ /**
515
+ * Run `fn` as one "logical write" for the purposes of global-listener
516
+ * batching -- see {@link globalNotifyDepth}. Reentrant calls (e.g. a
517
+ * `subscribe()` listener triggered mid-cascade that synchronously calls
518
+ * `get()`, which recomputes and dirties further cells) nest transparently:
519
+ * only the outermost call's `finally` can bring the depth back to zero and
520
+ * trigger the deferred flush.
521
+ */
522
+ private runBatched<T>(fn: () => T): T {
523
+ this.globalNotifyDepth++;
524
+ try {
525
+ return fn();
526
+ } finally {
527
+ this.globalNotifyDepth--;
528
+ if (this.globalNotifyDepth === 0 && this.globalNotifyPending) this.flushGlobalNotify();
529
+ }
530
+ }
531
+
532
+ /**
533
+ * Run `fn`, a sequence of otherwise-independent `set`/`define`/`delete`
534
+ * calls, as a SINGLE logical write for `subscribeAll` purposes (see
535
+ * {@link runBatched}/{@link scheduleGlobalNotify}). Without this, a
536
+ * multi-step mutation like a clear-then-replay (#374/#375) fires a
537
+ * `subscribeAll` notification after each individual call, so a listener
538
+ * that reads back across multiple cells (e.g. a canvas redraw walking an
539
+ * object-list cell and dereferencing each entry's point cells) can
540
+ * observe a transient state mid-sequence -- e.g. an id still listed in
541
+ * one cell whose corresponding point cell has already been deleted by an
542
+ * earlier call in the same `fn`. Wrapping the whole sequence here defers
543
+ * every `subscribeAll` notification until `fn` fully returns, so
544
+ * listeners only ever see the state before or after, never in between.
545
+ */
546
+ transaction<T>(fn: () => T): T {
547
+ return this.runBatched(fn);
548
+ }
549
+
550
+ /**
551
+ * Request a `subscribeAll` notification. While a logical write is still
552
+ * in progress ({@link globalNotifyDepth} > 0), the request is only
553
+ * recorded ({@link globalNotifyPending}) -- {@link runBatched} flushes it
554
+ * once that write (including everything it cascaded into) fully unwinds.
555
+ * Outside of any write (e.g. a standalone `get()` that lazily recomputes a
556
+ * dirty cell with no `set`/`define`/`delete` on the stack), there's no
557
+ * outer call to flush later, so it fires immediately.
558
+ */
559
+ private scheduleGlobalNotify(): void {
560
+ this.globalNotifyPending = true;
561
+ if (this.globalNotifyDepth === 0) this.flushGlobalNotify();
562
+ }
563
+
564
+ /**
565
+ * Actually invoke every `subscribeAll` listener, once, for the batch of
566
+ * cell changes accumulated since the last flush.
567
+ *
568
+ * Guarded against reentering the listener-invocation loop itself --
569
+ * `notifyingGlobalListeners`, scoped to "is a global-listener loop
570
+ * currently on the stack" rather than per-cell-id, because by this point
571
+ * the notification is no longer about any single cell; it's "this batch
572
+ * of writes changed something." A listener that synchronously starts a
573
+ * NEW logical write (e.g. `set()` on a different cell) re-arms {@link
574
+ * globalNotifyPending} instead of recursing into a second, nested
575
+ * invocation of every listener -- that pending flag is drained in the
576
+ * `while` loop below once the in-progress loop returns, so that second
577
+ * write's notification still fires (once), it's just deferred to right
578
+ * after the first finishes rather than dropped. This is what fixes issue
579
+ * #234's bug 2: the previous shared `notifyingGlobal` boolean guard
580
+ * skipped (silently discarded) exactly this case instead of deferring it.
581
+ */
582
+ private flushGlobalNotify(): void {
583
+ if (this.notifyingGlobalListeners) return; // already mid-loop; that loop's own drain (below) will pick this up
584
+ this.notifyingGlobalListeners = true;
585
+ try {
586
+ while (this.globalNotifyPending) {
587
+ this.globalNotifyPending = false;
588
+ for (const fn of this.globalListeners) fn();
589
+ }
590
+ } finally {
591
+ this.notifyingGlobalListeners = false;
592
+ }
593
+ }
594
+ }
595
+
596
+ /** Deep structural equality, used to skip redraws when a recompute is a no-op. */
597
+ export function structuralEqual(a: unknown, b: unknown): boolean {
598
+ return structuralEqualInner(a, b, []);
599
+ }
600
+
601
+ // `seen` is the stack of (a, b) pairs currently being compared by an
602
+ // enclosing call on the stack -- used only as a cycle guard, see below.
603
+ function structuralEqualInner(a: unknown, b: unknown, seen: Array<[object, object]>): boolean {
604
+ if (Object.is(a, b)) return true;
605
+ if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
606
+
607
+ // Cycle guard: if this exact (a, b) pair is already being compared by an
608
+ // enclosing (ancestor) call on the stack, it's a cycle -- stop recursing
609
+ // and treat it as equal here rather than looping forever. Any genuine
610
+ // difference elsewhere in the structure is still found by the other,
611
+ // non-cyclic branches of the comparison; this only prevents the recursion
612
+ // itself from being unbounded.
613
+ for (const [sa, sb] of seen) {
614
+ if (sa === a && sb === b) return true;
615
+ }
616
+
617
+ // Two objects of genuinely different types (Date vs plain object, Map vs
618
+ // Set, etc.) are never equal, regardless of their own-key shape below.
619
+ const aTag = Object.prototype.toString.call(a);
620
+ const bTag = Object.prototype.toString.call(b);
621
+ if (aTag !== bTag) return false;
622
+
623
+ seen = [...seen, [a, b]];
624
+
625
+ if (a instanceof Date) return a.getTime() === (b as Date).getTime();
626
+
627
+ if (a instanceof Map) {
628
+ const bm = b as Map<unknown, unknown>;
629
+ if (a.size !== bm.size) return false;
630
+ for (const [k, v] of a) {
631
+ if (!bm.has(k) || !structuralEqualInner(v, bm.get(k), seen)) return false;
632
+ }
633
+ return true;
634
+ }
635
+
636
+ if (a instanceof Set) {
637
+ // Set membership is by identity/SameValueZero, matching the Set's own
638
+ // semantics (not a structural comparison of elements) -- two elements
639
+ // that are merely structurally-equal-but-distinct objects are genuinely
640
+ // different members of a Set.
641
+ const bs = b as Set<unknown>;
642
+ if (a.size !== bs.size) return false;
643
+ for (const v of a) if (!bs.has(v)) return false;
644
+ return true;
645
+ }
646
+
647
+ if (Array.isArray(a)) {
648
+ const ba = b as unknown[];
649
+ if (a.length !== ba.length) return false;
650
+ return a.every((v, i) => structuralEqualInner(v, ba[i], seen));
651
+ }
652
+
653
+ // Any other exotic built-in (RegExp, DOM nodes, a real Path2D, etc.) that
654
+ // isn't a plain object falls through to here -- treat it as *not* equal
655
+ // by default rather than comparing (typically zero) own enumerable keys,
656
+ // which would otherwise silently treat any two distinct instances as
657
+ // identical. Safe default: this only means an extra recompute, never a
658
+ // missed update.
659
+ if (aTag !== "[object Object]") return false;
660
+
661
+ const aKeys = Object.keys(a as object);
662
+ const bKeys = Object.keys(b as object);
663
+ if (aKeys.length !== bKeys.length) return false;
664
+ return aKeys.every(
665
+ (k) =>
666
+ Object.hasOwn(b, k) &&
667
+ structuralEqualInner((a as Record<string, unknown>)[k], (b as Record<string, unknown>)[k], seen),
668
+ );
669
+ }