@jarenjs/calc 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +60 -0
  2. package/dist/types/ast.d.ts +65 -0
  3. package/dist/types/compile.d.ts +52 -0
  4. package/dist/types/component/index.d.ts +259 -0
  5. package/dist/types/component/rates/binance.d.ts +34 -0
  6. package/dist/types/component/rates/coingecko.d.ts +47 -0
  7. package/dist/types/component/rates/index.d.ts +84 -0
  8. package/dist/types/component/rules.d.ts +180 -0
  9. package/dist/types/component/schema.d.ts +38 -0
  10. package/dist/types/env.d.ts +24 -0
  11. package/dist/types/errors.d.ts +18 -0
  12. package/dist/types/index.d.ts +42 -0
  13. package/dist/types/modes/converter.d.ts +46 -0
  14. package/dist/types/modes/financial.d.ts +59 -0
  15. package/dist/types/modes/index.d.ts +38 -0
  16. package/dist/types/modes/programmer.d.ts +50 -0
  17. package/dist/types/modes/scientific.d.ts +28 -0
  18. package/dist/types/modes/standard.d.ts +32 -0
  19. package/dist/types/parser/index.d.ts +32 -0
  20. package/dist/types/plot/plot2d.d.ts +70 -0
  21. package/dist/types/plot/plot3d.d.ts +68 -0
  22. package/dist/types/render/error.d.ts +19 -0
  23. package/dist/types/theme.d.ts +35 -0
  24. package/dist/types/to-expr.d.ts +13 -0
  25. package/dist/types/utils.d.ts +9 -0
  26. package/docs/CALC-FORMAT.md +79 -0
  27. package/package.json +71 -0
  28. package/schemas/financial-inputs.schema.json +15 -0
  29. package/schemas/jaren-calc-ast.schema.json +91 -0
  30. package/schemas/jaren-calc-state.schema.json +52 -0
  31. package/src/ast.js +96 -0
  32. package/src/compile.js +119 -0
  33. package/src/component/index.js +352 -0
  34. package/src/component/rates/binance.js +44 -0
  35. package/src/component/rates/coingecko.js +63 -0
  36. package/src/component/rates/index.js +116 -0
  37. package/src/component/rules.js +118 -0
  38. package/src/component/schema.js +24 -0
  39. package/src/env.js +163 -0
  40. package/src/errors.js +23 -0
  41. package/src/index.js +85 -0
  42. package/src/modes/converter.js +73 -0
  43. package/src/modes/financial.js +89 -0
  44. package/src/modes/index.js +24 -0
  45. package/src/modes/programmer.js +69 -0
  46. package/src/modes/scientific.js +31 -0
  47. package/src/modes/standard.js +39 -0
  48. package/src/parser/index.js +220 -0
  49. package/src/plot/plot2d.js +221 -0
  50. package/src/plot/plot3d.js +177 -0
  51. package/src/render/error.js +25 -0
  52. package/src/theme.js +76 -0
  53. package/src/to-expr.js +84 -0
  54. package/src/utils.js +11 -0
  55. package/styles/calc.css +128 -0
