@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,34 @@
|
|
|
1
|
+
import { WaveShape } from "../core/motion.mjs";
|
|
2
|
+
import { SimCore } from "./types.mjs";
|
|
3
|
+
|
|
4
|
+
//#region src/sim/wave.d.ts
|
|
5
|
+
type WaveMode = 'ac' | 'dc';
|
|
6
|
+
interface WaveParams {
|
|
7
|
+
mode?: WaveMode;
|
|
8
|
+
/** Peak voltage (V). Default 9. */
|
|
9
|
+
volts?: number;
|
|
10
|
+
/** AC frequency (Hz). Ignored for DC. Default 1. */
|
|
11
|
+
freqHz?: number;
|
|
12
|
+
shape?: WaveShape;
|
|
13
|
+
/** Trace ring-buffer capacity (samples). Default 240 (~4s @ 60fps). */
|
|
14
|
+
trace?: number;
|
|
15
|
+
}
|
|
16
|
+
interface WaveState {
|
|
17
|
+
mode: WaveMode;
|
|
18
|
+
volts: number;
|
|
19
|
+
freqHz: number;
|
|
20
|
+
shape: WaveShape;
|
|
21
|
+
/** Seconds elapsed on this core's timeline. */
|
|
22
|
+
tSec: number;
|
|
23
|
+
/** Instantaneous signed voltage; sign = current direction. */
|
|
24
|
+
v: number;
|
|
25
|
+
/** ∫v·dt — accumulated charge displacement; what flow position (electrons / water) reads. */
|
|
26
|
+
charge: number;
|
|
27
|
+
/** Rolling history of `v`, oldest-first, length ≤ `cap` — for the live trace. */
|
|
28
|
+
samples: number[];
|
|
29
|
+
/** Fixed capacity of `samples`. */
|
|
30
|
+
cap: number;
|
|
31
|
+
}
|
|
32
|
+
declare const WaveCore: SimCore<WaveState, WaveParams>;
|
|
33
|
+
//#endregion
|
|
34
|
+
export { WaveCore, WaveMode, WaveParams, WaveState };
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { oscillate } from "../core/motion.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/sim/wave.ts
|
|
4
|
+
/**
|
|
5
|
+
* `wave` — the AC/DC signal source. One core drives a whole scene: a DC source
|
|
6
|
+
* holds a steady voltage; an AC source oscillates. Either way it exposes the
|
|
7
|
+
* instantaneous voltage `v` (signed — sign IS current direction) plus a rolling
|
|
8
|
+
* `samples` buffer for a live scrolling trace. The SAME `v` can skin many ways at
|
|
9
|
+
* once (a lamp's brightness, electrons in a wire, water sloshing in a pipe), which
|
|
10
|
+
* is the point: sim ≠ render, so one signal feeds every visual metaphor in sync.
|
|
11
|
+
*
|
|
12
|
+
* `mode` / `volts` / `freqHz` live in state (not just params) so a control can
|
|
13
|
+
* write them between steps and a binding can read them by name.
|
|
14
|
+
*/
|
|
15
|
+
function signal(s) {
|
|
16
|
+
if (s.mode === "dc") return s.volts;
|
|
17
|
+
return oscillate(s.tSec, {
|
|
18
|
+
amp: s.volts,
|
|
19
|
+
freq: s.freqHz,
|
|
20
|
+
shape: s.shape
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
const WaveCore = {
|
|
24
|
+
reset(params = {}) {
|
|
25
|
+
const cap = Math.max(2, Math.round(params.trace ?? 240));
|
|
26
|
+
const base = {
|
|
27
|
+
mode: params.mode ?? "dc",
|
|
28
|
+
volts: params.volts ?? 9,
|
|
29
|
+
freqHz: params.freqHz ?? 1,
|
|
30
|
+
shape: params.shape ?? "sine",
|
|
31
|
+
tSec: 0
|
|
32
|
+
};
|
|
33
|
+
return {
|
|
34
|
+
...base,
|
|
35
|
+
v: signal(base),
|
|
36
|
+
charge: 0,
|
|
37
|
+
samples: [signal(base)],
|
|
38
|
+
cap
|
|
39
|
+
};
|
|
40
|
+
},
|
|
41
|
+
step(state, dtSec) {
|
|
42
|
+
const dt = Math.max(0, dtSec);
|
|
43
|
+
const tSec = state.tSec + dt;
|
|
44
|
+
const v = signal({
|
|
45
|
+
...state,
|
|
46
|
+
tSec
|
|
47
|
+
});
|
|
48
|
+
const charge = state.charge + v * dt;
|
|
49
|
+
const samples = state.samples.length >= state.cap ? [...state.samples.slice(state.samples.length - state.cap + 1), v] : [...state.samples, v];
|
|
50
|
+
return {
|
|
51
|
+
...state,
|
|
52
|
+
tSec,
|
|
53
|
+
v,
|
|
54
|
+
charge,
|
|
55
|
+
samples
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
//#endregion
|
|
61
|
+
export { WaveCore };
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
//#region src/steps/index.d.ts
|
|
4
|
+
/** One step in the sequence — identity + gating only. Its TITLE, PROSE, and the
|
|
5
|
+
* interactive itself live in the authored Section block, not here. */
|
|
6
|
+
interface StepSpec {
|
|
7
|
+
id: string;
|
|
8
|
+
/** Unlock when ALL these step ids are completed. Default: the previous step
|
|
9
|
+
* (linear). Empty array → always unlocked. */
|
|
10
|
+
gate?: {
|
|
11
|
+
after: string[];
|
|
12
|
+
};
|
|
13
|
+
/** If false, the step never blocks downstream gating (informational/explainer). */
|
|
14
|
+
required?: boolean;
|
|
15
|
+
}
|
|
16
|
+
interface StepProgress {
|
|
17
|
+
completed: boolean;
|
|
18
|
+
score?: {
|
|
19
|
+
raw: number;
|
|
20
|
+
max: number;
|
|
21
|
+
};
|
|
22
|
+
attempts: number;
|
|
23
|
+
}
|
|
24
|
+
interface StepState {
|
|
25
|
+
spec: StepSpec;
|
|
26
|
+
index: number;
|
|
27
|
+
completed: boolean;
|
|
28
|
+
unlocked: boolean;
|
|
29
|
+
/** The first unlocked-and-unfinished step (where the learner "is"). */
|
|
30
|
+
current: boolean;
|
|
31
|
+
progress: StepProgress;
|
|
32
|
+
}
|
|
33
|
+
/** Owns the progress store for one stepped lesson/unit. Wrap its steps in this.
|
|
34
|
+
* `id` namespaces the forwarded xAPI activity (e.g. the lesson id). */
|
|
35
|
+
declare function StepProgressProvider({
|
|
36
|
+
id,
|
|
37
|
+
children
|
|
38
|
+
}: {
|
|
39
|
+
id?: string;
|
|
40
|
+
children: ReactNode;
|
|
41
|
+
}): ReactNode;
|
|
42
|
+
/**
|
|
43
|
+
* Scope a step's interactive so its `useLearner().report(...)` routes into the
|
|
44
|
+
* step progress store (keyed by step id) AND forwards to the host's learner
|
|
45
|
+
* (namespaced) for xAPI. Wrap each step's interactive content with this.
|
|
46
|
+
*/
|
|
47
|
+
declare function StepScope({
|
|
48
|
+
id,
|
|
49
|
+
children
|
|
50
|
+
}: {
|
|
51
|
+
id: string;
|
|
52
|
+
children: ReactNode;
|
|
53
|
+
}): ReactNode;
|
|
54
|
+
/** Reactive step state for an ordered sequence: per-step completion + gating +
|
|
55
|
+
* the current step. Pass the `StepSpec[]` derived from the authored sections. */
|
|
56
|
+
declare function useStepProgress(steps: StepSpec[]): {
|
|
57
|
+
steps: StepState[];
|
|
58
|
+
allComplete: boolean;
|
|
59
|
+
reset: () => void;
|
|
60
|
+
};
|
|
61
|
+
//#endregion
|
|
62
|
+
export { StepProgress, StepProgressProvider, StepScope, StepSpec, StepState, useStepProgress };
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { LearnerProvider, useLearner } from "../core/learner.mjs";
|
|
4
|
+
import { createContext, createElement, useContext, useMemo, useRef, useSyncExternalStore } from "react";
|
|
5
|
+
|
|
6
|
+
//#region src/steps/index.ts
|
|
7
|
+
/**
|
|
8
|
+
* Step progress + gating — the runtime that turns an ordered sequence of lesson
|
|
9
|
+
* STEPS into "unlock the next when this one is solved" + aggregated completion.
|
|
10
|
+
*
|
|
11
|
+
* ONE SOURCE OF TRUTH for the step sequence is the authored content itself — the
|
|
12
|
+
* @classytic/cms-ui `Section`/`Step` blocks a creator orders in the editor
|
|
13
|
+
* (serialized in the MDX). This engine deliberately does NOT model an authored
|
|
14
|
+
* "LessonDoc": that would be a SECOND, competing definition of the same steps
|
|
15
|
+
* (and creators never hand-write one — they build Section blocks). The host's
|
|
16
|
+
* Section player derives a `StepSpec[]` from the section order, wraps each
|
|
17
|
+
* section's interactive in `<StepScope id>`, and reads `useStepProgress` to gate
|
|
18
|
+
* reveal. Each lab's `useLearner().report` flows into step completion AND
|
|
19
|
+
* forwards (namespaced) to the host's xAPI learner.
|
|
20
|
+
*
|
|
21
|
+
* Headless on purpose: progress is one external store read via
|
|
22
|
+
* `useSyncExternalStore` (one store, many subscribers, no concurrent-React
|
|
23
|
+
* tearing). The host renders the prose + resolves each step's interactive.
|
|
24
|
+
*/
|
|
25
|
+
const EMPTY = {};
|
|
26
|
+
const NOOP_SUBSCRIBE = () => () => {};
|
|
27
|
+
var StepStore = class {
|
|
28
|
+
map = /* @__PURE__ */ new Map();
|
|
29
|
+
snap = EMPTY;
|
|
30
|
+
listeners = /* @__PURE__ */ new Set();
|
|
31
|
+
subscribe = (l) => {
|
|
32
|
+
this.listeners.add(l);
|
|
33
|
+
return () => {
|
|
34
|
+
this.listeners.delete(l);
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
getSnapshot = () => this.snap;
|
|
38
|
+
report(stepId, r) {
|
|
39
|
+
const prev = this.map.get(stepId) ?? {
|
|
40
|
+
completed: false,
|
|
41
|
+
attempts: 0
|
|
42
|
+
};
|
|
43
|
+
this.map.set(stepId, {
|
|
44
|
+
completed: prev.completed || r.completion === true,
|
|
45
|
+
score: r.score ?? prev.score,
|
|
46
|
+
attempts: prev.attempts + 1
|
|
47
|
+
});
|
|
48
|
+
this.snap = Object.fromEntries(this.map);
|
|
49
|
+
this.listeners.forEach((l) => l());
|
|
50
|
+
}
|
|
51
|
+
reset() {
|
|
52
|
+
this.map.clear();
|
|
53
|
+
this.snap = EMPTY;
|
|
54
|
+
this.listeners.forEach((l) => l());
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
const StepCtx = createContext(null);
|
|
58
|
+
/** Owns the progress store for one stepped lesson/unit. Wrap its steps in this.
|
|
59
|
+
* `id` namespaces the forwarded xAPI activity (e.g. the lesson id). */
|
|
60
|
+
function StepProgressProvider({ id, children }) {
|
|
61
|
+
const ref = useRef(null);
|
|
62
|
+
if (!ref.current || ref.current.groupId !== id) ref.current = {
|
|
63
|
+
store: ref.current?.store ?? new StepStore(),
|
|
64
|
+
groupId: id
|
|
65
|
+
};
|
|
66
|
+
return createElement(StepCtx.Provider, { value: ref.current }, children);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Scope a step's interactive so its `useLearner().report(...)` routes into the
|
|
70
|
+
* step progress store (keyed by step id) AND forwards to the host's learner
|
|
71
|
+
* (namespaced) for xAPI. Wrap each step's interactive content with this.
|
|
72
|
+
*/
|
|
73
|
+
function StepScope({ id, children }) {
|
|
74
|
+
const ctx = useContext(StepCtx);
|
|
75
|
+
const host = useLearner();
|
|
76
|
+
return createElement(LearnerProvider, {
|
|
77
|
+
learner: useMemo(() => ({
|
|
78
|
+
report: (r) => {
|
|
79
|
+
ctx?.store.report(id, r);
|
|
80
|
+
host?.report({
|
|
81
|
+
...r,
|
|
82
|
+
activity: ctx?.groupId ? `${ctx.groupId}.${id}.${r.activity}` : `${id}.${r.activity}`
|
|
83
|
+
});
|
|
84
|
+
},
|
|
85
|
+
...host?.getPrior ? { getPrior: (a) => host.getPrior(a) } : {}
|
|
86
|
+
}), [
|
|
87
|
+
ctx,
|
|
88
|
+
host,
|
|
89
|
+
id
|
|
90
|
+
]),
|
|
91
|
+
children
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
/** Reactive step state for an ordered sequence: per-step completion + gating +
|
|
95
|
+
* the current step. Pass the `StepSpec[]` derived from the authored sections. */
|
|
96
|
+
function useStepProgress(steps) {
|
|
97
|
+
const ctx = useContext(StepCtx);
|
|
98
|
+
const subscribe = ctx?.store.subscribe ?? NOOP_SUBSCRIBE;
|
|
99
|
+
const getSnapshot = ctx?.store.getSnapshot ?? (() => EMPTY);
|
|
100
|
+
const snap = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
|
101
|
+
return useMemo(() => {
|
|
102
|
+
const out = steps.map((spec, i) => {
|
|
103
|
+
const progress = snap[spec.id] ?? {
|
|
104
|
+
completed: false,
|
|
105
|
+
attempts: 0
|
|
106
|
+
};
|
|
107
|
+
const prev = steps[i - 1];
|
|
108
|
+
const unlocked = (spec.gate?.after ?? (prev ? [prev.id] : [])).every((gid) => snap[gid]?.completed === true);
|
|
109
|
+
return {
|
|
110
|
+
spec,
|
|
111
|
+
index: i,
|
|
112
|
+
completed: progress.completed,
|
|
113
|
+
unlocked,
|
|
114
|
+
current: false,
|
|
115
|
+
progress
|
|
116
|
+
};
|
|
117
|
+
});
|
|
118
|
+
const cur = out.find((s) => s.unlocked && !s.completed) ?? out[out.length - 1];
|
|
119
|
+
if (cur) cur.current = true;
|
|
120
|
+
return {
|
|
121
|
+
steps: out,
|
|
122
|
+
allComplete: out.every((s) => s.completed || s.spec.required === false),
|
|
123
|
+
reset: () => ctx?.store.reset()
|
|
124
|
+
};
|
|
125
|
+
}, [
|
|
126
|
+
steps,
|
|
127
|
+
snap,
|
|
128
|
+
ctx
|
|
129
|
+
]);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
//#endregion
|
|
133
|
+
export { StepProgressProvider, StepScope, useStepProgress };
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
//#region src/thermo/index.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* `thermo` — ideal-gas thermodynamic processes, as a PURE kernel (like `field`:
|
|
4
|
+
* no React, no pixels, no time-stepping). A lab drives it with a slider/progress
|
|
5
|
+
* fraction; the kernel returns the full P–V path of a quasi-static process plus the
|
|
6
|
+
* first-law bookkeeping along it (work, heat, internal energy, entropy).
|
|
7
|
+
*
|
|
8
|
+
* Everything is one ideal gas, PV = nRT, with a degrees-of-freedom ratio γ = Cp/Cv
|
|
9
|
+
* (monatomic 5/3, diatomic 7/5). The four canonical processes and the Carnot cycle
|
|
10
|
+
* all compose from `runProcess`:
|
|
11
|
+
*
|
|
12
|
+
* isothermal T const PV = const W = nRT·ln(V₂/V₁), Q = W, ΔU = 0
|
|
13
|
+
* adiabatic Q = 0 PVᵞ = const W = (P₁V₁−P₂V₂)/(γ−1) = −ΔU, ΔS = 0
|
|
14
|
+
* isobaric P const V/T = const W = PΔV, Q = nCpΔT, ΔU = nCvΔT
|
|
15
|
+
* isochoric V const P/T = const W = 0, Q = ΔU = nCvΔT
|
|
16
|
+
*
|
|
17
|
+
* SI units throughout: P in pascals, V in m³, T in kelvin, energy in joules,
|
|
18
|
+
* entropy in J/K. (Labs convert to kPa / litres for display.)
|
|
19
|
+
*/
|
|
20
|
+
/** Universal gas constant, J/(mol·K). */
|
|
21
|
+
declare const R = 8.314462618;
|
|
22
|
+
type ProcessKind = 'isothermal' | 'adiabatic' | 'isobaric' | 'isochoric';
|
|
23
|
+
interface GasState {
|
|
24
|
+
/** Pressure, Pa. */
|
|
25
|
+
P: number;
|
|
26
|
+
/** Volume, m³. */
|
|
27
|
+
V: number;
|
|
28
|
+
/** Temperature, K. */
|
|
29
|
+
T: number;
|
|
30
|
+
/** Amount, mol. */
|
|
31
|
+
n: number;
|
|
32
|
+
}
|
|
33
|
+
interface ProcessResult {
|
|
34
|
+
kind: ProcessKind;
|
|
35
|
+
start: GasState;
|
|
36
|
+
end: GasState;
|
|
37
|
+
/** Sampled states from start→end (for drawing the P–V curve). */
|
|
38
|
+
path: GasState[];
|
|
39
|
+
/** Work done BY the gas, J ( = ∫P dV, the area under the P–V curve). */
|
|
40
|
+
W: number;
|
|
41
|
+
/** Heat added TO the gas, J. */
|
|
42
|
+
Q: number;
|
|
43
|
+
/** Change in internal energy, J ( = nCvΔT). */
|
|
44
|
+
dU: number;
|
|
45
|
+
/** Change in entropy, J/K. */
|
|
46
|
+
dS: number;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Run one quasi-static process from `start` to a target. Give exactly one target
|
|
50
|
+
* coordinate that the process can reach (e.g. a target volume for an isothermal or
|
|
51
|
+
* adiabatic expansion, a target temperature for isobaric/isochoric heating).
|
|
52
|
+
*/
|
|
53
|
+
declare function runProcess(start: GasState, kind: ProcessKind, target: {
|
|
54
|
+
V?: number;
|
|
55
|
+
P?: number;
|
|
56
|
+
T?: number;
|
|
57
|
+
}, gamma?: number, samples?: number): ProcessResult;
|
|
58
|
+
interface CarnotResult {
|
|
59
|
+
/** The four legs in order: hot isothermal expansion, adiabatic expansion,
|
|
60
|
+
* cold isothermal compression, adiabatic compression (back to start). */
|
|
61
|
+
legs: ProcessResult[];
|
|
62
|
+
Th: number;
|
|
63
|
+
Tc: number;
|
|
64
|
+
/** Heat absorbed from the hot reservoir, J (>0). */
|
|
65
|
+
Qh: number;
|
|
66
|
+
/** Heat rejected to the cold reservoir, J (>0). */
|
|
67
|
+
Qc: number;
|
|
68
|
+
/** Net work done by the engine over the cycle, J ( = Qh − Qc = enclosed P–V area). */
|
|
69
|
+
Wnet: number;
|
|
70
|
+
/** Thermal efficiency = 1 − Tc/Th = Wnet/Qh. */
|
|
71
|
+
efficiency: number;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* A Carnot cycle starting at `(V1, Th)`: isothermal expansion V1→V2 at Th, then
|
|
75
|
+
* adiabatic expansion to Tc, isothermal compression at Tc, and adiabatic
|
|
76
|
+
* compression back to the start. V3/V4 are fixed by the closure condition.
|
|
77
|
+
*/
|
|
78
|
+
declare function carnotCycle(n: number, Th: number, Tc: number, V1: number, V2: number, gamma?: number): CarnotResult;
|
|
79
|
+
declare const gammaMonatomic: number;
|
|
80
|
+
declare const gammaDiatomic: number;
|
|
81
|
+
//#endregion
|
|
82
|
+
export { CarnotResult, GasState, ProcessKind, ProcessResult, R, carnotCycle, gammaDiatomic, gammaMonatomic, runProcess };
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
//#region src/thermo/index.ts
|
|
2
|
+
/**
|
|
3
|
+
* `thermo` — ideal-gas thermodynamic processes, as a PURE kernel (like `field`:
|
|
4
|
+
* no React, no pixels, no time-stepping). A lab drives it with a slider/progress
|
|
5
|
+
* fraction; the kernel returns the full P–V path of a quasi-static process plus the
|
|
6
|
+
* first-law bookkeeping along it (work, heat, internal energy, entropy).
|
|
7
|
+
*
|
|
8
|
+
* Everything is one ideal gas, PV = nRT, with a degrees-of-freedom ratio γ = Cp/Cv
|
|
9
|
+
* (monatomic 5/3, diatomic 7/5). The four canonical processes and the Carnot cycle
|
|
10
|
+
* all compose from `runProcess`:
|
|
11
|
+
*
|
|
12
|
+
* isothermal T const PV = const W = nRT·ln(V₂/V₁), Q = W, ΔU = 0
|
|
13
|
+
* adiabatic Q = 0 PVᵞ = const W = (P₁V₁−P₂V₂)/(γ−1) = −ΔU, ΔS = 0
|
|
14
|
+
* isobaric P const V/T = const W = PΔV, Q = nCpΔT, ΔU = nCvΔT
|
|
15
|
+
* isochoric V const P/T = const W = 0, Q = ΔU = nCvΔT
|
|
16
|
+
*
|
|
17
|
+
* SI units throughout: P in pascals, V in m³, T in kelvin, energy in joules,
|
|
18
|
+
* entropy in J/K. (Labs convert to kPa / litres for display.)
|
|
19
|
+
*/
|
|
20
|
+
/** Universal gas constant, J/(mol·K). */
|
|
21
|
+
const R = 8.314462618;
|
|
22
|
+
const cv = (gamma) => R / (gamma - 1);
|
|
23
|
+
const cp = (gamma) => gamma * R / (gamma - 1);
|
|
24
|
+
/** State completed from any two of (P,V,T) via PV = nRT. */
|
|
25
|
+
const fromPV = (P, V, n) => ({
|
|
26
|
+
P,
|
|
27
|
+
V,
|
|
28
|
+
n,
|
|
29
|
+
T: P * V / (n * R)
|
|
30
|
+
});
|
|
31
|
+
/**
|
|
32
|
+
* Run one quasi-static process from `start` to a target. Give exactly one target
|
|
33
|
+
* coordinate that the process can reach (e.g. a target volume for an isothermal or
|
|
34
|
+
* adiabatic expansion, a target temperature for isobaric/isochoric heating).
|
|
35
|
+
*/
|
|
36
|
+
function runProcess(start, kind, target, gamma = 5 / 3, samples = 64) {
|
|
37
|
+
const { n } = start;
|
|
38
|
+
const Cv = cv(gamma), Cp = cp(gamma);
|
|
39
|
+
let end;
|
|
40
|
+
if (kind === "isothermal") {
|
|
41
|
+
const T = start.T;
|
|
42
|
+
const V2 = target.V ?? (target.P != null ? n * 8.314462618 * T / target.P : start.V);
|
|
43
|
+
end = fromPV(n * R * T / V2, V2, n);
|
|
44
|
+
} else if (kind === "adiabatic") {
|
|
45
|
+
const K = start.P * start.V ** gamma;
|
|
46
|
+
const V2 = target.V ?? (target.T != null ? start.V * (start.T / target.T) ** (1 / (gamma - 1)) : start.V);
|
|
47
|
+
end = fromPV(K / V2 ** gamma, V2, n);
|
|
48
|
+
} else if (kind === "isobaric") {
|
|
49
|
+
const P = start.P;
|
|
50
|
+
end = fromPV(P, target.V ?? (target.T != null ? n * 8.314462618 * target.T / P : start.V), n);
|
|
51
|
+
} else {
|
|
52
|
+
const V = start.V;
|
|
53
|
+
end = fromPV(target.P ?? (target.T != null ? n * 8.314462618 * target.T / V : start.P), V, n);
|
|
54
|
+
}
|
|
55
|
+
const path = [];
|
|
56
|
+
for (let i = 0; i <= samples; i++) {
|
|
57
|
+
const u = i / samples;
|
|
58
|
+
let s;
|
|
59
|
+
if (kind === "isochoric") s = fromPV(start.P + (end.P - start.P) * u, start.V, n);
|
|
60
|
+
else {
|
|
61
|
+
const V = start.V + (end.V - start.V) * u;
|
|
62
|
+
if (kind === "isothermal") s = fromPV(n * R * start.T / V, V, n);
|
|
63
|
+
else if (kind === "adiabatic") s = fromPV(start.P * start.V ** gamma / V ** gamma, V, n);
|
|
64
|
+
else s = fromPV(start.P, V, n);
|
|
65
|
+
}
|
|
66
|
+
path.push(s);
|
|
67
|
+
}
|
|
68
|
+
const dU = n * Cv * (end.T - start.T);
|
|
69
|
+
let W, Q, dS;
|
|
70
|
+
if (kind === "isothermal") {
|
|
71
|
+
W = n * R * start.T * Math.log(end.V / start.V);
|
|
72
|
+
Q = W;
|
|
73
|
+
dS = n * R * Math.log(end.V / start.V);
|
|
74
|
+
} else if (kind === "adiabatic") {
|
|
75
|
+
W = (start.P * start.V - end.P * end.V) / (gamma - 1);
|
|
76
|
+
Q = 0;
|
|
77
|
+
dS = 0;
|
|
78
|
+
} else if (kind === "isobaric") {
|
|
79
|
+
W = start.P * (end.V - start.V);
|
|
80
|
+
Q = n * Cp * (end.T - start.T);
|
|
81
|
+
dS = n * Cp * Math.log(end.T / start.T);
|
|
82
|
+
} else {
|
|
83
|
+
W = 0;
|
|
84
|
+
Q = dU;
|
|
85
|
+
dS = n * Cv * Math.log(end.T / start.T);
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
kind,
|
|
89
|
+
start,
|
|
90
|
+
end,
|
|
91
|
+
path,
|
|
92
|
+
W,
|
|
93
|
+
Q,
|
|
94
|
+
dU,
|
|
95
|
+
dS
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* A Carnot cycle starting at `(V1, Th)`: isothermal expansion V1→V2 at Th, then
|
|
100
|
+
* adiabatic expansion to Tc, isothermal compression at Tc, and adiabatic
|
|
101
|
+
* compression back to the start. V3/V4 are fixed by the closure condition.
|
|
102
|
+
*/
|
|
103
|
+
function carnotCycle(n, Th, Tc, V1, V2, gamma = 5 / 3) {
|
|
104
|
+
const ratio = (Th / Tc) ** (1 / (gamma - 1));
|
|
105
|
+
const V3 = V2 * ratio;
|
|
106
|
+
const V4 = V1 * ratio;
|
|
107
|
+
const legHot = runProcess(fromPV(n * R * Th / V1, V1, n), "isothermal", { V: V2 }, gamma);
|
|
108
|
+
const legExp = runProcess(legHot.end, "adiabatic", { V: V3 }, gamma);
|
|
109
|
+
const legCold = runProcess(legExp.end, "isothermal", { V: V4 }, gamma);
|
|
110
|
+
const legComp = runProcess(legCold.end, "adiabatic", { V: V1 }, gamma);
|
|
111
|
+
const Qh = legHot.Q;
|
|
112
|
+
const Qc = -legCold.Q;
|
|
113
|
+
const Wnet = Qh - Qc;
|
|
114
|
+
return {
|
|
115
|
+
legs: [
|
|
116
|
+
legHot,
|
|
117
|
+
legExp,
|
|
118
|
+
legCold,
|
|
119
|
+
legComp
|
|
120
|
+
],
|
|
121
|
+
Th,
|
|
122
|
+
Tc,
|
|
123
|
+
Qh,
|
|
124
|
+
Qc,
|
|
125
|
+
Wnet,
|
|
126
|
+
efficiency: 1 - Tc / Th
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
const gammaMonatomic = 5 / 3;
|
|
130
|
+
const gammaDiatomic = 7 / 5;
|
|
131
|
+
|
|
132
|
+
//#endregion
|
|
133
|
+
export { R, carnotCycle, gammaDiatomic, gammaMonatomic, runProcess };
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { ViewBox } from "../core/coords.mjs";
|
|
2
|
+
import { PointerEvent, ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
//#region src/view/Stage.d.ts
|
|
5
|
+
interface StageProps {
|
|
6
|
+
/** Visible math range. Defaults to [-8,8]×[-5,5]. */
|
|
7
|
+
view?: ViewBox;
|
|
8
|
+
/** Fixed pixel width (otherwise fills container + measures). */
|
|
9
|
+
width?: number;
|
|
10
|
+
/** Fixed pixel height (otherwise derived from aspectRatio or width). */
|
|
11
|
+
height?: number;
|
|
12
|
+
/** width / height, used to derive the missing dimension. */
|
|
13
|
+
aspectRatio?: number;
|
|
14
|
+
/** Inner padding in px. */
|
|
15
|
+
pad?: number;
|
|
16
|
+
/** Preserve aspect ratio (uniform scale). Default true. */
|
|
17
|
+
preserveAspect?: boolean;
|
|
18
|
+
ariaLabel?: string;
|
|
19
|
+
className?: string;
|
|
20
|
+
background?: string;
|
|
21
|
+
/** Pointer → math coordinate callback (tools/builder use this). */
|
|
22
|
+
onPointerMath?: (p: [number, number], e: PointerEvent<SVGSVGElement>) => void;
|
|
23
|
+
children?: ReactNode;
|
|
24
|
+
}
|
|
25
|
+
declare function Stage({
|
|
26
|
+
view,
|
|
27
|
+
width,
|
|
28
|
+
height,
|
|
29
|
+
aspectRatio,
|
|
30
|
+
pad,
|
|
31
|
+
preserveAspect,
|
|
32
|
+
ariaLabel,
|
|
33
|
+
className,
|
|
34
|
+
background,
|
|
35
|
+
onPointerMath,
|
|
36
|
+
children
|
|
37
|
+
}: StageProps): ReactNode;
|
|
38
|
+
//#endregion
|
|
39
|
+
export { Stage, StageProps };
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { createCoords } from "../core/coords.mjs";
|
|
4
|
+
import { CoordsContext, StageRefContext } from "../core/context.mjs";
|
|
5
|
+
import { useElementSize } from "./useElementSize.mjs";
|
|
6
|
+
import { useMemo, useRef } from "react";
|
|
7
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
8
|
+
|
|
9
|
+
//#region src/view/Stage.tsx
|
|
10
|
+
/**
|
|
11
|
+
* <Stage> — the SVG root. SSR-safe: builds a CoordinateSystem from explicit
|
|
12
|
+
* width/height/aspectRatio (or a default) so children render on first paint;
|
|
13
|
+
* after mount a ResizeObserver swaps in the measured width. There is ONE content
|
|
14
|
+
* group with no transform — every primitive self-positions via coords.toPx().
|
|
15
|
+
* A pixel-space overlay group hosts selection halos/handles (builder phase).
|
|
16
|
+
*/
|
|
17
|
+
const DEFAULT_VIEW = {
|
|
18
|
+
xMin: -8,
|
|
19
|
+
xMax: 8,
|
|
20
|
+
yMin: -5,
|
|
21
|
+
yMax: 5
|
|
22
|
+
};
|
|
23
|
+
function Stage({ view = DEFAULT_VIEW, width, height, aspectRatio, pad = 12, preserveAspect = true, ariaLabel = "Interactive figure", className, background = "var(--stage-bg)", onPointerMath, children }) {
|
|
24
|
+
const svgRef = useRef(null);
|
|
25
|
+
const { ref: wrapRef, size } = useElementSize();
|
|
26
|
+
const w = Math.max(1, Math.round(size?.width ?? width ?? 640));
|
|
27
|
+
const h = Math.max(1, Math.round(height ?? (aspectRatio ? w / aspectRatio : Math.max(160, w * .6))));
|
|
28
|
+
const coords = useMemo(() => createCoords(w, h, view, {
|
|
29
|
+
pad,
|
|
30
|
+
preserveAspect
|
|
31
|
+
}), [
|
|
32
|
+
w,
|
|
33
|
+
h,
|
|
34
|
+
view,
|
|
35
|
+
pad,
|
|
36
|
+
preserveAspect
|
|
37
|
+
]);
|
|
38
|
+
const handlePointer = onPointerMath ? (e) => {
|
|
39
|
+
const ctm = svgRef.current?.getScreenCTM();
|
|
40
|
+
if (!ctm) return;
|
|
41
|
+
const pt = new DOMPoint(e.clientX, e.clientY).matrixTransform(ctm.inverse());
|
|
42
|
+
onPointerMath(coords.toMath(pt.x, pt.y), e);
|
|
43
|
+
} : void 0;
|
|
44
|
+
return /* @__PURE__ */ jsx("div", {
|
|
45
|
+
ref: wrapRef,
|
|
46
|
+
className,
|
|
47
|
+
style: { width: "100%" },
|
|
48
|
+
children: /* @__PURE__ */ jsxs("svg", {
|
|
49
|
+
ref: svgRef,
|
|
50
|
+
width: w,
|
|
51
|
+
height: h,
|
|
52
|
+
viewBox: `0 0 ${w} ${h}`,
|
|
53
|
+
role: "group",
|
|
54
|
+
"aria-label": ariaLabel,
|
|
55
|
+
onPointerDown: handlePointer,
|
|
56
|
+
style: {
|
|
57
|
+
display: "block",
|
|
58
|
+
background,
|
|
59
|
+
borderRadius: 12,
|
|
60
|
+
border: "1px solid var(--stage-grid)",
|
|
61
|
+
userSelect: "none",
|
|
62
|
+
touchAction: "none"
|
|
63
|
+
},
|
|
64
|
+
children: [/* @__PURE__ */ jsx("defs", { children: /* @__PURE__ */ jsx("marker", {
|
|
65
|
+
id: "stage-arrow",
|
|
66
|
+
viewBox: "0 0 10 10",
|
|
67
|
+
refX: "8",
|
|
68
|
+
refY: "5",
|
|
69
|
+
markerWidth: "7",
|
|
70
|
+
markerHeight: "7",
|
|
71
|
+
orient: "auto-start-reverse",
|
|
72
|
+
children: /* @__PURE__ */ jsx("path", {
|
|
73
|
+
d: "M0,0 L10,5 L0,10 z",
|
|
74
|
+
fill: "context-stroke"
|
|
75
|
+
})
|
|
76
|
+
}) }), /* @__PURE__ */ jsx(CoordsContext.Provider, {
|
|
77
|
+
value: coords,
|
|
78
|
+
children: /* @__PURE__ */ jsxs(StageRefContext.Provider, {
|
|
79
|
+
value: svgRef,
|
|
80
|
+
children: [/* @__PURE__ */ jsx("g", {
|
|
81
|
+
"data-stage-content": true,
|
|
82
|
+
children
|
|
83
|
+
}), /* @__PURE__ */ jsx("g", { "data-stage-overlay": true })]
|
|
84
|
+
})
|
|
85
|
+
})]
|
|
86
|
+
})
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
//#endregion
|
|
91
|
+
export { Stage };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { RefObject } from "react";
|
|
2
|
+
|
|
3
|
+
//#region src/view/useElementSize.d.ts
|
|
4
|
+
interface Size {
|
|
5
|
+
width: number;
|
|
6
|
+
height: number;
|
|
7
|
+
}
|
|
8
|
+
declare function useElementSize<T extends HTMLElement>(): {
|
|
9
|
+
ref: RefObject<T | null>;
|
|
10
|
+
size: Size | null;
|
|
11
|
+
};
|
|
12
|
+
//#endregion
|
|
13
|
+
export { Size, useElementSize };
|