@classytic/stage 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/README.md +5 -1
- package/dist/assets/index.mjs +0 -1
- package/dist/builder/Palette.mjs +50 -89
- package/dist/builder/SceneBuilder.mjs +15 -73
- package/dist/core/index.d.mts +2 -1
- package/dist/core/index.mjs +2 -1
- package/dist/core/math.d.mts +26 -0
- package/dist/core/math.mjs +37 -0
- package/dist/finance/bizsim.d.mts +93 -0
- package/dist/finance/bizsim.mjs +117 -0
- package/dist/finance/index.d.mts +118 -0
- package/dist/finance/index.mjs +203 -0
- package/dist/index.d.mts +4 -3
- package/dist/index.mjs +4 -4
- package/dist/interaction/MovableDot.mjs +19 -0
- package/dist/interaction/useDraggable.mjs +24 -4
- package/dist/math/calculus.d.mts +22 -1
- package/dist/math/calculus.mjs +156 -2
- package/dist/math/index.d.mts +2 -2
- package/dist/math/index.mjs +2 -2
- package/dist/math/latex.mjs +8 -0
- package/dist/primitives/Dot.d.mts +2 -15
- package/dist/primitives/Dot.mjs +6 -4
- package/dist/primitives/Grid.d.mts +33 -17
- package/dist/primitives/Grid.mjs +89 -17
- package/dist/primitives/Label.d.mts +1 -14
- package/dist/primitives/Label.mjs +3 -2
- package/dist/primitives/Lines.d.mts +4 -32
- package/dist/primitives/Lines.mjs +10 -8
- package/dist/primitives/Shapes.d.mts +5 -43
- package/dist/primitives/Shapes.mjs +12 -10
- package/dist/primitives/index.d.mts +2 -2
- package/dist/primitives/index.mjs +2 -2
- package/dist/primitives/props.mjs +31 -0
- package/dist/scene/Scene.d.mts +6 -1
- package/dist/scene/Scene.mjs +15 -40
- package/dist/view/Stage.mjs +4 -11
- package/package.json +30 -22
- package/styles.css +125 -8
- package/dist/assets/kit/index.mjs +0 -4
package/LICENSE
CHANGED
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@ npm i @classytic/stage react react-dom
|
|
|
14
14
|
|
|
15
15
|
Everything is also re‑exported from the root for convenience, but prefer subpaths so
|
|
16
16
|
your bundle pulls only what it uses (ESM, per‑file emit; `sideEffects` are scoped to
|
|
17
|
-
`**/*.css
|
|
17
|
+
`**/*.css`, so JavaScript tree-shakes while the shared stylesheet is preserved):
|
|
18
18
|
|
|
19
19
|
| Subpath | What's in it |
|
|
20
20
|
|---|---|
|
|
@@ -63,3 +63,7 @@ export function Demo() {
|
|
|
63
63
|
## License
|
|
64
64
|
|
|
65
65
|
MIT
|
|
66
|
+
|
|
67
|
+
## Trademark
|
|
68
|
+
|
|
69
|
+
MIT-licensed code. "Classytic"/"arc" names + logos are trademarks of Classytic LLC — see [TRADEMARK.md](TRADEMARK.md).
|
package/dist/assets/index.mjs
CHANGED
package/dist/builder/Palette.mjs
CHANGED
|
@@ -4,96 +4,57 @@ import { ToolIcon } from "./icons.mjs";
|
|
|
4
4
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
5
5
|
|
|
6
6
|
//#region src/builder/Palette.tsx
|
|
7
|
-
const btn = (active) => ({
|
|
8
|
-
display: "inline-flex",
|
|
9
|
-
alignItems: "center",
|
|
10
|
-
gap: 6,
|
|
11
|
-
padding: "6px 10px",
|
|
12
|
-
borderRadius: 8,
|
|
13
|
-
fontSize: 13,
|
|
14
|
-
fontWeight: 600,
|
|
15
|
-
cursor: "pointer",
|
|
16
|
-
color: active ? "var(--stage-bg)" : "var(--stage-fg)",
|
|
17
|
-
background: active ? "var(--stage-accent)" : "transparent",
|
|
18
|
-
border: `1px solid ${active ? "var(--stage-accent)" : "var(--stage-muted)"}`
|
|
19
|
-
});
|
|
20
|
-
const iconBtn = (disabled) => ({
|
|
21
|
-
display: "inline-flex",
|
|
22
|
-
alignItems: "center",
|
|
23
|
-
gap: 6,
|
|
24
|
-
padding: "6px 10px",
|
|
25
|
-
borderRadius: 8,
|
|
26
|
-
fontSize: 13,
|
|
27
|
-
fontWeight: 600,
|
|
28
|
-
cursor: disabled ? "not-allowed" : "pointer",
|
|
29
|
-
opacity: disabled ? .4 : 1,
|
|
30
|
-
color: "var(--stage-fg)",
|
|
31
|
-
background: "transparent",
|
|
32
|
-
border: "1px solid var(--stage-muted)"
|
|
33
|
-
});
|
|
34
7
|
function Palette({ tools, activeTool, onSelect, onUndo, onRedo, canUndo, canRedo, onClear, onSave, hint }) {
|
|
35
|
-
return /* @__PURE__ */ jsxs("div", {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
},
|
|
86
|
-
children: "Save"
|
|
87
|
-
})
|
|
88
|
-
]
|
|
89
|
-
}), /* @__PURE__ */ jsx("p", {
|
|
90
|
-
style: {
|
|
91
|
-
fontSize: 12,
|
|
92
|
-
opacity: .7,
|
|
93
|
-
margin: "6px 2px 0"
|
|
94
|
-
},
|
|
95
|
-
children: hint
|
|
96
|
-
})] });
|
|
8
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
9
|
+
className: "stage-palette",
|
|
10
|
+
children: [/* @__PURE__ */ jsxs("div", {
|
|
11
|
+
role: "toolbar",
|
|
12
|
+
"aria-label": "construction tools",
|
|
13
|
+
className: "stage-palette__toolbar",
|
|
14
|
+
children: [
|
|
15
|
+
tools.map((t) => /* @__PURE__ */ jsxs("button", {
|
|
16
|
+
type: "button",
|
|
17
|
+
"aria-pressed": activeTool === t.id,
|
|
18
|
+
onClick: () => onSelect(t.id),
|
|
19
|
+
className: "stage-tool-button",
|
|
20
|
+
children: [/* @__PURE__ */ jsx(ToolIcon, { name: t.icon }), t.label]
|
|
21
|
+
}, t.id)),
|
|
22
|
+
/* @__PURE__ */ jsx("span", {
|
|
23
|
+
className: "stage-palette__divider",
|
|
24
|
+
"aria-hidden": "true"
|
|
25
|
+
}),
|
|
26
|
+
/* @__PURE__ */ jsxs("button", {
|
|
27
|
+
type: "button",
|
|
28
|
+
onClick: onUndo,
|
|
29
|
+
disabled: !canUndo,
|
|
30
|
+
className: "stage-tool-button",
|
|
31
|
+
children: [/* @__PURE__ */ jsx(ToolIcon, { name: "undo" }), "Undo"]
|
|
32
|
+
}),
|
|
33
|
+
/* @__PURE__ */ jsxs("button", {
|
|
34
|
+
type: "button",
|
|
35
|
+
onClick: onRedo,
|
|
36
|
+
disabled: !canRedo,
|
|
37
|
+
className: "stage-tool-button",
|
|
38
|
+
children: [/* @__PURE__ */ jsx(ToolIcon, { name: "redo" }), "Redo"]
|
|
39
|
+
}),
|
|
40
|
+
/* @__PURE__ */ jsx("button", {
|
|
41
|
+
type: "button",
|
|
42
|
+
onClick: onClear,
|
|
43
|
+
className: "stage-tool-button",
|
|
44
|
+
children: "Clear"
|
|
45
|
+
}),
|
|
46
|
+
onSave && /* @__PURE__ */ jsx("button", {
|
|
47
|
+
type: "button",
|
|
48
|
+
onClick: onSave,
|
|
49
|
+
className: "stage-tool-button stage-tool-button--save",
|
|
50
|
+
children: "Save"
|
|
51
|
+
})
|
|
52
|
+
]
|
|
53
|
+
}), /* @__PURE__ */ jsx("p", {
|
|
54
|
+
className: "stage-palette__hint",
|
|
55
|
+
children: hint
|
|
56
|
+
})]
|
|
57
|
+
});
|
|
97
58
|
}
|
|
98
59
|
|
|
99
60
|
//#endregion
|
|
@@ -149,12 +149,7 @@ function SceneBuilder({ initial, onSave, view, height = 440, tools, authoring =
|
|
|
149
149
|
}, `pend-${id}`) : null;
|
|
150
150
|
}), [pending, resolved]);
|
|
151
151
|
return /* @__PURE__ */ jsxs("div", {
|
|
152
|
-
className: "not-prose",
|
|
153
|
-
style: {
|
|
154
|
-
display: "flex",
|
|
155
|
-
flexDirection: "column",
|
|
156
|
-
gap: 10
|
|
157
|
-
},
|
|
152
|
+
className: "not-prose stage-builder",
|
|
158
153
|
children: [
|
|
159
154
|
/* @__PURE__ */ jsx(Palette, {
|
|
160
155
|
tools: toolSet,
|
|
@@ -184,21 +179,11 @@ function SceneBuilder({ initial, onSave, view, height = 440, tools, authoring =
|
|
|
184
179
|
]
|
|
185
180
|
}),
|
|
186
181
|
authoring && scalars.length > 0 && /* @__PURE__ */ jsx("div", {
|
|
187
|
-
|
|
188
|
-
display: "flex",
|
|
189
|
-
flexWrap: "wrap",
|
|
190
|
-
gap: 14,
|
|
191
|
-
padding: "2px",
|
|
192
|
-
fontSize: 13
|
|
193
|
-
},
|
|
182
|
+
className: "stage-builder__scalars",
|
|
194
183
|
children: scalars.map((el) => {
|
|
195
184
|
const f = el.free;
|
|
196
185
|
return /* @__PURE__ */ jsxs("label", {
|
|
197
|
-
|
|
198
|
-
display: "inline-flex",
|
|
199
|
-
alignItems: "center",
|
|
200
|
-
gap: 6
|
|
201
|
-
},
|
|
186
|
+
className: "stage-builder__scalar",
|
|
202
187
|
children: [
|
|
203
188
|
/* @__PURE__ */ jsx("strong", { children: el.label ?? el.id }),
|
|
204
189
|
/* @__PURE__ */ jsx("input", {
|
|
@@ -214,10 +199,7 @@ function SceneBuilder({ initial, onSave, view, height = 440, tools, authoring =
|
|
|
214
199
|
})
|
|
215
200
|
}),
|
|
216
201
|
/* @__PURE__ */ jsx("span", {
|
|
217
|
-
|
|
218
|
-
fontVariantNumeric: "tabular-nums",
|
|
219
|
-
minWidth: 24
|
|
220
|
-
},
|
|
202
|
+
className: "stage-builder__scalar-value",
|
|
221
203
|
children: f.value
|
|
222
204
|
})
|
|
223
205
|
]
|
|
@@ -225,30 +207,12 @@ function SceneBuilder({ initial, onSave, view, height = 440, tools, authoring =
|
|
|
225
207
|
})
|
|
226
208
|
}),
|
|
227
209
|
authoring && /* @__PURE__ */ jsxs("details", {
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
borderRadius: 8,
|
|
232
|
-
padding: "6px 10px"
|
|
233
|
-
},
|
|
234
|
-
children: [/* @__PURE__ */ jsx("summary", {
|
|
235
|
-
style: {
|
|
236
|
-
cursor: "pointer",
|
|
237
|
-
fontWeight: 600
|
|
238
|
-
},
|
|
239
|
-
children: "Pedagogy, objectives · hints · difficulty"
|
|
240
|
-
}), /* @__PURE__ */ jsxs("div", {
|
|
241
|
-
style: {
|
|
242
|
-
display: "grid",
|
|
243
|
-
gap: 8,
|
|
244
|
-
marginTop: 8
|
|
245
|
-
},
|
|
210
|
+
className: "stage-builder__pedagogy",
|
|
211
|
+
children: [/* @__PURE__ */ jsx("summary", { children: "Pedagogy, objectives · hints · difficulty" }), /* @__PURE__ */ jsxs("div", {
|
|
212
|
+
className: "stage-builder__pedagogy-fields",
|
|
246
213
|
children: [
|
|
247
214
|
/* @__PURE__ */ jsxs("label", {
|
|
248
|
-
|
|
249
|
-
display: "grid",
|
|
250
|
-
gap: 3
|
|
251
|
-
},
|
|
215
|
+
className: "stage-builder__field-label",
|
|
252
216
|
children: ["Objectives (one per line)", /* @__PURE__ */ jsx("textarea", {
|
|
253
217
|
rows: 2,
|
|
254
218
|
value: (pedagogy.objectives ?? []).join("\n"),
|
|
@@ -256,14 +220,11 @@ function SceneBuilder({ initial, onSave, view, height = 440, tools, authoring =
|
|
|
256
220
|
...p,
|
|
257
221
|
objectives: splitLines(e.currentTarget.value)
|
|
258
222
|
})),
|
|
259
|
-
|
|
223
|
+
className: "stage-builder__field"
|
|
260
224
|
})]
|
|
261
225
|
}),
|
|
262
226
|
/* @__PURE__ */ jsxs("label", {
|
|
263
|
-
|
|
264
|
-
display: "grid",
|
|
265
|
-
gap: 3
|
|
266
|
-
},
|
|
227
|
+
className: "stage-builder__field-label",
|
|
267
228
|
children: ["Hints (one per line)", /* @__PURE__ */ jsx("textarea", {
|
|
268
229
|
rows: 2,
|
|
269
230
|
value: (pedagogy.hints ?? []).join("\n"),
|
|
@@ -271,39 +232,29 @@ function SceneBuilder({ initial, onSave, view, height = 440, tools, authoring =
|
|
|
271
232
|
...p,
|
|
272
233
|
hints: splitLines(e.currentTarget.value)
|
|
273
234
|
})),
|
|
274
|
-
|
|
235
|
+
className: "stage-builder__field"
|
|
275
236
|
})]
|
|
276
237
|
}),
|
|
277
238
|
/* @__PURE__ */ jsxs("label", {
|
|
278
|
-
|
|
279
|
-
display: "grid",
|
|
280
|
-
gap: 3
|
|
281
|
-
},
|
|
239
|
+
className: "stage-builder__field-label",
|
|
282
240
|
children: ["Success criteria", /* @__PURE__ */ jsx("input", {
|
|
283
241
|
value: pedagogy.successCriteria ?? "",
|
|
284
242
|
onChange: (e) => setPedagogy((p) => ({
|
|
285
243
|
...p,
|
|
286
244
|
successCriteria: e.currentTarget.value || void 0
|
|
287
245
|
})),
|
|
288
|
-
|
|
246
|
+
className: "stage-builder__field"
|
|
289
247
|
})]
|
|
290
248
|
}),
|
|
291
249
|
/* @__PURE__ */ jsxs("label", {
|
|
292
|
-
|
|
293
|
-
display: "inline-flex",
|
|
294
|
-
alignItems: "center",
|
|
295
|
-
gap: 8
|
|
296
|
-
},
|
|
250
|
+
className: "stage-builder__difficulty",
|
|
297
251
|
children: ["Difficulty", /* @__PURE__ */ jsxs("select", {
|
|
298
252
|
value: pedagogy.difficulty ?? "",
|
|
299
253
|
onChange: (e) => setPedagogy((p) => ({
|
|
300
254
|
...p,
|
|
301
255
|
difficulty: e.currentTarget.value ? Number(e.currentTarget.value) : void 0
|
|
302
256
|
})),
|
|
303
|
-
|
|
304
|
-
...FIELD,
|
|
305
|
-
width: "auto"
|
|
306
|
-
},
|
|
257
|
+
className: "stage-builder__field stage-builder__field--select",
|
|
307
258
|
children: [/* @__PURE__ */ jsx("option", {
|
|
308
259
|
value: "",
|
|
309
260
|
children: ", "
|
|
@@ -325,15 +276,6 @@ function SceneBuilder({ initial, onSave, view, height = 440, tools, authoring =
|
|
|
325
276
|
]
|
|
326
277
|
});
|
|
327
278
|
}
|
|
328
|
-
const FIELD = {
|
|
329
|
-
width: "100%",
|
|
330
|
-
border: "1px solid var(--stage-muted)",
|
|
331
|
-
borderRadius: 6,
|
|
332
|
-
padding: "4px 8px",
|
|
333
|
-
background: "transparent",
|
|
334
|
-
color: "inherit",
|
|
335
|
-
font: "inherit"
|
|
336
|
-
};
|
|
337
279
|
const splitLines = (s) => s.split("\n").map((x) => x.trim()).filter(Boolean);
|
|
338
280
|
|
|
339
281
|
//#endregion
|
package/dist/core/index.d.mts
CHANGED
|
@@ -5,4 +5,5 @@ import { CoordsContext, DragOverlayContext, StageRefContext, useCoords, useDragO
|
|
|
5
5
|
import { ClockProvider, ClockProviderProps, FrameDriver, FrameInfo, useFrameLoop } from "./clock.mjs";
|
|
6
6
|
import { Learner, LearnerProvider, LearnerResult, PriorAttempts, useLearner } from "./learner.mjs";
|
|
7
7
|
import { EaseFn, EaseName, Keyframe, OscillateOpts, SpringOpts, WaveShape, ease, lerp, oscillate, prefersReducedMotion, spring, timeline } from "./motion.mjs";
|
|
8
|
-
|
|
8
|
+
import { approxEq, clamp, clamp01, gcd, remap, round, snapTo, toDeg, toRad } from "./math.mjs";
|
|
9
|
+
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, approxEq, clamp, clamp01, createCoords, ease, fmt, gcd, getControlSurface, lerp, listControlSurfaces, mat, onControlChange, oscillate, prefersReducedMotion, remap, round, snapTo, spring, timeline, toDeg, toRad, useControlSurface, useCoords, useDragOverlay, useFrameLoop, useLearner, useStageRef, vec };
|
package/dist/core/index.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import { CoordsContext, DragOverlayContext, StageRefContext, useCoords, useDragO
|
|
|
4
4
|
import { ClockProvider, useFrameLoop } from "./clock.mjs";
|
|
5
5
|
import { getControlSurface, listControlSurfaces, onControlChange, useControlSurface } from "./control.mjs";
|
|
6
6
|
import { LearnerProvider, useLearner } from "./learner.mjs";
|
|
7
|
+
import { approxEq, clamp, clamp01, gcd, remap, round, snapTo, toDeg, toRad } from "./math.mjs";
|
|
7
8
|
import { ease, lerp, oscillate, prefersReducedMotion, spring, timeline } from "./motion.mjs";
|
|
8
9
|
|
|
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 };
|
|
10
|
+
export { ClockProvider, CoordsContext, DragOverlayContext, LearnerProvider, StageRefContext, approxEq, clamp, clamp01, createCoords, ease, fmt, gcd, getControlSurface, lerp, listControlSurfaces, mat, onControlChange, oscillate, prefersReducedMotion, remap, round, snapTo, spring, timeline, toDeg, toRad, useControlSurface, useCoords, useDragOverlay, useFrameLoop, useLearner, useStageRef, vec };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
//#region src/core/math.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Small numeric kit shared by every consumer (labs re-exports these). One
|
|
4
|
+
* source of truth: labs presets used to hand-roll `clamp` 8+ different times.
|
|
5
|
+
* Pure, SSR-deterministic (no transcendental output is emitted directly into
|
|
6
|
+
* SVG — callers still `fmt()` pixel values).
|
|
7
|
+
*/
|
|
8
|
+
declare const clamp: (v: number, lo: number, hi: number) => number;
|
|
9
|
+
/** Clamp to the unit interval [0, 1] — the ubiquitous "fraction / progress" guard. */
|
|
10
|
+
declare const clamp01: (v: number) => number;
|
|
11
|
+
/** Round to `dp` decimal places (default 2), as a number. */
|
|
12
|
+
declare const round: (n: number, dp?: number) => number;
|
|
13
|
+
/** Greatest common divisor (Euclid), 0→1 so a ratio/fraction can always reduce. */
|
|
14
|
+
declare const gcd: (a: number, b: number) => number;
|
|
15
|
+
/** Degrees → radians. */
|
|
16
|
+
declare const toRad: (deg: number) => number;
|
|
17
|
+
/** Radians → degrees. */
|
|
18
|
+
declare const toDeg: (rad: number) => number;
|
|
19
|
+
/** Are two numbers equal within a tolerance (default 1e-6)? */
|
|
20
|
+
declare const approxEq: (a: number, b: number, eps?: number) => boolean;
|
|
21
|
+
/** Map x from [inMin,inMax] to [outMin,outMax], optionally clamped. */
|
|
22
|
+
declare function remap(x: number, inMin: number, inMax: number, outMin: number, outMax: number, doClamp?: boolean): number;
|
|
23
|
+
/** Snap `v` to the nearest multiple of `step` (step <= 0 → identity). */
|
|
24
|
+
declare const snapTo: (v: number, step: number) => number;
|
|
25
|
+
//#endregion
|
|
26
|
+
export { approxEq, clamp, clamp01, gcd, remap, round, snapTo, toDeg, toRad };
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
//#region src/core/math.ts
|
|
2
|
+
/**
|
|
3
|
+
* Small numeric kit shared by every consumer (labs re-exports these). One
|
|
4
|
+
* source of truth: labs presets used to hand-roll `clamp` 8+ different times.
|
|
5
|
+
* Pure, SSR-deterministic (no transcendental output is emitted directly into
|
|
6
|
+
* SVG — callers still `fmt()` pixel values).
|
|
7
|
+
*/
|
|
8
|
+
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
|
|
9
|
+
/** Clamp to the unit interval [0, 1] — the ubiquitous "fraction / progress" guard. */
|
|
10
|
+
const clamp01 = (v) => v < 0 ? 0 : v > 1 ? 1 : v;
|
|
11
|
+
/** Round to `dp` decimal places (default 2), as a number. */
|
|
12
|
+
const round = (n, dp = 2) => Math.round(n * 10 ** dp) / 10 ** dp;
|
|
13
|
+
/** Greatest common divisor (Euclid), 0→1 so a ratio/fraction can always reduce. */
|
|
14
|
+
const gcd = (a, b) => {
|
|
15
|
+
a = Math.abs(a);
|
|
16
|
+
b = Math.abs(b);
|
|
17
|
+
while (b) [a, b] = [b, a % b];
|
|
18
|
+
return a || 1;
|
|
19
|
+
};
|
|
20
|
+
const DEG = Math.PI / 180;
|
|
21
|
+
/** Degrees → radians. */
|
|
22
|
+
const toRad = (deg) => deg * DEG;
|
|
23
|
+
/** Radians → degrees. */
|
|
24
|
+
const toDeg = (rad) => rad / DEG;
|
|
25
|
+
/** Are two numbers equal within a tolerance (default 1e-6)? */
|
|
26
|
+
const approxEq = (a, b, eps = 1e-6) => Math.abs(a - b) <= eps;
|
|
27
|
+
/** Map x from [inMin,inMax] to [outMin,outMax], optionally clamped. */
|
|
28
|
+
function remap(x, inMin, inMax, outMin, outMax, doClamp = false) {
|
|
29
|
+
const t = inMax === inMin ? 0 : (x - inMin) / (inMax - inMin);
|
|
30
|
+
const v = outMin + (outMax - outMin) * t;
|
|
31
|
+
return doClamp ? clamp(v, Math.min(outMin, outMax), Math.max(outMin, outMax)) : v;
|
|
32
|
+
}
|
|
33
|
+
/** Snap `v` to the nearest multiple of `step` (step <= 0 → identity). */
|
|
34
|
+
const snapTo = (v, step) => step > 0 ? Math.round(v / step) * step : v;
|
|
35
|
+
|
|
36
|
+
//#endregion
|
|
37
|
+
export { approxEq, clamp, clamp01, gcd, remap, round, snapTo, toDeg, toRad };
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
//#region src/finance/bizsim.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* bizsim — a tiny, always-balanced business-simulation engine (part of `finance`).
|
|
4
|
+
*
|
|
5
|
+
* This is the PRIMITIVE a "run a business" story lesson is built on: a teacher/creator
|
|
6
|
+
* authors a scenario as a sequence of business ACTIONS (invest capital, take a loan,
|
|
7
|
+
* buy equipment, buy stock, sell, pay an expense, depreciate, close the period). Each
|
|
8
|
+
* action is a pure, DOUBLE-ENTRY-BALANCED transform of the business state, so the
|
|
9
|
+
* derived income statement and balance sheet are always correct and the balance sheet
|
|
10
|
+
* always balances — no accounting knowledge required to author, and it scales all the
|
|
11
|
+
* way to financial analysis (ratios) at the end.
|
|
12
|
+
*
|
|
13
|
+
* The engine is content-neutral: it knows nothing about samosas, apps or coffee — the
|
|
14
|
+
* creator supplies the story and the numbers.
|
|
15
|
+
*/
|
|
16
|
+
interface BizState {
|
|
17
|
+
cash: number;
|
|
18
|
+
/** Stock on hand: quantity and its value at cost. */
|
|
19
|
+
inventoryQty: number;
|
|
20
|
+
inventoryValue: number;
|
|
21
|
+
/** Fixed assets at book value. */
|
|
22
|
+
equipment: number;
|
|
23
|
+
/** Liabilities (loans). */
|
|
24
|
+
loan: number;
|
|
25
|
+
/** Owner capital contributed. */
|
|
26
|
+
capital: number;
|
|
27
|
+
/** Accumulated profit from closed periods. */
|
|
28
|
+
retained: number;
|
|
29
|
+
/** Current open-period tallies (become net profit at closePeriod). */
|
|
30
|
+
revenue: number;
|
|
31
|
+
cogs: number;
|
|
32
|
+
expenses: number;
|
|
33
|
+
}
|
|
34
|
+
declare const initialBiz: (over?: Partial<BizState>) => BizState;
|
|
35
|
+
type BizAction = {
|
|
36
|
+
type: 'invest';
|
|
37
|
+
amount: number;
|
|
38
|
+
} | {
|
|
39
|
+
type: 'loan';
|
|
40
|
+
amount: number;
|
|
41
|
+
} | {
|
|
42
|
+
type: 'repay';
|
|
43
|
+
amount: number;
|
|
44
|
+
} | {
|
|
45
|
+
type: 'buyEquipment';
|
|
46
|
+
amount: number;
|
|
47
|
+
} | {
|
|
48
|
+
type: 'depreciate';
|
|
49
|
+
amount: number;
|
|
50
|
+
} | {
|
|
51
|
+
type: 'buyStock';
|
|
52
|
+
qty: number;
|
|
53
|
+
unitCost: number;
|
|
54
|
+
} | {
|
|
55
|
+
type: 'sell';
|
|
56
|
+
qty: number;
|
|
57
|
+
price: number;
|
|
58
|
+
} | {
|
|
59
|
+
type: 'expense';
|
|
60
|
+
amount: number;
|
|
61
|
+
} | {
|
|
62
|
+
type: 'closePeriod';
|
|
63
|
+
};
|
|
64
|
+
/** Apply one action; pure and balance-preserving (assets = liabilities + equity). */
|
|
65
|
+
declare function applyBiz(s: BizState, a: BizAction): BizState;
|
|
66
|
+
/** Run a whole authored scenario from an (optional) starting state. */
|
|
67
|
+
declare function runBiz(actions: BizAction[], init?: Partial<BizState>): BizState;
|
|
68
|
+
interface IncomeStatement {
|
|
69
|
+
revenue: number;
|
|
70
|
+
cogs: number;
|
|
71
|
+
grossProfit: number;
|
|
72
|
+
expenses: number;
|
|
73
|
+
netProfit: number;
|
|
74
|
+
}
|
|
75
|
+
/** Income statement for the current open period. */
|
|
76
|
+
declare function incomeStatement(s: BizState): IncomeStatement;
|
|
77
|
+
interface BalanceSheet {
|
|
78
|
+
cash: number;
|
|
79
|
+
inventory: number;
|
|
80
|
+
equipment: number;
|
|
81
|
+
totalAssets: number;
|
|
82
|
+
loan: number;
|
|
83
|
+
capital: number;
|
|
84
|
+
retained: number;
|
|
85
|
+
openProfit: number;
|
|
86
|
+
totalEquity: number;
|
|
87
|
+
totalLiabilitiesEquity: number;
|
|
88
|
+
balances: boolean;
|
|
89
|
+
}
|
|
90
|
+
/** Balance sheet; `balances` is always true by construction (a self-check). */
|
|
91
|
+
declare function balanceSheet(s: BizState): BalanceSheet;
|
|
92
|
+
//#endregion
|
|
93
|
+
export { BalanceSheet, BizAction, BizState, IncomeStatement, applyBiz, balanceSheet, incomeStatement, initialBiz, runBiz };
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
//#region src/finance/bizsim.ts
|
|
2
|
+
const initialBiz = (over = {}) => ({
|
|
3
|
+
cash: 0,
|
|
4
|
+
inventoryQty: 0,
|
|
5
|
+
inventoryValue: 0,
|
|
6
|
+
equipment: 0,
|
|
7
|
+
loan: 0,
|
|
8
|
+
capital: 0,
|
|
9
|
+
retained: 0,
|
|
10
|
+
revenue: 0,
|
|
11
|
+
cogs: 0,
|
|
12
|
+
expenses: 0,
|
|
13
|
+
...over
|
|
14
|
+
});
|
|
15
|
+
/** Apply one action; pure and balance-preserving (assets = liabilities + equity). */
|
|
16
|
+
function applyBiz(s, a) {
|
|
17
|
+
switch (a.type) {
|
|
18
|
+
case "invest": return {
|
|
19
|
+
...s,
|
|
20
|
+
cash: s.cash + a.amount,
|
|
21
|
+
capital: s.capital + a.amount
|
|
22
|
+
};
|
|
23
|
+
case "loan": return {
|
|
24
|
+
...s,
|
|
25
|
+
cash: s.cash + a.amount,
|
|
26
|
+
loan: s.loan + a.amount
|
|
27
|
+
};
|
|
28
|
+
case "repay": return {
|
|
29
|
+
...s,
|
|
30
|
+
cash: s.cash - a.amount,
|
|
31
|
+
loan: s.loan - a.amount
|
|
32
|
+
};
|
|
33
|
+
case "buyEquipment": return {
|
|
34
|
+
...s,
|
|
35
|
+
cash: s.cash - a.amount,
|
|
36
|
+
equipment: s.equipment + a.amount
|
|
37
|
+
};
|
|
38
|
+
case "depreciate": return {
|
|
39
|
+
...s,
|
|
40
|
+
equipment: s.equipment - a.amount,
|
|
41
|
+
expenses: s.expenses + a.amount
|
|
42
|
+
};
|
|
43
|
+
case "buyStock": return {
|
|
44
|
+
...s,
|
|
45
|
+
cash: s.cash - a.qty * a.unitCost,
|
|
46
|
+
inventoryQty: s.inventoryQty + a.qty,
|
|
47
|
+
inventoryValue: s.inventoryValue + a.qty * a.unitCost
|
|
48
|
+
};
|
|
49
|
+
case "sell": {
|
|
50
|
+
const avg = s.inventoryQty > 0 ? s.inventoryValue / s.inventoryQty : 0;
|
|
51
|
+
const qty = Math.min(a.qty, s.inventoryQty);
|
|
52
|
+
const cost = qty * avg, sales = qty * a.price;
|
|
53
|
+
return {
|
|
54
|
+
...s,
|
|
55
|
+
cash: s.cash + sales,
|
|
56
|
+
revenue: s.revenue + sales,
|
|
57
|
+
cogs: s.cogs + cost,
|
|
58
|
+
inventoryQty: s.inventoryQty - qty,
|
|
59
|
+
inventoryValue: s.inventoryValue - cost
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
case "expense": return {
|
|
63
|
+
...s,
|
|
64
|
+
cash: s.cash - a.amount,
|
|
65
|
+
expenses: s.expenses + a.amount
|
|
66
|
+
};
|
|
67
|
+
case "closePeriod": {
|
|
68
|
+
const net = s.revenue - s.cogs - s.expenses;
|
|
69
|
+
return {
|
|
70
|
+
...s,
|
|
71
|
+
retained: s.retained + net,
|
|
72
|
+
revenue: 0,
|
|
73
|
+
cogs: 0,
|
|
74
|
+
expenses: 0
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
default: return s;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/** Run a whole authored scenario from an (optional) starting state. */
|
|
81
|
+
function runBiz(actions, init = {}) {
|
|
82
|
+
return actions.reduce(applyBiz, initialBiz(init));
|
|
83
|
+
}
|
|
84
|
+
/** Income statement for the current open period. */
|
|
85
|
+
function incomeStatement(s) {
|
|
86
|
+
const grossProfit = s.revenue - s.cogs;
|
|
87
|
+
return {
|
|
88
|
+
revenue: s.revenue,
|
|
89
|
+
cogs: s.cogs,
|
|
90
|
+
grossProfit,
|
|
91
|
+
expenses: s.expenses,
|
|
92
|
+
netProfit: grossProfit - s.expenses
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
/** Balance sheet; `balances` is always true by construction (a self-check). */
|
|
96
|
+
function balanceSheet(s) {
|
|
97
|
+
const totalAssets = s.cash + s.inventoryValue + s.equipment;
|
|
98
|
+
const openProfit = s.revenue - s.cogs - s.expenses;
|
|
99
|
+
const totalEquity = s.capital + s.retained + openProfit;
|
|
100
|
+
const totalLiabilitiesEquity = s.loan + totalEquity;
|
|
101
|
+
return {
|
|
102
|
+
cash: s.cash,
|
|
103
|
+
inventory: s.inventoryValue,
|
|
104
|
+
equipment: s.equipment,
|
|
105
|
+
totalAssets,
|
|
106
|
+
loan: s.loan,
|
|
107
|
+
capital: s.capital,
|
|
108
|
+
retained: s.retained,
|
|
109
|
+
openProfit,
|
|
110
|
+
totalEquity,
|
|
111
|
+
totalLiabilitiesEquity,
|
|
112
|
+
balances: Math.abs(totalAssets - totalLiabilitiesEquity) < 1e-6
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
//#endregion
|
|
117
|
+
export { applyBiz, balanceSheet, incomeStatement, initialBiz, runBiz };
|