@@ -0,0 +1,31 @@
1
+ //@ts-check
2
+ /**
3
+ * @file The scientific mode kernel: the standard keypad plus the
4
+ * A1 transcendentals as function-insert keys, constants, factorial and an
5
+ * angle-mode toggle. It binds the same float environment as standard —
6
+ * the transcendentals were already in it — so no formulas live here.
7
+ */
8
+
9
+ import { defaultEnv } from '../env.js';
10
+ import { format } from './standard.js';
11
+
12
+ /** @type {Array<Array<{label:string,k:string,tone?:string,span?:number}>>} */
13
+ export const KEYPAD = [
14
+ [{ label: 'sin', k: 'sin(', tone: 'fn' }, { label: 'cos', k: 'cos(', tone: 'fn' }, { label: 'tan', k: 'tan(', tone: 'fn' }, { label: 'C', k: 'clear', tone: 'clear' }, { label: '⌫', k: 'back', tone: 'clear' }],
15
+ [{ label: 'asin', k: 'asin(', tone: 'fn' }, { label: 'acos', k: 'acos(', tone: 'fn' }, { label: 'atan', k: 'atan(', tone: 'fn' }, { label: '(', k: '(' }, { label: ')', k: ')' }],
16
+ [{ label: 'ln', k: 'ln(', tone: 'fn' }, { label: 'log', k: 'log(', tone: 'fn' }, { label: '√', k: 'sqrt(', tone: 'fn' }, { label: '7', k: '7' }, { label: '8', k: '8' }],
17
+ [{ label: 'eˣ', k: 'exp(', tone: 'fn' }, { label: 'x!', k: '!', tone: 'op' }, { label: '^', k: '^', tone: 'op' }, { label: '9', k: '9' }, { label: '÷', k: '/', tone: 'op' }],
18
+ [{ label: 'π', k: 'pi', tone: 'const' }, { label: 'e', k: 'e', tone: 'const' }, { label: '4', k: '4' }, { label: '5', k: '5' }, { label: '6', k: '6' }],
19
+ [{ label: '×', k: '*', tone: 'op' }, { label: '1', k: '1' }, { label: '2', k: '2' }, { label: '3', k: '3' }, { label: '−', k: '-', tone: 'op' }],
20
+ [{ label: '0', k: '0' }, { label: '.', k: '.' }, { label: 'ans', k: 'ans' }, { label: '+', k: '+', tone: 'op' }, { label: '=', k: 'equals', tone: 'equals' }],
21
+ ];
22
+
23
+ export const scientificMode = {
24
+ id: 'scientific',
25
+ label: 'Scientific',
26
+ env: defaultEnv(),
27
+ keypad: KEYPAD,
28
+ format,
29
+ /** angle modes offered by the toggle */
30
+ angleModes: ['rad', 'deg', 'grad'],
31
+ };
@@ -0,0 +1,39 @@
1
+ //@ts-check
2
+ /**
3
+ * @file The standard mode kernel: a data-driven
4
+ * keypad descriptor, the function-binding environment the compiler uses,
5
+ * and a display formatter. A key is `{ label, k, tone?, span? }`; `k` is
6
+ * the token appended to the expression entry, or a command (`=`, `C`,
7
+ * `back`). Switching mode is just a patch of `$.calc.mode`.
8
+ */
9
+
10
+ import { formatNumber } from '@jarenjs/core/math';
11
+ import { defaultEnv } from '../env.js';
12
+
13
+ /** @type {Array<Array<{label:string,k:string,tone?:string,span?:number}>>} */
14
+ export const KEYPAD = [
15
+ [{ label: 'C', k: 'clear', tone: 'clear' }, { label: '⌫', k: 'back', tone: 'clear' }, { label: '(', k: '(' }, { label: ')', k: ')' }],
16
+ [{ label: '7', k: '7' }, { label: '8', k: '8' }, { label: '9', k: '9' }, { label: '÷', k: '/', tone: 'op' }],
17
+ [{ label: '4', k: '4' }, { label: '5', k: '5' }, { label: '6', k: '6' }, { label: '×', k: '*', tone: 'op' }],
18
+ [{ label: '1', k: '1' }, { label: '2', k: '2' }, { label: '3', k: '3' }, { label: '−', k: '-', tone: 'op' }],
19
+ [{ label: '0', k: '0' }, { label: '.', k: '.' }, { label: '%', k: '%' }, { label: '+', k: '+', tone: 'op' }],
20
+ [{ label: 'ans', k: 'ans' }, { label: '^', k: '^', tone: 'op' }, { label: '=', k: 'equals', tone: 'equals', span: 2 }],
21
+ ];
22
+
23
+ /**
24
+ * Format a numeric result for the display.
25
+ * @param {number} value @param {any} [state]
26
+ * @returns {string}
27
+ */
28
+ export function format(value, state) {
29
+ const group = state && state.group === true;
30
+ return formatNumber(value, { notation: 'auto', precision: 12, group });
31
+ }
32
+
33
+ export const standardMode = {
34
+ id: 'standard',
35
+ label: 'Standard',
36
+ env: defaultEnv(),
37
+ keypad: KEYPAD,
38
+ format,
39
+ };
@@ -0,0 +1,220 @@
1
+ //@ts-check
2
+ /**
3
+ * @file `parseExpression(text, opts) → ExprAST`. A char-offset
4
+ * recursive-descent, precedence-climbing parser in the repository idiom
5
+ * (module-const sticky regexes for the number/identifier leaves, a
6
+ * `fail(msg, pos)` that raises `CalcParseError` with 1-based line/column).
7
+ * No `eval`, no per-parse `RegExp` allocation.
8
+ *
9
+ * Grammar (low → high precedence), all left-associative except power
10
+ * (right) and the prefix unaries:
11
+ *
12
+ * or := and ('|' and)*
13
+ * and := shift ('&' shift)*
14
+ * shift := add (('<<'|'>>') add)*
15
+ * add := mul (('+'|'-') mul)*
16
+ * mul := unary (('*'|'/') unary)*
17
+ * unary := ('-'|'+'|'~') unary | power
18
+ * power := postfix ('^' unary)? // right-assoc via unary
19
+ * postfix := primary ('!' | '%')*
20
+ * primary := number | const | ident | ident '(' args ')' | '(' or ')'
21
+ *
22
+ * Number bases (`0x`/`0o`/`0b`) and scientific notation are recognized at
23
+ * the leaf; the AST keeps only the numeric value.
24
+ */
25
+
26
+ import { num, constant, variable, unary, postfix, binary, call, isConstant } from '../ast.js';
27
+ import { CalcParseError } from '../errors.js';
28
+
29
+ /** Sticky number matcher: hex / octal / binary / decimal-with-exponent. */
30
+ const RE_NUMBER = /0[xX][0-9a-fA-F]+|0[oO][0-7]+|0[bB][01]+|(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?/y;
31
+ /** Sticky identifier matcher (ascii idents plus the greek constant glyphs). */
32
+ const RE_IDENT = /[A-Za-z_][A-Za-z0-9_]*|[πφτ]/y;
33
+
34
+ /** Binary operator precedences (higher binds tighter); power is separate. */
35
+ const BINOPS = {
36
+ '|': 1, '&': 2, '<<': 3, '>>': 3, '+': 4, '-': 4, '*': 5, '/': 5,
37
+ };
38
+
39
+ class Parser {
40
+ /** @param {string} text */
41
+ constructor(text) {
42
+ this.s = text;
43
+ this.n = text.length;
44
+ this.p = 0;
45
+ }
46
+
47
+ /** @returns {never} */
48
+ fail(message, pos = this.p) {
49
+ let line = 1;
50
+ let col = 1;
51
+ for (let i = 0; i < pos && i < this.n; i++) {
52
+ if (this.s.charCodeAt(i) === 10) { line++; col = 1; } else col++;
53
+ }
54
+ throw new CalcParseError(message, line, col, pos);
55
+ }
56
+
57
+ skipWs() {
58
+ while (this.p < this.n) {
59
+ const c = this.s.charCodeAt(this.p);
60
+ if (c === 0x20 || c === 0x09 || c === 0x0a || c === 0x0d) this.p++;
61
+ else break;
62
+ }
63
+ }
64
+
65
+ /** Peek the operator at the cursor (no consume): returns its string or null. */
66
+ peekOp() {
67
+ const c = this.s.charCodeAt(this.p);
68
+ const c2 = this.p + 1 < this.n ? this.s.charCodeAt(this.p + 1) : 0;
69
+ if (c === 0x3c && c2 === 0x3c) return '<<';
70
+ if (c === 0x3e && c2 === 0x3e) return '>>';
71
+ if (c === 0x7c) return '|';
72
+ if (c === 0x26) return '&';
73
+ if (c === 0x2b) return '+';
74
+ if (c === 0x2d) return '-';
75
+ if (c === 0x2a) return '*';
76
+ if (c === 0x2f) return '/';
77
+ return null;
78
+ }
79
+
80
+ parse() {
81
+ this.skipWs();
82
+ if (this.p >= this.n) this.fail('empty expression');
83
+ const node = this.parseBinary(1);
84
+ this.skipWs();
85
+ if (this.p < this.n) this.fail(`unexpected '${this.s[this.p]}'`);
86
+ return node;
87
+ }
88
+
89
+ /** Precedence climbing over BINOPS (power/unary handled below). */
90
+ parseBinary(minPrec) {
91
+ let left = this.parseUnary();
92
+ for (;;) {
93
+ this.skipWs();
94
+ const op = this.peekOp();
95
+ if (op === null) break;
96
+ const prec = BINOPS[op];
97
+ if (prec === undefined || prec < minPrec) break;
98
+ this.p += op.length;
99
+ const right = this.parseBinary(prec + 1); // all these are left-assoc
100
+ left = binary(op, left, right);
101
+ }
102
+ return left;
103
+ }
104
+
105
+ parseUnary() {
106
+ this.skipWs();
107
+ const c = this.s.charCodeAt(this.p);
108
+ if (c === 0x2d || c === 0x2b || c === 0x7e) { // - + ~
109
+ const op = this.s[this.p];
110
+ this.p++;
111
+ return unary(op, this.parseUnary());
112
+ }
113
+ return this.parsePower();
114
+ }
115
+
116
+ parsePower() {
117
+ const base = this.parsePostfix();
118
+ this.skipWs();
119
+ if (this.s.charCodeAt(this.p) === 0x5e) { // ^
120
+ this.p++;
121
+ return binary('^', base, this.parseUnary()); // right-assoc
122
+ }
123
+ return base;
124
+ }
125
+
126
+ parsePostfix() {
127
+ let node = this.parsePrimary();
128
+ for (;;) {
129
+ this.skipWs();
130
+ const c = this.s.charCodeAt(this.p);
131
+ if (c === 0x21) { this.p++; node = postfix('!', node); } // !
132
+ else if (c === 0x25) { this.p++; node = postfix('%', node); } // %
133
+ else break;
134
+ }
135
+ return node;
136
+ }
137
+
138
+ parsePrimary() {
139
+ this.skipWs();
140
+ if (this.p >= this.n) this.fail('unexpected end of input');
141
+ const c = this.s.charCodeAt(this.p);
142
+
143
+ if (c === 0x28) { // (
144
+ this.p++;
145
+ const inner = this.parseBinary(1);
146
+ this.skipWs();
147
+ if (this.s.charCodeAt(this.p) !== 0x29) this.fail("expected ')'");
148
+ this.p++;
149
+ return inner;
150
+ }
151
+
152
+ // number
153
+ if ((c >= 0x30 && c <= 0x39) || c === 0x2e) { // digit or '.'
154
+ RE_NUMBER.lastIndex = this.p;
155
+ const m = RE_NUMBER.exec(this.s);
156
+ if (m === null || m.index !== this.p) this.fail('invalid number');
157
+ this.p += m[0].length;
158
+ return num(parseNumericLiteral(m[0]));
159
+ }
160
+
161
+ // identifier: constant, variable, or function call
162
+ RE_IDENT.lastIndex = this.p;
163
+ const mi = RE_IDENT.exec(this.s);
164
+ if (mi !== null && mi.index === this.p) {
165
+ const name = mi[0];
166
+ this.p += name.length;
167
+ this.skipWs();
168
+ if (this.s.charCodeAt(this.p) === 0x28) { // '(' → call
169
+ this.p++;
170
+ const args = this.parseArgs();
171
+ return call(name, args);
172
+ }
173
+ return isConstant(name) ? constant(name) : variable(name);
174
+ }
175
+
176
+ this.fail(`unexpected '${this.s[this.p]}'`);
177
+ }
178
+
179
+ parseArgs() {
180
+ const args = [];
181
+ this.skipWs();
182
+ if (this.s.charCodeAt(this.p) === 0x29) { this.p++; return args; } // empty
183
+ for (;;) {
184
+ args.push(this.parseBinary(1));
185
+ this.skipWs();
186
+ const c = this.s.charCodeAt(this.p);
187
+ if (c === 0x2c) { this.p++; continue; } // ,
188
+ if (c === 0x29) { this.p++; break; } // )
189
+ this.fail("expected ',' or ')'");
190
+ }
191
+ return args;
192
+ }
193
+ }
194
+
195
+ /**
196
+ * Interpret a matched numeric literal (respecting 0x/0o/0b prefixes).
197
+ * @param {string} lit
198
+ * @returns {number}
199
+ */
200
+ function parseNumericLiteral(lit) {
201
+ if (lit.length > 1 && lit[0] === '0') {
202
+ const k = lit[1];
203
+ if (k === 'x' || k === 'X') return parseInt(lit.slice(2), 16);
204
+ if (k === 'o' || k === 'O') return parseInt(lit.slice(2), 8);
205
+ if (k === 'b' || k === 'B') return parseInt(lit.slice(2), 2);
206
+ }
207
+ return Number(lit);
208
+ }
209
+
210
+ /**
211
+ * Parse an expression string into an `ExprAST`.
212
+ * @param {string} text
213
+ * @param {{ [k: string]: any }} [opts]
214
+ * @returns {any}
215
+ */
216
+ export function parseExpression(text, opts = {}) {
217
+ void opts; // reserved for future mode gating (see CALC-FORMAT.md)
218
+ if (typeof text !== 'string') throw new CalcParseError('expression must be a string');
219
+ return new Parser(text).parse();
220
+ }
@@ -0,0 +1,221 @@
1
+ //@ts-check
2
+ /**
3
+ * @file The x·y plotter. `plot2d` compiles `f(x)`
4
+ * ONCE, samples the domain into `Float64Array` buffers, maps them to the
5
+ * viewport with a standard linear remap, and emits axes/grid/ticks plus one
6
+ * `<path>` polyline per series — breaking the path on NaN/±Inf and at
7
+ * asymptote jumps. The intermediate **scene** is geometry-as-plain-JSON
8
+ * (deterministic → golden-geometry tests); `scene2dToVnode` turns it into
9
+ * pure-vnode SVG.
10
+ */
11
+
12
+ import { Float64, remap, niceStep } from '@jarenjs/core/math';
13
+ import { svgRoot, line, path, textAt, polylinePath } from '@jarenjs/view/helpers';
14
+ import { parseExpression } from '../parser/index.js';
15
+ import { compileExpr } from '../compile.js';
16
+ import { defaultEnv } from '../env.js';
17
+ import { createTheme } from '../theme.js';
18
+ import { contentKey } from '@jarenjs/core/object';
19
+
20
+ const DEFAULTS = {
21
+ width: 480,
22
+ height: 320,
23
+ samples: 240,
24
+ padding: { left: 40, right: 12, top: 12, bottom: 24 },
25
+ domain: [-10, 10],
26
+ variable: 'x',
27
+ };
28
+
29
+ /**
30
+ * Evenly-spaced "nice" ticks spanning [min, max].
31
+ * @param {number} min @param {number} max @param {number} count
32
+ * @returns {number[]}
33
+ */
34
+ function niceTicks(min, max, count) {
35
+ if (!(max > min)) return [min];
36
+ // A non-positive per-step span (a degenerate `count`) falls back to unit
37
+ // ticks rather than letting the ladder answer with NaN.
38
+ const raw = (max - min) / count;
39
+ const step = raw > 0 ? niceStep(raw) : 1;
40
+ const start = Math.ceil(min / step) * step;
41
+ const ticks = [];
42
+ for (let v = start; v <= max + step * 1e-9; v += step) {
43
+ ticks.push(Math.abs(v) < step * 1e-9 ? 0 : v);
44
+ }
45
+ return ticks;
46
+ }
47
+
48
+ /**
49
+ * @typedef {object} Plot2dConfig
50
+ * @property {string|string[]} [expr] one or more source expressions
51
+ * @property {[number,number]} [domain]
52
+ * @property {[number,number]} [range] omit for auto-range
53
+ * @property {number} [samples]
54
+ * @property {number} [width] @property {number} [height]
55
+ * @property {string} [variable] independent variable name (default 'x')
56
+ * @property {any} [scope] extra variables in scope
57
+ * @property {any} [env] evaluation environment (default float)
58
+ * @property {any} [theme]
59
+ */
60
+
61
+ /**
62
+ * Build the geometry-as-JSON scene.
63
+ * @param {string|string[]|Plot2dConfig} exprOrConfig
64
+ * @param {Plot2dConfig} [options]
65
+ * @returns {any}
66
+ */
67
+ export function buildScene2d(exprOrConfig, options = {}) {
68
+ /** @type {Plot2dConfig} */
69
+ const cfg = typeof exprOrConfig === 'string' || Array.isArray(exprOrConfig)
70
+ ? { ...options, expr: exprOrConfig }
71
+ : { ...options, ...exprOrConfig };
72
+
73
+ const width = cfg.width ?? DEFAULTS.width;
74
+ const height = cfg.height ?? DEFAULTS.height;
75
+ const pad = DEFAULTS.padding;
76
+ const plot = { left: pad.left, right: width - pad.right, top: pad.top, bottom: height - pad.bottom };
77
+ const [xmin, xmax] = cfg.domain ?? DEFAULTS.domain;
78
+ const samples = Math.max(2, cfg.samples ?? DEFAULTS.samples);
79
+ const varName = cfg.variable ?? DEFAULTS.variable;
80
+ const env = cfg.env ?? defaultEnv();
81
+ const exprs = Array.isArray(cfg.expr) ? cfg.expr : (cfg.expr != null ? [cfg.expr] : []);
82
+
83
+ // sample each series into typed-array buffers (compile once)
84
+ const xs = new Float64Array(samples);
85
+ for (let i = 0; i < samples; i++) xs[i] = remap(i, 0, samples - 1, xmin, xmax);
86
+
87
+ const rawSeries = [];
88
+ for (const src of exprs) {
89
+ let fn;
90
+ try {
91
+ fn = compileExpr(parseExpression(String(src)), { env });
92
+ }
93
+ catch {
94
+ rawSeries.push({ source: String(src), ys: null });
95
+ continue;
96
+ }
97
+ const ys = new Float64Array(samples);
98
+ const scope = { ...(cfg.scope ?? {}) };
99
+ for (let i = 0; i < samples; i++) {
100
+ scope[varName] = xs[i];
101
+ const v = +fn(scope);
102
+ ys[i] = v;
103
+ }
104
+ rawSeries.push({ source: String(src), ys });
105
+ }
106
+
107
+ // auto-range from finite samples
108
+ let [ymin, ymax] = cfg.range ?? [Infinity, -Infinity];
109
+ if (cfg.range === undefined) {
110
+ for (const s of rawSeries) {
111
+ if (s.ys === null) continue;
112
+ for (let i = 0; i < samples; i++) {
113
+ const y = s.ys[i];
114
+ if (Number.isFinite(y)) { if (y < ymin) ymin = y; if (y > ymax) ymax = y; }
115
+ }
116
+ }
117
+ if (!(ymax > ymin)) { ymin = -1; ymax = 1; }
118
+ const margin = (ymax - ymin) * 0.08;
119
+ ymin -= margin; ymax += margin;
120
+ }
121
+
122
+ const toScreen = (x, y) => ({
123
+ x: remap(x, xmin, xmax, plot.left, plot.right),
124
+ y: remap(y, ymin, ymax, plot.bottom, plot.top),
125
+ });
126
+
127
+ // build screen-space points, breaking on non-finite and asymptote jumps
128
+ const series = rawSeries.map((s) => {
129
+ if (s.ys === null) return { source: s.source, points: [], error: true };
130
+ const points = [];
131
+ let prevY = null;
132
+ for (let i = 0; i < samples; i++) {
133
+ const y = s.ys[i];
134
+ if (!Number.isFinite(y)) { points.push(null); prevY = null; continue; }
135
+ const p = toScreen(xs[i], y);
136
+ if (prevY !== null && Math.abs(p.y - prevY) > (plot.bottom - plot.top)) {
137
+ points.push(null); // discontinuity: full-height jump
138
+ }
139
+ points.push(p);
140
+ prevY = p.y;
141
+ }
142
+ return { source: s.source, points, error: false };
143
+ });
144
+
145
+ const xticks = niceTicks(xmin, xmax, 8).map((v) => ({ value: v, x: remap(v, xmin, xmax, plot.left, plot.right) }));
146
+ const yticks = niceTicks(ymin, ymax, 6).map((v) => ({ value: v, y: remap(v, ymin, ymax, plot.bottom, plot.top) }));
147
+ const axisX = (0 >= ymin && 0 <= ymax) ? remap(0, ymin, ymax, plot.bottom, plot.top) : null;
148
+ const axisY = (0 >= xmin && 0 <= xmax) ? remap(0, xmin, xmax, plot.left, plot.right) : null;
149
+
150
+ return {
151
+ kind: '2d',
152
+ width, height, plot,
153
+ domain: [xmin, xmax], range: [ymin, ymax],
154
+ series, xticks, yticks,
155
+ axes: { x: axisX, y: axisY },
156
+ };
157
+ }
158
+
159
+ /**
160
+ * Render a 2D scene into a pure-vnode SVG.
161
+ * @param {any} scene @param {{ theme?: any }} [options]
162
+ * @returns {any}
163
+ */
164
+ export function scene2dToVnode(scene, options = {}) {
165
+ const theme = createTheme(options.theme ?? 'default');
166
+ const t = theme.tokens;
167
+ const children = [];
168
+
169
+ // grid
170
+ for (const tick of scene.xticks) {
171
+ children.push(line(tick.x, scene.plot.top, tick.x, scene.plot.bottom, { stroke: t.grid, 'stroke-width': 1, class: 'calc-grid' }));
172
+ }
173
+ for (const tick of scene.yticks) {
174
+ children.push(line(scene.plot.left, tick.y, scene.plot.right, tick.y, { stroke: t.grid, 'stroke-width': 1, class: 'calc-grid' }));
175
+ }
176
+ // axes
177
+ if (scene.axes.x !== null) {
178
+ children.push(line(scene.plot.left, scene.axes.x, scene.plot.right, scene.axes.x, { stroke: t.axis, 'stroke-width': 1.5, class: 'calc-axis' }));
179
+ }
180
+ if (scene.axes.y !== null) {
181
+ children.push(line(scene.axes.y, scene.plot.top, scene.axes.y, scene.plot.bottom, { stroke: t.axis, 'stroke-width': 1.5, class: 'calc-axis' }));
182
+ }
183
+ // tick labels
184
+ for (const tick of scene.xticks) {
185
+ children.push(textAt(tick.x, scene.plot.bottom + 14, formatTick(tick.value), 10, { fill: t.text, 'text-anchor': 'middle', class: 'calc-tick' }));
186
+ }
187
+ for (const tick of scene.yticks) {
188
+ children.push(textAt(scene.plot.left - 4, tick.y + 3, formatTick(tick.value), 10, { fill: t.text, 'text-anchor': 'end', class: 'calc-tick' }));
189
+ }
190
+ // series polylines
191
+ const colors = [t.series1, t.series2, t.series3];
192
+ scene.series.forEach((s, i) => {
193
+ const d = polylinePath(s.points);
194
+ if (d) children.push(path(d, { stroke: colors[i % colors.length], 'stroke-width': 2, fill: 'none', class: 'calc-series' }));
195
+ });
196
+
197
+ // contentKey stable-stringifies, so two structurally equal scenes
198
+ // share a key regardless of property order (the old JSON.stringify
199
+ // key was insertion-order fragile).
200
+ const key = 'p2:' + contentKey({ d: scene.domain, r: scene.range, s: scene.series.map((s) => s.source) });
201
+ return svgRoot('calc-plot', scene.width, scene.height, theme, children, key);
202
+ }
203
+
204
+ /** @param {number} v */
205
+ function formatTick(v) {
206
+ if (v === 0) return '0';
207
+ const a = Math.abs(v);
208
+ if (a >= 1e4 || a < 1e-3) return v.toExponential(0);
209
+ return String(Float64.roundTo(v, 3));
210
+ }
211
+
212
+ /**
213
+ * Compile → sample → SVG vnode in one call.
214
+ * @param {string|string[]|Plot2dConfig} exprOrConfig
215
+ * @param {Plot2dConfig} [options]
216
+ * @returns {any}
217
+ */
218
+ export function plot2d(exprOrConfig, options = {}) {
219
+ const scene = buildScene2d(exprOrConfig, options);
220
+ return scene2dToVnode(scene, { theme: options.theme });
221
+ }
@@ -0,0 +1,177 @@
1
+ //@ts-check
2
+ /**
3
+ * @file The x·y·z surface plotter. Samples
4
+ * `z = f(x, y)` on an N×N grid into `Vec3f64` model points, rotates them
5
+ * (yaw/pitch) and projects them with the `core` `mat4`/`project.js`
6
+ * kernel, builds quad faces, **depth-sorts back-to-front (painter's
7
+ * algorithm)**, shades each quad by height and emits `<polygon>`s with an
8
+ * optional wireframe. Deterministic and headless → golden-geometry tests.
9
+ */
10
+
11
+ import { Float64, Vec3f64, Mat4, project3dTo2d, remap } from '@jarenjs/core/math';
12
+ import { lerpColor } from '@jarenjs/core/color';
13
+ import { svgRoot, polygon } from '@jarenjs/view/helpers';
14
+ import { parseExpression } from '../parser/index.js';
15
+ import { compileExpr } from '../compile.js';
16
+ import { defaultEnv } from '../env.js';
17
+ import { createTheme } from '../theme.js';
18
+ import { contentKey } from '@jarenjs/core/object';
19
+
20
+ const DEFAULTS = {
21
+ width: 420,
22
+ height: 360,
23
+ grid: 24,
24
+ domainX: [-3, 3],
25
+ domainY: [-3, 3],
26
+ yaw: 0.6,
27
+ pitch: 0.5,
28
+ variables: ['x', 'y'],
29
+ distance: 3.2,
30
+ };
31
+
32
+ /**
33
+ * @typedef {object} Plot3dConfig
34
+ * @property {string} [expr] the z = f(x,y) source
35
+ * @property {[number,number]} [domainX] @property {[number,number]} [domainY]
36
+ * @property {number} [grid] samples per axis
37
+ * @property {number} [yaw] @property {number} [pitch] rotation, radians
38
+ * @property {number} [width] @property {number} [height]
39
+ * @property {[string,string]} [variables] independent variable names
40
+ * @property {any} [scope] @property {any} [env]
41
+ * @property {any} [theme] @property {boolean} [wireframe]
42
+ */
43
+
44
+ /**
45
+ * Build the projected, depth-sorted scene (geometry as plain JSON).
46
+ * @param {string|Plot3dConfig} exprOrConfig
47
+ * @param {Plot3dConfig} [options]
48
+ * @returns {any}
49
+ */
50
+ export function buildScene3d(exprOrConfig, options = {}) {
51
+ /** @type {Plot3dConfig} */
52
+ const cfg = typeof exprOrConfig === 'string'
53
+ ? { ...options, expr: exprOrConfig }
54
+ : { ...options, ...exprOrConfig };
55
+
56
+ const width = cfg.width ?? DEFAULTS.width;
57
+ const height = cfg.height ?? DEFAULTS.height;
58
+ const N = Math.max(2, cfg.grid ?? DEFAULTS.grid);
59
+ const [xmin, xmax] = cfg.domainX ?? DEFAULTS.domainX;
60
+ const [ymin, ymax] = cfg.domainY ?? DEFAULTS.domainY;
61
+ const yaw = cfg.yaw ?? DEFAULTS.yaw;
62
+ const pitch = cfg.pitch ?? DEFAULTS.pitch;
63
+ const [vx, vy] = cfg.variables ?? DEFAULTS.variables;
64
+ const env = cfg.env ?? defaultEnv();
65
+
66
+ let fn;
67
+ try {
68
+ fn = compileExpr(parseExpression(String(cfg.expr ?? '0')), { env });
69
+ }
70
+ catch {
71
+ return { kind: '3d', width, height, quads: [], error: true };
72
+ }
73
+
74
+ // sample z on the grid, tracking z-range for normalization/shading
75
+ const zs = [];
76
+ let zmin = Infinity;
77
+ let zmax = -Infinity;
78
+ const scope = { ...(cfg.scope ?? {}) };
79
+ for (let i = 0; i <= N; i++) {
80
+ const row = [];
81
+ for (let j = 0; j <= N; j++) {
82
+ scope[vx] = remap(i, 0, N, xmin, xmax);
83
+ scope[vy] = remap(j, 0, N, ymin, ymax);
84
+ const z = +fn(scope);
85
+ row.push(z);
86
+ if (Number.isFinite(z)) { if (z < zmin) zmin = z; if (z > zmax) zmax = z; }
87
+ }
88
+ zs.push(row);
89
+ }
90
+ if (!(zmax > zmin)) { zmin = -1; zmax = 1; }
91
+
92
+ // model → view → projection
93
+ const model = Mat4.multiply(Mat4.rotationX(pitch), Mat4.rotationY(yaw));
94
+ const view = Mat4.translation(0, 0, -(cfg.distance ?? DEFAULTS.distance));
95
+ const proj = Mat4.perspective(Math.PI / 3.2, width / height, 0.1, 100);
96
+ const mvp = Mat4.multiply(proj, Mat4.multiply(view, model));
97
+ const viewport = { x: 0, y: 0, width, height };
98
+
99
+ const toModel = (i, j) => {
100
+ const nx = remap(i, 0, N, -1.2, 1.2);
101
+ const ny = remap(j, 0, N, -1.2, 1.2);
102
+ const nz = Number.isFinite(zs[i][j]) ? remap(zs[i][j], zmin, zmax, -0.7, 0.7) : NaN;
103
+ return new Vec3f64(nx, nz, ny); // z-height becomes the vertical (world Y)
104
+ };
105
+
106
+ // project every grid vertex once
107
+ const proj2d = [];
108
+ for (let i = 0; i <= N; i++) {
109
+ const row = [];
110
+ for (let j = 0; j <= N; j++) {
111
+ const m = toModel(i, j);
112
+ row.push(Number.isFinite(m.y) ? project3dTo2d(m, mvp, viewport) : null);
113
+ }
114
+ proj2d.push(row);
115
+ }
116
+
117
+ // build quads with average depth + height shade
118
+ const quads = [];
119
+ for (let i = 0; i < N; i++) {
120
+ for (let j = 0; j < N; j++) {
121
+ const a = proj2d[i][j], b = proj2d[i + 1][j], c = proj2d[i + 1][j + 1], d = proj2d[i][j + 1];
122
+ if (a === null || b === null || c === null || d === null) continue;
123
+ const depth = (a.z + b.z + c.z + d.z) / 4;
124
+ const avgZ = (zs[i][j] + zs[i + 1][j] + zs[i + 1][j + 1] + zs[i][j + 1]) / 4;
125
+ const shade = Float64.clamp(remap(avgZ, zmin, zmax, 0, 1), 0, 1);
126
+ quads.push({
127
+ points: [round(a), round(b), round(c), round(d)],
128
+ depth: Float64.roundTo(depth, 4),
129
+ shade: Float64.roundTo(shade, 3),
130
+ });
131
+ }
132
+ }
133
+ // painter's algorithm: farthest first (larger NDC z = farther)
134
+ quads.sort((p, q) => q.depth - p.depth);
135
+
136
+ return {
137
+ kind: '3d', width, height,
138
+ zrange: [zmin, zmax],
139
+ wireframe: cfg.wireframe ?? true,
140
+ quads,
141
+ };
142
+ }
143
+
144
+ /** @param {{x:number,y:number}} p */
145
+ function round(p) {
146
+ return { x: Float64.roundTo(p.x, 2), y: Float64.roundTo(p.y, 2) };
147
+ }
148
+
149
+ /**
150
+ * Render a 3D scene into pure-vnode SVG.
151
+ * @param {any} scene @param {{ theme?: any }} [options]
152
+ * @returns {any}
153
+ */
154
+ export function scene3dToVnode(scene, options = {}) {
155
+ const theme = createTheme(options.theme ?? 'default');
156
+ const t = theme.tokens;
157
+ const children = scene.quads.map((quad) => polygon(quad.points, {
158
+ fill: lerpColor(t.surfaceLo, t.surfaceHi, quad.shade),
159
+ stroke: scene.wireframe ? t.wire : 'none',
160
+ 'stroke-width': scene.wireframe ? 0.4 : 0,
161
+ 'stroke-linejoin': 'round',
162
+ class: 'calc-face',
163
+ }));
164
+ const key = 'p3:' + contentKey({ z: scene.zrange, n: scene.quads.length, q0: scene.quads[0] });
165
+ return svgRoot('calc-plot', scene.width, scene.height, theme, children, key);
166
+ }
167
+
168
+ /**
169
+ * Compile → sample → project → SVG vnode in one call.
170
+ * @param {string|Plot3dConfig} exprOrConfig
171
+ * @param {Plot3dConfig} [options]
172
+ * @returns {any}
173
+ */
174
+ export function plot3d(exprOrConfig, options = {}) {
175
+ const scene = buildScene3d(exprOrConfig, options);
176
+ return scene3dToVnode(scene, { theme: options.theme });
177
+ }