@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.
- package/README.md +60 -0
- package/dist/types/ast.d.ts +65 -0
- package/dist/types/compile.d.ts +52 -0
- package/dist/types/component/index.d.ts +259 -0
- package/dist/types/component/rates/binance.d.ts +34 -0
- package/dist/types/component/rates/coingecko.d.ts +47 -0
- package/dist/types/component/rates/index.d.ts +84 -0
- package/dist/types/component/rules.d.ts +180 -0
- package/dist/types/component/schema.d.ts +38 -0
- package/dist/types/env.d.ts +24 -0
- package/dist/types/errors.d.ts +18 -0
- package/dist/types/index.d.ts +42 -0
- package/dist/types/modes/converter.d.ts +46 -0
- package/dist/types/modes/financial.d.ts +59 -0
- package/dist/types/modes/index.d.ts +38 -0
- package/dist/types/modes/programmer.d.ts +50 -0
- package/dist/types/modes/scientific.d.ts +28 -0
- package/dist/types/modes/standard.d.ts +32 -0
- package/dist/types/parser/index.d.ts +32 -0
- package/dist/types/plot/plot2d.d.ts +70 -0
- package/dist/types/plot/plot3d.d.ts +68 -0
- package/dist/types/render/error.d.ts +19 -0
- package/dist/types/theme.d.ts +35 -0
- package/dist/types/to-expr.d.ts +13 -0
- package/dist/types/utils.d.ts +9 -0
- package/docs/CALC-FORMAT.md +79 -0
- package/package.json +71 -0
- package/schemas/financial-inputs.schema.json +15 -0
- package/schemas/jaren-calc-ast.schema.json +91 -0
- package/schemas/jaren-calc-state.schema.json +52 -0
- package/src/ast.js +96 -0
- package/src/compile.js +119 -0
- package/src/component/index.js +352 -0
- package/src/component/rates/binance.js +44 -0
- package/src/component/rates/coingecko.js +63 -0
- package/src/component/rates/index.js +116 -0
- package/src/component/rules.js +118 -0
- package/src/component/schema.js +24 -0
- package/src/env.js +163 -0
- package/src/errors.js +23 -0
- package/src/index.js +85 -0
- package/src/modes/converter.js +73 -0
- package/src/modes/financial.js +89 -0
- package/src/modes/index.js +24 -0
- package/src/modes/programmer.js +69 -0
- package/src/modes/scientific.js +31 -0
- package/src/modes/standard.js +39 -0
- package/src/parser/index.js +220 -0
- package/src/plot/plot2d.js +221 -0
- package/src/plot/plot3d.js +177 -0
- package/src/render/error.js +25 -0
- package/src/theme.js +76 -0
- package/src/to-expr.js +84 -0
- package/src/utils.js +11 -0
- 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
|
+
}
|