@classytic/stage 0.1.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.
- package/LICENSE +21 -0
- package/README.md +65 -0
- package/dist/assets/index.d.mts +3 -0
- package/dist/assets/index.mjs +5 -0
- package/dist/assets/kit/glyphs.d.mts +61 -0
- package/dist/assets/kit/glyphs.mjs +256 -0
- package/dist/assets/kit/index.d.mts +2 -0
- package/dist/assets/kit/index.mjs +4 -0
- package/dist/assets/kit/svg-defs.d.mts +6 -0
- package/dist/assets/kit/svg-defs.mjs +137 -0
- package/dist/builder/Palette.d.mts +30 -0
- package/dist/builder/Palette.mjs +100 -0
- package/dist/builder/SceneBuilder.d.mts +26 -0
- package/dist/builder/SceneBuilder.mjs +340 -0
- package/dist/builder/controls.d.mts +18 -0
- package/dist/builder/controls.mjs +29 -0
- package/dist/builder/editor.d.mts +46 -0
- package/dist/builder/editor.mjs +130 -0
- package/dist/builder/icons.d.mts +14 -0
- package/dist/builder/icons.mjs +143 -0
- package/dist/builder/index.d.mts +6 -0
- package/dist/builder/index.mjs +7 -0
- package/dist/builder/tools.d.mts +47 -0
- package/dist/builder/tools.mjs +303 -0
- package/dist/chem/index.d.mts +118 -0
- package/dist/chem/index.mjs +131 -0
- package/dist/core/clock.d.mts +41 -0
- package/dist/core/clock.mjs +62 -0
- package/dist/core/context.d.mts +13 -0
- package/dist/core/context.mjs +29 -0
- package/dist/core/control.d.mts +58 -0
- package/dist/core/control.mjs +113 -0
- package/dist/core/coords.d.mts +51 -0
- package/dist/core/coords.mjs +44 -0
- package/dist/core/index.d.mts +8 -0
- package/dist/core/index.mjs +9 -0
- package/dist/core/learner.d.mts +44 -0
- package/dist/core/learner.mjs +23 -0
- package/dist/core/motion.d.mts +73 -0
- package/dist/core/motion.mjs +103 -0
- package/dist/core/richText.d.mts +28 -0
- package/dist/core/richText.mjs +52 -0
- package/dist/core/vec.d.mts +47 -0
- package/dist/core/vec.mjs +144 -0
- package/dist/field/index.d.mts +58 -0
- package/dist/field/index.mjs +156 -0
- package/dist/index.d.mts +54 -0
- package/dist/index.mjs +55 -0
- package/dist/interaction/MovableDot.d.mts +33 -0
- package/dist/interaction/MovableDot.mjs +66 -0
- package/dist/interaction/hitTest.d.mts +16 -0
- package/dist/interaction/hitTest.mjs +39 -0
- package/dist/interaction/index.d.mts +5 -0
- package/dist/interaction/index.mjs +6 -0
- package/dist/interaction/useDraggable.d.mts +51 -0
- package/dist/interaction/useDraggable.mjs +116 -0
- package/dist/interaction/useScreenToMath.d.mts +12 -0
- package/dist/interaction/useScreenToMath.mjs +25 -0
- package/dist/logic/ast.d.mts +29 -0
- package/dist/logic/ast.mjs +37 -0
- package/dist/logic/index.d.mts +25 -0
- package/dist/logic/index.mjs +36 -0
- package/dist/logic/latex.d.mts +6 -0
- package/dist/logic/latex.mjs +33 -0
- package/dist/logic/minimize.d.mts +37 -0
- package/dist/logic/minimize.mjs +175 -0
- package/dist/logic/parse.d.mts +6 -0
- package/dist/logic/parse.mjs +68 -0
- package/dist/logic/table.d.mts +24 -0
- package/dist/logic/table.mjs +65 -0
- package/dist/logic/tokenize.mjs +258 -0
- package/dist/math/ast.d.mts +35 -0
- package/dist/math/ast.mjs +94 -0
- package/dist/math/calculus.d.mts +9 -0
- package/dist/math/calculus.mjs +188 -0
- package/dist/math/defs.mjs +51 -0
- package/dist/math/index.d.mts +26 -0
- package/dist/math/index.mjs +42 -0
- package/dist/math/latex.d.mts +7 -0
- package/dist/math/latex.mjs +115 -0
- package/dist/math/parse.d.mts +6 -0
- package/dist/math/parse.mjs +91 -0
- package/dist/math/tokenize.mjs +112 -0
- package/dist/primitives/CanvasLayer.d.mts +26 -0
- package/dist/primitives/CanvasLayer.mjs +87 -0
- package/dist/primitives/Dot.d.mts +27 -0
- package/dist/primitives/Dot.mjs +40 -0
- package/dist/primitives/Grid.d.mts +30 -0
- package/dist/primitives/Grid.mjs +122 -0
- package/dist/primitives/Label.d.mts +33 -0
- package/dist/primitives/Label.mjs +45 -0
- package/dist/primitives/Lines.d.mts +50 -0
- package/dist/primitives/Lines.mjs +82 -0
- package/dist/primitives/Plot.d.mts +50 -0
- package/dist/primitives/Plot.mjs +87 -0
- package/dist/primitives/Shapes.d.mts +76 -0
- package/dist/primitives/Shapes.mjs +79 -0
- package/dist/primitives/Tex.d.mts +27 -0
- package/dist/primitives/Tex.mjs +54 -0
- package/dist/primitives/index.d.mts +10 -0
- package/dist/primitives/index.mjs +10 -0
- package/dist/primitives/props.d.mts +15 -0
- package/dist/scene/Scene.d.mts +31 -0
- package/dist/scene/Scene.mjs +168 -0
- package/dist/scene/assets.d.mts +37 -0
- package/dist/scene/assets.mjs +31 -0
- package/dist/scene/commands.d.mts +47 -0
- package/dist/scene/commands.mjs +162 -0
- package/dist/scene/evaluators.d.mts +17 -0
- package/dist/scene/evaluators.mjs +210 -0
- package/dist/scene/index.d.mts +13 -0
- package/dist/scene/index.mjs +13 -0
- package/dist/scene/migrate.d.mts +11 -0
- package/dist/scene/migrate.mjs +35 -0
- package/dist/scene/render.d.mts +14 -0
- package/dist/scene/render.mjs +126 -0
- package/dist/scene/resolve.d.mts +12 -0
- package/dist/scene/resolve.mjs +95 -0
- package/dist/scene/schema.d.mts +9 -0
- package/dist/scene/schema.mjs +51 -0
- package/dist/scene/sims.d.mts +18 -0
- package/dist/scene/sims.mjs +54 -0
- package/dist/scene/store.d.mts +22 -0
- package/dist/scene/store.mjs +37 -0
- package/dist/scene/types.d.mts +228 -0
- package/dist/scene/types.mjs +27 -0
- package/dist/sim/equilibrium.d.mts +36 -0
- package/dist/sim/equilibrium.mjs +54 -0
- package/dist/sim/index.d.mts +9 -0
- package/dist/sim/index.mjs +9 -0
- package/dist/sim/particles.d.mts +43 -0
- package/dist/sim/particles.mjs +136 -0
- package/dist/sim/rate.d.mts +36 -0
- package/dist/sim/rate.mjs +39 -0
- package/dist/sim/registry.d.mts +10 -0
- package/dist/sim/registry.mjs +27 -0
- package/dist/sim/sampler.d.mts +47 -0
- package/dist/sim/sampler.mjs +79 -0
- package/dist/sim/thermal.d.mts +78 -0
- package/dist/sim/thermal.mjs +172 -0
- package/dist/sim/types.d.mts +24 -0
- package/dist/sim/wave.d.mts +34 -0
- package/dist/sim/wave.mjs +61 -0
- package/dist/steps/index.d.mts +62 -0
- package/dist/steps/index.mjs +133 -0
- package/dist/thermo/index.d.mts +82 -0
- package/dist/thermo/index.mjs +133 -0
- package/dist/view/Stage.d.mts +39 -0
- package/dist/view/Stage.mjs +91 -0
- package/dist/view/index.d.mts +4 -0
- package/dist/view/index.mjs +5 -0
- package/dist/view/useElementSize.d.mts +13 -0
- package/dist/view/useElementSize.mjs +40 -0
- package/dist/view/useInView.d.mts +9 -0
- package/dist/view/useInView.mjs +33 -0
- package/package.json +141 -0
- package/styles.css +70 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
//#region src/chem/index.ts
|
|
2
|
+
/**
|
|
3
|
+
* `chem` — acid–base / pH AND electrochemistry / Nernst, as a PURE kernel (no
|
|
4
|
+
* React, no pixels). The engine behind titration curves, pH problems, and galvanic
|
|
5
|
+
* cells.
|
|
6
|
+
*
|
|
7
|
+
* pH at any point is found by solving the exact charge balance for [H⁺] = h:
|
|
8
|
+
*
|
|
9
|
+
* [titrant cation] + h = [OH⁻] + [A⁻]
|
|
10
|
+
* n_b/V + h = K_w/h + (n_a/V)·K_a/(K_a + h)
|
|
11
|
+
*
|
|
12
|
+
* where n_a is the acid initially present, n_b the strong base added, V the total
|
|
13
|
+
* volume. A strong acid is just the K_a → ∞ limit (fully dissociated). Solving the
|
|
14
|
+
* balance (monotonic in h → bisection) is robust everywhere — initial point, buffer
|
|
15
|
+
* region, equivalence, and past it — with no piecewise approximations to get wrong.
|
|
16
|
+
*
|
|
17
|
+
* Concentrations in mol/L, volumes in litres. Currently models an ACID analyte
|
|
18
|
+
* titrated by a STRONG BASE (covers strong-acid and weak-acid curves).
|
|
19
|
+
*/
|
|
20
|
+
const Kw = 1e-14;
|
|
21
|
+
const kaOf = (spec) => spec.analyte === "strong-acid" ? 1e9 : 10 ** -(spec.pKa ?? 4.76);
|
|
22
|
+
/** Solve the charge balance n_b/V + h = K_w/h + (n_a/V)·K_a/(K_a+h) for h>0. */
|
|
23
|
+
function solveH(naOverV, nbOverV, Ka) {
|
|
24
|
+
const f = (h) => nbOverV + h - Kw / h - naOverV * Ka / (Ka + h);
|
|
25
|
+
let lo = 1e-14, hi = 10;
|
|
26
|
+
for (let i = 0; i < 200; i++) {
|
|
27
|
+
const mid = Math.sqrt(lo * hi);
|
|
28
|
+
if (f(mid) > 0) hi = mid;
|
|
29
|
+
else lo = mid;
|
|
30
|
+
}
|
|
31
|
+
return Math.sqrt(lo * hi);
|
|
32
|
+
}
|
|
33
|
+
/** pH after adding `Vb` litres of titrant. */
|
|
34
|
+
function pHAt(spec, Vb) {
|
|
35
|
+
const Ka = kaOf(spec);
|
|
36
|
+
const na = spec.Ca * spec.Va;
|
|
37
|
+
const nb = spec.Cb * Math.max(0, Vb);
|
|
38
|
+
const V = spec.Va + Math.max(0, Vb);
|
|
39
|
+
const h = solveH(na / V, nb / V, Ka);
|
|
40
|
+
return -Math.log10(h);
|
|
41
|
+
}
|
|
42
|
+
/** Sample the full titration curve from 0 to `span`·vEq (default 2×). */
|
|
43
|
+
function titrationCurve(spec, n = 160, span = 2) {
|
|
44
|
+
const vEq = spec.Ca * spec.Va / spec.Cb;
|
|
45
|
+
const vMax = vEq * span;
|
|
46
|
+
const points = [];
|
|
47
|
+
for (let i = 0; i <= n; i++) {
|
|
48
|
+
const v = i / n * vMax;
|
|
49
|
+
points.push({
|
|
50
|
+
v,
|
|
51
|
+
pH: pHAt(spec, v)
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
points,
|
|
56
|
+
vEq,
|
|
57
|
+
pHEq: pHAt(spec, vEq),
|
|
58
|
+
pHHalf: pHAt(spec, vEq / 2),
|
|
59
|
+
pKa: spec.analyte === "weak-acid" ? spec.pKa ?? 4.76 : NaN
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/** Universal gas constant, J/(mol·K). */
|
|
63
|
+
const R = 8.314462618;
|
|
64
|
+
/** Faraday constant, C/mol. */
|
|
65
|
+
const F = 96485.332;
|
|
66
|
+
/** Standard temperature, K (25 °C). */
|
|
67
|
+
const T_STD = 298.15;
|
|
68
|
+
const gcd = (a, b) => b === 0 ? a : gcd(b, a % b);
|
|
69
|
+
const lcm = (a, b) => a * b / gcd(a, b);
|
|
70
|
+
/** Galvanic cell from two half-cells; cathode/anode assigned by standard potential. */
|
|
71
|
+
function galvanicCell(x, y, T = T_STD) {
|
|
72
|
+
const xIsCathode = x.E0 > y.E0 || x.E0 === y.E0 && x.conc >= y.conc;
|
|
73
|
+
const cathode = xIsCathode ? x : y;
|
|
74
|
+
const anode = xIsCathode ? y : x;
|
|
75
|
+
const E0cell = cathode.E0 - anode.E0;
|
|
76
|
+
const n = lcm(anode.z, cathode.z);
|
|
77
|
+
const Q = Math.pow(anode.conc, n / anode.z) / Math.pow(cathode.conc, n / cathode.z);
|
|
78
|
+
const E = E0cell - R * T / (n * F) * Math.log(Q);
|
|
79
|
+
return {
|
|
80
|
+
cathode,
|
|
81
|
+
anode,
|
|
82
|
+
E0cell,
|
|
83
|
+
n,
|
|
84
|
+
Q,
|
|
85
|
+
E,
|
|
86
|
+
spontaneous: E > 0
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
/** Arrhenius rate constant k = A·e^(−Ea/RT). */
|
|
90
|
+
function arrhenius(A, Ea, T) {
|
|
91
|
+
return A * Math.exp(-Ea / (R * T));
|
|
92
|
+
}
|
|
93
|
+
/** Ratio k(T)/k(Tref) at fixed Ea — the stable way to scale a rate without a raw A. */
|
|
94
|
+
function arrheniusRatio(Ea, T, Tref = T_STD) {
|
|
95
|
+
return Math.exp(-(Ea / R) * (1 / T - 1 / Tref));
|
|
96
|
+
}
|
|
97
|
+
/** Fraction of molecules with energy ≥ Ea (Boltzmann) ≈ e^(−Ea/RT). */
|
|
98
|
+
function fractionAboveEa(Ea, T) {
|
|
99
|
+
return Math.exp(-Ea / (R * T));
|
|
100
|
+
}
|
|
101
|
+
/** Concentration [A] at time t for a reaction of the given order, rate = k·[A]^order. */
|
|
102
|
+
function concAt(order, A0, k, t) {
|
|
103
|
+
if (order === 0) return Math.max(0, A0 - k * t);
|
|
104
|
+
if (order === 1) return A0 * Math.exp(-k * t);
|
|
105
|
+
return A0 / (1 + A0 * k * t);
|
|
106
|
+
}
|
|
107
|
+
/** Half-life for the given order (constant only for first order). */
|
|
108
|
+
function halfLife(order, A0, k) {
|
|
109
|
+
if (order === 0) return A0 / (2 * k);
|
|
110
|
+
if (order === 1) return Math.LN2 / k;
|
|
111
|
+
return 1 / (k * A0);
|
|
112
|
+
}
|
|
113
|
+
/** Solve a balanced reaction for the limiting reagent, extent, products and leftovers. */
|
|
114
|
+
function solveStoichiometry(reactants, products, amountsMol) {
|
|
115
|
+
const ratios = reactants.map((r, i) => (amountsMol[i] ?? 0) / r.coeff);
|
|
116
|
+
const extent = Math.min(...ratios);
|
|
117
|
+
return {
|
|
118
|
+
extent,
|
|
119
|
+
limiting: reactants.filter((_, i) => Math.abs(ratios[i] - extent) < 1e-9).map((r) => r.name),
|
|
120
|
+
consumed: reactants.map((r) => r.coeff * extent),
|
|
121
|
+
leftover: reactants.map((r, i) => (amountsMol[i] ?? 0) - r.coeff * extent),
|
|
122
|
+
products: products.map((p) => ({
|
|
123
|
+
name: p.name,
|
|
124
|
+
moles: p.coeff * extent,
|
|
125
|
+
grams: p.molarMass != null ? p.coeff * extent * p.molarMass : void 0
|
|
126
|
+
}))
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
//#endregion
|
|
131
|
+
export { F, Kw, R, T_STD, arrhenius, arrheniusRatio, concAt, fractionAboveEa, galvanicCell, halfLife, pHAt, solveStoichiometry, titrationCurve };
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
//#region src/core/clock.d.ts
|
|
4
|
+
interface FrameInfo {
|
|
5
|
+
/** Milliseconds on the active timeline. */
|
|
6
|
+
timeMs: number;
|
|
7
|
+
/** Delta since previous tick, ms. */
|
|
8
|
+
dtMs: number;
|
|
9
|
+
/** Monotonic frame counter. */
|
|
10
|
+
frame: number;
|
|
11
|
+
/** Frames per second (measured for RAF; exact for a fixed driver). */
|
|
12
|
+
fps: number;
|
|
13
|
+
}
|
|
14
|
+
/** A frame source: push `FrameInfo` to subscribers. Implement this to drive
|
|
15
|
+
* the engine from a non-RAF timeline (e.g. Remotion). */
|
|
16
|
+
interface FrameDriver {
|
|
17
|
+
subscribe(cb: (f: FrameInfo) => void): () => void;
|
|
18
|
+
}
|
|
19
|
+
interface ClockProviderProps {
|
|
20
|
+
driver: FrameDriver;
|
|
21
|
+
children: ReactNode;
|
|
22
|
+
}
|
|
23
|
+
/** Override the default RAF loop for everything inside (e.g. Remotion). */
|
|
24
|
+
declare function ClockProvider({
|
|
25
|
+
driver,
|
|
26
|
+
children
|
|
27
|
+
}: ClockProviderProps): ReactNode;
|
|
28
|
+
/**
|
|
29
|
+
* Run `cb` once per frame from the active clock. Default = RAF. When `running`
|
|
30
|
+
* is false the loop is idle. `cb` is held in a ref so changing it doesn't
|
|
31
|
+
* restart the loop.
|
|
32
|
+
*
|
|
33
|
+
* Visibility/offscreen pausing is NOT this hook's job — gate `running` on
|
|
34
|
+
* `useInView()` (the one canonical hook) for that: `useFrameLoop(tick, { running:
|
|
35
|
+
* playing && inView })`. Keeps the clock a single-purpose primitive.
|
|
36
|
+
*/
|
|
37
|
+
declare function useFrameLoop(cb: (f: FrameInfo) => void, options?: {
|
|
38
|
+
running?: boolean;
|
|
39
|
+
}): void;
|
|
40
|
+
//#endregion
|
|
41
|
+
export { ClockProvider, ClockProviderProps, FrameDriver, FrameInfo, useFrameLoop };
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { createContext, createElement, useContext, useEffect, useRef } from "react";
|
|
4
|
+
|
|
5
|
+
//#region src/core/clock.ts
|
|
6
|
+
/**
|
|
7
|
+
* Injectable clock. Components drive animation through `useFrameLoop(cb)` instead
|
|
8
|
+
* of calling `requestAnimationFrame` directly. By default that's a RAF loop
|
|
9
|
+
* (interactive). A host can wrap a subtree in `<ClockProvider driver={…}>` to
|
|
10
|
+
* supply frames from another source — e.g. a Remotion app pushing
|
|
11
|
+
* `useCurrentFrame()`-derived frames for deterministic, scrubbable video. The
|
|
12
|
+
* engine itself stays unaware of Remotion.
|
|
13
|
+
*
|
|
14
|
+
* Discipline for video-determinism: compute visuals from the `FrameInfo` you
|
|
15
|
+
* receive (timeMs / frame), not a private accumulating counter — so the same
|
|
16
|
+
* component renders identically at any frame.
|
|
17
|
+
*/
|
|
18
|
+
const DriverContext = createContext(null);
|
|
19
|
+
/** Override the default RAF loop for everything inside (e.g. Remotion). */
|
|
20
|
+
function ClockProvider({ driver, children }) {
|
|
21
|
+
return createElement(DriverContext.Provider, { value: driver }, children);
|
|
22
|
+
}
|
|
23
|
+
const now = () => typeof performance !== "undefined" ? performance.now() : Date.now();
|
|
24
|
+
/**
|
|
25
|
+
* Run `cb` once per frame from the active clock. Default = RAF. When `running`
|
|
26
|
+
* is false the loop is idle. `cb` is held in a ref so changing it doesn't
|
|
27
|
+
* restart the loop.
|
|
28
|
+
*
|
|
29
|
+
* Visibility/offscreen pausing is NOT this hook's job — gate `running` on
|
|
30
|
+
* `useInView()` (the one canonical hook) for that: `useFrameLoop(tick, { running:
|
|
31
|
+
* playing && inView })`. Keeps the clock a single-purpose primitive.
|
|
32
|
+
*/
|
|
33
|
+
function useFrameLoop(cb, options = {}) {
|
|
34
|
+
const { running = true } = options;
|
|
35
|
+
const driver = useContext(DriverContext);
|
|
36
|
+
const cbRef = useRef(cb);
|
|
37
|
+
cbRef.current = cb;
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
if (!running) return;
|
|
40
|
+
if (driver) return driver.subscribe((f) => cbRef.current(f));
|
|
41
|
+
let raf = 0;
|
|
42
|
+
let last = now();
|
|
43
|
+
let frame = 0;
|
|
44
|
+
const loop = () => {
|
|
45
|
+
const t = now();
|
|
46
|
+
const dtMs = t - last;
|
|
47
|
+
last = t;
|
|
48
|
+
cbRef.current({
|
|
49
|
+
timeMs: t,
|
|
50
|
+
dtMs,
|
|
51
|
+
frame: frame++,
|
|
52
|
+
fps: dtMs > 0 ? 1e3 / dtMs : 60
|
|
53
|
+
});
|
|
54
|
+
raf = requestAnimationFrame(loop);
|
|
55
|
+
};
|
|
56
|
+
raf = requestAnimationFrame(loop);
|
|
57
|
+
return () => cancelAnimationFrame(raf);
|
|
58
|
+
}, [running, driver]);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
//#endregion
|
|
62
|
+
export { ClockProvider, useFrameLoop };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Vec2 } from "./vec.mjs";
|
|
2
|
+
import { CoordinateSystem } from "./coords.mjs";
|
|
3
|
+
import { RefObject } from "react";
|
|
4
|
+
|
|
5
|
+
//#region src/core/context.d.ts
|
|
6
|
+
declare const CoordsContext: import("react").Context<CoordinateSystem | null>;
|
|
7
|
+
declare function useCoords(): CoordinateSystem;
|
|
8
|
+
declare const StageRefContext: import("react").Context<RefObject<SVGSVGElement | null> | null>;
|
|
9
|
+
declare function useStageRef(): RefObject<SVGSVGElement | null> | null;
|
|
10
|
+
declare const DragOverlayContext: import("react").Context<ReadonlyMap<string, Vec2>>;
|
|
11
|
+
declare function useDragOverlay(): ReadonlyMap<string, Vec2>;
|
|
12
|
+
//#endregion
|
|
13
|
+
export { CoordsContext, DragOverlayContext, StageRefContext, useCoords, useDragOverlay, useStageRef };
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { createContext, useContext } from "react";
|
|
4
|
+
|
|
5
|
+
//#region src/core/context.ts
|
|
6
|
+
/**
|
|
7
|
+
* Engine React contexts. Kept dependency-light: the scene/editor contexts are
|
|
8
|
+
* added in the scene-graph phase. Primitives read CoordsContext; interaction
|
|
9
|
+
* reads StageRefContext (for getScreenCTM) and DragOverlayContext (ephemeral
|
|
10
|
+
* per-frame drag values that resolve() reads without mutating the doc).
|
|
11
|
+
*/
|
|
12
|
+
const CoordsContext = createContext(null);
|
|
13
|
+
function useCoords() {
|
|
14
|
+
const c = useContext(CoordsContext);
|
|
15
|
+
if (!c) throw new Error("Stage primitives must be rendered inside <Stage>.");
|
|
16
|
+
return c;
|
|
17
|
+
}
|
|
18
|
+
const StageRefContext = createContext(null);
|
|
19
|
+
function useStageRef() {
|
|
20
|
+
return useContext(StageRefContext);
|
|
21
|
+
}
|
|
22
|
+
const EMPTY_OVERLAY = /* @__PURE__ */ new Map();
|
|
23
|
+
const DragOverlayContext = createContext(EMPTY_OVERLAY);
|
|
24
|
+
function useDragOverlay() {
|
|
25
|
+
return useContext(DragOverlayContext);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
//#endregion
|
|
29
|
+
export { CoordsContext, DragOverlayContext, StageRefContext, useCoords, useDragOverlay, useStageRef };
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
//#region src/core/control.d.ts
|
|
2
|
+
type ControlSpec = {
|
|
3
|
+
type: 'number';
|
|
4
|
+
label?: string;
|
|
5
|
+
min?: number;
|
|
6
|
+
max?: number;
|
|
7
|
+
step?: number;
|
|
8
|
+
unit?: string;
|
|
9
|
+
get: () => number;
|
|
10
|
+
set: (v: number) => void;
|
|
11
|
+
} | {
|
|
12
|
+
type: 'boolean';
|
|
13
|
+
label?: string;
|
|
14
|
+
get: () => boolean;
|
|
15
|
+
set: (v: boolean) => void;
|
|
16
|
+
} | {
|
|
17
|
+
type: 'enum';
|
|
18
|
+
label?: string;
|
|
19
|
+
options: readonly string[];
|
|
20
|
+
get: () => string;
|
|
21
|
+
set: (v: string) => void;
|
|
22
|
+
} | {
|
|
23
|
+
type: 'action';
|
|
24
|
+
label?: string;
|
|
25
|
+
invoke: () => void;
|
|
26
|
+
};
|
|
27
|
+
type ControlMap = Record<string, ControlSpec>;
|
|
28
|
+
interface ControlSurface {
|
|
29
|
+
readonly id: string;
|
|
30
|
+
/** Machine-readable description of every control (for agent discovery). */
|
|
31
|
+
describe(): Array<{
|
|
32
|
+
name: string;
|
|
33
|
+
type: ControlSpec['type'];
|
|
34
|
+
label?: string;
|
|
35
|
+
value?: unknown;
|
|
36
|
+
min?: number;
|
|
37
|
+
max?: number;
|
|
38
|
+
options?: readonly string[];
|
|
39
|
+
}>;
|
|
40
|
+
get(name: string): unknown;
|
|
41
|
+
set(name: string, value: unknown): boolean;
|
|
42
|
+
invoke(name: string): boolean;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Register a widget's controls under `id` for its lifetime (no-op if `id` is
|
|
46
|
+
* undefined). Registration happens in an effect (render stays side-effect-free,
|
|
47
|
+
* per React 19 / concurrent rules); a ref holds the latest controls so getters
|
|
48
|
+
* and setters always see current state.
|
|
49
|
+
*/
|
|
50
|
+
declare function useControlSurface(id: string | undefined, controls: ControlMap): void;
|
|
51
|
+
/** Look up a live control surface by id (returns null if no such widget is mounted). */
|
|
52
|
+
declare function getControlSurface(id: string): ControlSurface | null;
|
|
53
|
+
/** Every mounted control surface — for an agent to discover what's on the page. */
|
|
54
|
+
declare function listControlSurfaces(): ControlSurface[];
|
|
55
|
+
/** Subscribe to registry changes (surfaces mounting/unmounting or values set). */
|
|
56
|
+
declare function onControlChange(fn: () => void): () => void;
|
|
57
|
+
//#endregion
|
|
58
|
+
export { ControlMap, ControlSpec, ControlSurface, getControlSurface, listControlSurfaces, onControlChange, useControlSurface };
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef } from "react";
|
|
4
|
+
|
|
5
|
+
//#region src/core/control.ts
|
|
6
|
+
/**
|
|
7
|
+
* control — an addressable control surface so an external driver (a voice/AI
|
|
8
|
+
* agent, an autograder, a Remotion timeline, a "show me" button) can inspect and
|
|
9
|
+
* operate a live visualization without prop-drilling.
|
|
10
|
+
*
|
|
11
|
+
* A widget registers named controls under an `id`; anyone can later look the id
|
|
12
|
+
* up and read/set values or invoke actions:
|
|
13
|
+
*
|
|
14
|
+
* useControlSurface('balance-1', {
|
|
15
|
+
* x: { type: 'number', min: 0, max: 8, get: () => x, set: setX },
|
|
16
|
+
* check:{ type: 'action', invoke: () => grade() },
|
|
17
|
+
* });
|
|
18
|
+
*
|
|
19
|
+
* getControlSurface('balance-1')?.set('x', 3);
|
|
20
|
+
* listControlSurfaces(); // discover what's on the page
|
|
21
|
+
*
|
|
22
|
+
* In scene/builder mode, prefer `controlsFromScene(editor, …)` so set() routes
|
|
23
|
+
* through the editor's command stack (undoable + persisted) instead of raw
|
|
24
|
+
* closures.
|
|
25
|
+
*/
|
|
26
|
+
const registry = /* @__PURE__ */ new Map();
|
|
27
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
28
|
+
const notify = () => listeners.forEach((l) => l());
|
|
29
|
+
function makeSurface(id, getControls) {
|
|
30
|
+
return {
|
|
31
|
+
id,
|
|
32
|
+
describe: () => Object.entries(getControls()).map(([name, c]) => ({
|
|
33
|
+
name,
|
|
34
|
+
type: c.type,
|
|
35
|
+
label: c.label,
|
|
36
|
+
value: c.type === "action" ? void 0 : c.get(),
|
|
37
|
+
...c.type === "number" ? {
|
|
38
|
+
min: c.min,
|
|
39
|
+
max: c.max
|
|
40
|
+
} : {},
|
|
41
|
+
...c.type === "enum" ? { options: c.options } : {}
|
|
42
|
+
})),
|
|
43
|
+
get: (name) => {
|
|
44
|
+
const c = getControls()[name];
|
|
45
|
+
return c && c.type !== "action" ? c.get() : void 0;
|
|
46
|
+
},
|
|
47
|
+
set: (name, value) => {
|
|
48
|
+
const c = getControls()[name];
|
|
49
|
+
if (!c || c.type === "action") return false;
|
|
50
|
+
if (c.type === "number" && typeof value === "number") {
|
|
51
|
+
c.set(value);
|
|
52
|
+
notify();
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
if (c.type === "boolean" && typeof value === "boolean") {
|
|
56
|
+
c.set(value);
|
|
57
|
+
notify();
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
if (c.type === "enum" && typeof value === "string" && c.options.includes(value)) {
|
|
61
|
+
c.set(value);
|
|
62
|
+
notify();
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
return false;
|
|
66
|
+
},
|
|
67
|
+
invoke: (name) => {
|
|
68
|
+
const c = getControls()[name];
|
|
69
|
+
if (c?.type === "action") {
|
|
70
|
+
c.invoke();
|
|
71
|
+
notify();
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Register a widget's controls under `id` for its lifetime (no-op if `id` is
|
|
80
|
+
* undefined). Registration happens in an effect (render stays side-effect-free,
|
|
81
|
+
* per React 19 / concurrent rules); a ref holds the latest controls so getters
|
|
82
|
+
* and setters always see current state.
|
|
83
|
+
*/
|
|
84
|
+
function useControlSurface(id, controls) {
|
|
85
|
+
const ref = useRef(controls);
|
|
86
|
+
ref.current = controls;
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
if (!id) return;
|
|
89
|
+
registry.set(id, () => ref.current);
|
|
90
|
+
notify();
|
|
91
|
+
return () => {
|
|
92
|
+
registry.delete(id);
|
|
93
|
+
notify();
|
|
94
|
+
};
|
|
95
|
+
}, [id]);
|
|
96
|
+
}
|
|
97
|
+
/** Look up a live control surface by id (returns null if no such widget is mounted). */
|
|
98
|
+
function getControlSurface(id) {
|
|
99
|
+
const getControls = registry.get(id);
|
|
100
|
+
return getControls ? makeSurface(id, getControls) : null;
|
|
101
|
+
}
|
|
102
|
+
/** Every mounted control surface — for an agent to discover what's on the page. */
|
|
103
|
+
function listControlSurfaces() {
|
|
104
|
+
return [...registry.entries()].map(([id, getControls]) => makeSurface(id, getControls));
|
|
105
|
+
}
|
|
106
|
+
/** Subscribe to registry changes (surfaces mounting/unmounting or values set). */
|
|
107
|
+
function onControlChange(fn) {
|
|
108
|
+
listeners.add(fn);
|
|
109
|
+
return () => listeners.delete(fn);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
//#endregion
|
|
113
|
+
export { getControlSurface, listControlSurfaces, onControlChange, useControlSurface };
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
//#region src/core/coords.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* CoordinateSystem — the math↔pixel mapping that decouples mathematical
|
|
4
|
+
* coordinates from screen pixels. Primitives draw in math units; pan/zoom/resize
|
|
5
|
+
* are trivial because only the mapping changes.
|
|
6
|
+
*
|
|
7
|
+
* Convention: math y is UP (standard), screen y is DOWN — the mapping flips it.
|
|
8
|
+
* Every stage primitive emits PIXEL coordinates via toPx() into one space — there
|
|
9
|
+
* is no global CSS-matrix group (it would mirror text, scale strokes, and desync
|
|
10
|
+
* getScreenCTM-based pointer math under zoom).
|
|
11
|
+
*/
|
|
12
|
+
interface ViewBox {
|
|
13
|
+
/** Visible math range. */
|
|
14
|
+
xMin: number;
|
|
15
|
+
xMax: number;
|
|
16
|
+
yMin: number;
|
|
17
|
+
yMax: number;
|
|
18
|
+
}
|
|
19
|
+
interface CoordinateSystem {
|
|
20
|
+
readonly view: ViewBox;
|
|
21
|
+
readonly width: number;
|
|
22
|
+
readonly height: number;
|
|
23
|
+
/** math (x,y) → pixel (px,py). */
|
|
24
|
+
toPx(x: number, y: number): [number, number];
|
|
25
|
+
/** pixel (px,py) → math (x,y). */
|
|
26
|
+
toMath(px: number, py: number): [number, number];
|
|
27
|
+
/** Scale a math x-distance to pixels. */
|
|
28
|
+
sx(dx: number): number;
|
|
29
|
+
/** Scale a math y-distance to pixels (magnitude). */
|
|
30
|
+
sy(dy: number): number;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Round an emitted SVG coordinate to a stable precision (3 dp ≈ 1/1000 px, far
|
|
34
|
+
* below sub-pixel). Use this for EVERY pixel value written into SVG attributes —
|
|
35
|
+
* never for `toMath`/hit-testing math. It exists for SSR determinism: a
|
|
36
|
+
* coordinate derived from a transcendental (`Math.sin`, `pow`, the expr engine)
|
|
37
|
+
* can differ in its last bits between the server's V8 and the browser's V8,
|
|
38
|
+
* producing a React hydration mismatch on the raw float. Rounding makes the
|
|
39
|
+
* serialized string identical on both sides.
|
|
40
|
+
*/
|
|
41
|
+
declare const fmt: (n: number) => number;
|
|
42
|
+
interface CoordsOptions {
|
|
43
|
+
/** Inner padding in pixels (keeps content off the edges). */
|
|
44
|
+
pad?: number;
|
|
45
|
+
/** Preserve aspect ratio (uses the smaller of x/y scales for both). Default true. */
|
|
46
|
+
preserveAspect?: boolean;
|
|
47
|
+
}
|
|
48
|
+
/** Build a CoordinateSystem for a viewport of `width`×`height` (CSS px) showing `view`. */
|
|
49
|
+
declare function createCoords(width: number, height: number, view: ViewBox, options?: CoordsOptions): CoordinateSystem;
|
|
50
|
+
//#endregion
|
|
51
|
+
export { CoordinateSystem, CoordsOptions, ViewBox, createCoords, fmt };
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
//#region src/core/coords.ts
|
|
2
|
+
/**
|
|
3
|
+
* Round an emitted SVG coordinate to a stable precision (3 dp ≈ 1/1000 px, far
|
|
4
|
+
* below sub-pixel). Use this for EVERY pixel value written into SVG attributes —
|
|
5
|
+
* never for `toMath`/hit-testing math. It exists for SSR determinism: a
|
|
6
|
+
* coordinate derived from a transcendental (`Math.sin`, `pow`, the expr engine)
|
|
7
|
+
* can differ in its last bits between the server's V8 and the browser's V8,
|
|
8
|
+
* producing a React hydration mismatch on the raw float. Rounding makes the
|
|
9
|
+
* serialized string identical on both sides.
|
|
10
|
+
*/
|
|
11
|
+
const fmt = (n) => Math.round(n * 1e3) / 1e3;
|
|
12
|
+
/** Build a CoordinateSystem for a viewport of `width`×`height` (CSS px) showing `view`. */
|
|
13
|
+
function createCoords(width, height, view, options = {}) {
|
|
14
|
+
const { pad = 0, preserveAspect = true } = options;
|
|
15
|
+
const w = Math.max(1, width - pad * 2);
|
|
16
|
+
const h = Math.max(1, height - pad * 2);
|
|
17
|
+
const spanX = view.xMax - view.xMin || 1;
|
|
18
|
+
const spanY = view.yMax - view.yMin || 1;
|
|
19
|
+
let scaleX = w / spanX;
|
|
20
|
+
let scaleY = h / spanY;
|
|
21
|
+
if (preserveAspect) {
|
|
22
|
+
const s = Math.min(scaleX, scaleY);
|
|
23
|
+
scaleX = s;
|
|
24
|
+
scaleY = s;
|
|
25
|
+
}
|
|
26
|
+
const drawW = spanX * scaleX;
|
|
27
|
+
const drawH = spanY * scaleY;
|
|
28
|
+
const offX = pad + (w - drawW) / 2;
|
|
29
|
+
const offY = pad + (h - drawH) / 2;
|
|
30
|
+
const toPx = (x, y) => [offX + (x - view.xMin) * scaleX, offY + (view.yMax - y) * scaleY];
|
|
31
|
+
const toMath = (px, py) => [view.xMin + (px - offX) / scaleX, view.yMax - (py - offY) / scaleY];
|
|
32
|
+
return {
|
|
33
|
+
view,
|
|
34
|
+
width,
|
|
35
|
+
height,
|
|
36
|
+
toPx,
|
|
37
|
+
toMath,
|
|
38
|
+
sx: (dx) => dx * scaleX,
|
|
39
|
+
sy: (dy) => dy * scaleY
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
//#endregion
|
|
44
|
+
export { createCoords, fmt };
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { Matrix, Vec2, mat, vec } from "./vec.mjs";
|
|
2
|
+
import { CoordinateSystem, CoordsOptions, ViewBox, createCoords, fmt } from "./coords.mjs";
|
|
3
|
+
import { ControlMap, ControlSpec, ControlSurface, getControlSurface, listControlSurfaces, onControlChange, useControlSurface } from "./control.mjs";
|
|
4
|
+
import { CoordsContext, DragOverlayContext, StageRefContext, useCoords, useDragOverlay, useStageRef } from "./context.mjs";
|
|
5
|
+
import { ClockProvider, ClockProviderProps, FrameDriver, FrameInfo, useFrameLoop } from "./clock.mjs";
|
|
6
|
+
import { Learner, LearnerProvider, LearnerResult, PriorAttempts, useLearner } from "./learner.mjs";
|
|
7
|
+
import { EaseFn, EaseName, Keyframe, OscillateOpts, SpringOpts, WaveShape, ease, lerp, oscillate, prefersReducedMotion, spring, timeline } from "./motion.mjs";
|
|
8
|
+
export { ClockProvider, type ClockProviderProps, type ControlMap, type ControlSpec, type ControlSurface, type CoordinateSystem, CoordsContext, type CoordsOptions, DragOverlayContext, type EaseFn, type EaseName, type FrameDriver, type FrameInfo, type Keyframe, type Learner, LearnerProvider, type LearnerResult, type Matrix, type OscillateOpts, type PriorAttempts, type SpringOpts, StageRefContext, type Vec2, type ViewBox, type WaveShape, createCoords, ease, fmt, getControlSurface, lerp, listControlSurfaces, mat, onControlChange, oscillate, prefersReducedMotion, spring, timeline, useControlSurface, useCoords, useDragOverlay, useFrameLoop, useLearner, useStageRef, vec };
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { createCoords, fmt } from "./coords.mjs";
|
|
2
|
+
import { mat, vec } from "./vec.mjs";
|
|
3
|
+
import { CoordsContext, DragOverlayContext, StageRefContext, useCoords, useDragOverlay, useStageRef } from "./context.mjs";
|
|
4
|
+
import { ClockProvider, useFrameLoop } from "./clock.mjs";
|
|
5
|
+
import { getControlSurface, listControlSurfaces, onControlChange, useControlSurface } from "./control.mjs";
|
|
6
|
+
import { LearnerProvider, useLearner } from "./learner.mjs";
|
|
7
|
+
import { ease, lerp, oscillate, prefersReducedMotion, spring, timeline } from "./motion.mjs";
|
|
8
|
+
|
|
9
|
+
export { ClockProvider, CoordsContext, DragOverlayContext, LearnerProvider, StageRefContext, createCoords, ease, fmt, getControlSurface, lerp, listControlSurfaces, mat, onControlChange, oscillate, prefersReducedMotion, spring, timeline, useControlSurface, useCoords, useDragOverlay, useFrameLoop, useLearner, useStageRef, vec };
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
//#region src/core/learner.d.ts
|
|
4
|
+
interface LearnerResult {
|
|
5
|
+
/** Stable activity id for this interaction (e.g. a checkpoint key). */
|
|
6
|
+
activity: string;
|
|
7
|
+
/** Did the learner satisfy the goal? */
|
|
8
|
+
correct?: boolean;
|
|
9
|
+
/** Numeric score, when applicable. */
|
|
10
|
+
score?: {
|
|
11
|
+
raw: number;
|
|
12
|
+
max: number;
|
|
13
|
+
};
|
|
14
|
+
/** Free-form response (e.g. the chosen value). */
|
|
15
|
+
response?: string;
|
|
16
|
+
/** Mark the activity complete. */
|
|
17
|
+
completion?: boolean;
|
|
18
|
+
/** Pedagogy context (from the lab's LabMeta) — e.g. the objective this event
|
|
19
|
+
* evidences. The host maps it into xAPI context.extensions. */
|
|
20
|
+
objectiveId?: string;
|
|
21
|
+
/** 1-based attempt number, when the host tracks tries. */
|
|
22
|
+
attempt?: number;
|
|
23
|
+
}
|
|
24
|
+
interface PriorAttempts {
|
|
25
|
+
bestPct: number;
|
|
26
|
+
attempts: number;
|
|
27
|
+
}
|
|
28
|
+
interface Learner {
|
|
29
|
+
/** Fire-and-forget: record an attempt/result. */
|
|
30
|
+
report(result: LearnerResult): void;
|
|
31
|
+
/** Optional: prior best for showing "Best: 3/4" etc. */
|
|
32
|
+
getPrior?(activity: string): PriorAttempts | null;
|
|
33
|
+
}
|
|
34
|
+
declare function LearnerProvider({
|
|
35
|
+
learner,
|
|
36
|
+
children
|
|
37
|
+
}: {
|
|
38
|
+
learner: Learner;
|
|
39
|
+
children: ReactNode;
|
|
40
|
+
}): ReactNode;
|
|
41
|
+
/** `null` when no host is wired (stateless preview). */
|
|
42
|
+
declare function useLearner(): Learner | null;
|
|
43
|
+
//#endregion
|
|
44
|
+
export { Learner, LearnerProvider, LearnerResult, PriorAttempts, useLearner };
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { createContext, createElement, useContext } from "react";
|
|
4
|
+
|
|
5
|
+
//#region src/core/learner.ts
|
|
6
|
+
/**
|
|
7
|
+
* Learner / assessment seam — a host-agnostic interface so a visualization can
|
|
8
|
+
* report "the learner did X / got it right" without knowing how it's stored.
|
|
9
|
+
* Mentora provides a `Learner` that maps `report()` to xAPI statements; a plain
|
|
10
|
+
* docs/demo app provides none and the viz degrades to a stateless playground.
|
|
11
|
+
* This is what powers Brilliant-style checkpoints + progress data.
|
|
12
|
+
*/
|
|
13
|
+
const LearnerContext = createContext(null);
|
|
14
|
+
function LearnerProvider({ learner, children }) {
|
|
15
|
+
return createElement(LearnerContext.Provider, { value: learner }, children);
|
|
16
|
+
}
|
|
17
|
+
/** `null` when no host is wired (stateless preview). */
|
|
18
|
+
function useLearner() {
|
|
19
|
+
return useContext(LearnerContext);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
//#endregion
|
|
23
|
+
export { LearnerProvider, useLearner };
|