tweakit 0.6.0 → 0.7.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/AGENTS.md +5 -5
- package/README.md +3 -3
- package/dist/tweaks/auto.js +1 -1
- package/dist/tweaks/auto.js.map +1 -1
- package/dist/tweaks/bezier-S2BOXQBM.js +2 -0
- package/dist/tweaks/bezier-S2BOXQBM.js.map +6 -0
- package/dist/tweaks/chunk-3MZAAMC2.js +2 -0
- package/dist/tweaks/chunk-3MZAAMC2.js.map +6 -0
- package/dist/tweaks/chunk-ETB35KFV.js +2 -0
- package/dist/tweaks/chunk-ETB35KFV.js.map +6 -0
- package/dist/tweaks/chunk-YB5GKWZI.js +2 -0
- package/dist/tweaks/chunk-YB5GKWZI.js.map +6 -0
- package/dist/tweaks/clip-A3VI53BK.js +2 -0
- package/dist/tweaks/clip-A3VI53BK.js.map +6 -0
- package/dist/tweaks/color-UGYMAF3W.js +2 -0
- package/dist/tweaks/core.js +1 -1
- package/dist/tweaks/core.js.map +3 -3
- package/dist/tweaks/fold-GY47HIQA.js +2 -0
- package/dist/tweaks/fold-GY47HIQA.js.map +6 -0
- package/dist/tweaks/gradient-BTX25WSH.js +2 -0
- package/dist/tweaks/gradient-BTX25WSH.js.map +6 -0
- package/dist/tweaks/{heavy-CLD7UAVI.js → heavy-NPFIGHRM.js} +2 -2
- package/dist/tweaks/image-HTQQ4WX6.js +4 -0
- package/dist/tweaks/image-HTQQ4WX6.js.map +6 -0
- package/dist/tweaks/{image-store-WRKY2NPU.js → image-store-QD5SMZXG.js} +1 -1
- package/dist/tweaks/image-store-QD5SMZXG.js.map +6 -0
- package/dist/tweaks/interval-BHPI4LRN.js +2 -0
- package/dist/tweaks/interval-BHPI4LRN.js.map +6 -0
- package/dist/tweaks/markup-QM53TQJZ.js +2 -0
- package/dist/tweaks/markup-QM53TQJZ.js.map +6 -0
- package/dist/tweaks/monitor-722WIYGW.js +3 -0
- package/dist/tweaks/monitor-722WIYGW.js.map +6 -0
- package/dist/tweaks/motion-HMCZK6HJ.js +2 -0
- package/dist/tweaks/motion-HMCZK6HJ.js.map +6 -0
- package/dist/tweaks/plot-D3NQ6XNN.js +3 -0
- package/dist/tweaks/plot-D3NQ6XNN.js.map +6 -0
- package/dist/tweaks/point-XDXKMDXH.js +2 -0
- package/dist/tweaks/point-XDXKMDXH.js.map +6 -0
- package/dist/tweaks/presets-EUWNSCUW.js +2 -0
- package/dist/tweaks/presets-EUWNSCUW.js.map +6 -0
- package/dist/tweaks/shadow-AM7VSRNS.js +2 -0
- package/dist/tweaks/shadow-AM7VSRNS.js.map +6 -0
- package/dist/tweaks/shadow-root-RYUV3AGS.js +3 -0
- package/dist/tweaks/shadow-root-RYUV3AGS.js.map +6 -0
- package/dist/tweaks/sides-JWIURDA4.js +2 -0
- package/dist/tweaks/sides-JWIURDA4.js.map +6 -0
- package/dist/tweaks/spring-BF5GBTPM.js +2 -0
- package/dist/tweaks/spring-BF5GBTPM.js.map +6 -0
- package/dist/tweaks/tabs-PRVUUZXZ.js +2 -0
- package/dist/tweaks/tabs-PRVUUZXZ.js.map +6 -0
- package/dist/tweaks.css +1 -1
- package/dist/tweaks.js +5 -4
- package/dist/tweaks.js.map +3 -3
- package/dist/types/tweaks/enhance.d.ts +3 -2
- package/dist/types/tweaks/types.d.ts +278 -72
- package/package.json +5 -2
- package/src/tweaks/auto.ts +9 -5
- package/src/tweaks/clip.ts +84 -22
- package/src/tweaks/controls/basic.ts +463 -139
- package/src/tweaks/controls/bezier.ts +152 -47
- package/src/tweaks/controls/color.ts +610 -161
- package/src/tweaks/controls/gradient.ts +256 -77
- package/src/tweaks/controls/image.ts +388 -132
- package/src/tweaks/controls/interval.ts +126 -41
- package/src/tweaks/controls/monitor.ts +142 -36
- package/src/tweaks/controls/motion.ts +231 -56
- package/src/tweaks/controls/plot.ts +321 -101
- package/src/tweaks/controls/point.ts +134 -45
- package/src/tweaks/controls/shadow.ts +103 -30
- package/src/tweaks/controls/sides.ts +150 -40
- package/src/tweaks/controls/spring.ts +220 -60
- package/src/tweaks/controls/tabs.ts +39 -18
- package/src/tweaks/core.ts +1 -1
- package/src/tweaks/easing.ts +45 -20
- package/src/tweaks/enhance.ts +21 -6
- package/src/tweaks/feedback.ts +133 -34
- package/src/tweaks/fold.ts +22 -10
- package/src/tweaks/heavy.ts +461 -137
- package/src/tweaks/icons.ts +12 -5
- package/src/tweaks/image-store.ts +131 -43
- package/src/tweaks/lazy.ts +36 -19
- package/src/tweaks/markup.ts +255 -92
- package/src/tweaks/panel.ts +1262 -438
- package/src/tweaks/presets.ts +85 -0
- package/src/tweaks/schema.ts +213 -51
- package/src/tweaks/shadow-root.ts +178 -0
- package/src/tweaks/shared.ts +802 -190
- package/src/tweaks/standalone.ts +120 -30
- package/src/tweaks/types.ts +644 -143
- package/src/tweaks/undo.ts +104 -0
- package/src/tweaks.css +556 -550
- package/src/wide-gamut.ts +473 -125
- package/dist/tweaks/bezier-JFEZMCLP.js +0 -2
- package/dist/tweaks/bezier-JFEZMCLP.js.map +0 -6
- package/dist/tweaks/chunk-6UO4DHBN.js +0 -2
- package/dist/tweaks/chunk-6UO4DHBN.js.map +0 -6
- package/dist/tweaks/chunk-DIQMLBAS.js +0 -2
- package/dist/tweaks/chunk-DIQMLBAS.js.map +0 -6
- package/dist/tweaks/chunk-XE6OUJBG.js +0 -2
- package/dist/tweaks/chunk-XE6OUJBG.js.map +0 -6
- package/dist/tweaks/clip-WW7JYO4X.js +0 -2
- package/dist/tweaks/clip-WW7JYO4X.js.map +0 -6
- package/dist/tweaks/color-YMMFURP6.js +0 -2
- package/dist/tweaks/fold-U6YRZLCJ.js +0 -2
- package/dist/tweaks/fold-U6YRZLCJ.js.map +0 -6
- package/dist/tweaks/gradient-JER7PW4D.js +0 -2
- package/dist/tweaks/gradient-JER7PW4D.js.map +0 -6
- package/dist/tweaks/image-H6QSLGO3.js +0 -4
- package/dist/tweaks/image-H6QSLGO3.js.map +0 -6
- package/dist/tweaks/image-store-WRKY2NPU.js.map +0 -6
- package/dist/tweaks/interval-TEUHQGZN.js +0 -2
- package/dist/tweaks/interval-TEUHQGZN.js.map +0 -6
- package/dist/tweaks/markup-NIBJD6E2.js +0 -2
- package/dist/tweaks/markup-NIBJD6E2.js.map +0 -6
- package/dist/tweaks/monitor-3BY7YCGX.js +0 -3
- package/dist/tweaks/monitor-3BY7YCGX.js.map +0 -6
- package/dist/tweaks/motion-FQPUBKKM.js +0 -2
- package/dist/tweaks/motion-FQPUBKKM.js.map +0 -6
- package/dist/tweaks/plot-2HBNBZSG.js +0 -3
- package/dist/tweaks/plot-2HBNBZSG.js.map +0 -6
- package/dist/tweaks/point-VV6XV3I6.js +0 -2
- package/dist/tweaks/point-VV6XV3I6.js.map +0 -6
- package/dist/tweaks/shadow-5E4E55I2.js +0 -2
- package/dist/tweaks/shadow-5E4E55I2.js.map +0 -6
- package/dist/tweaks/sides-P3STEVYY.js +0 -2
- package/dist/tweaks/sides-P3STEVYY.js.map +0 -6
- package/dist/tweaks/spring-HUVSXNVN.js +0 -2
- package/dist/tweaks/spring-HUVSXNVN.js.map +0 -6
- package/dist/tweaks/tabs-76FW7LQ7.js +0 -2
- package/dist/tweaks/tabs-76FW7LQ7.js.map +0 -6
- /package/dist/tweaks/{color-YMMFURP6.js.map → color-UGYMAF3W.js.map} +0 -0
- /package/dist/tweaks/{heavy-CLD7UAVI.js.map → heavy-NPFIGHRM.js.map} +0 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// ── Plot — graph y=f(x) with a safe expression evaluator (compileExpr). Lazy.
|
|
2
|
-
import { el, txt, clamp, onReady, onLive, quietFocus, registerControl } from "../shared.js";
|
|
2
|
+
import { el, txt, clamp, onReady, onLive, quietFocus, registerControl, words } from "../shared.js";
|
|
3
3
|
import { svgEl } from "../heavy.js";
|
|
4
4
|
import type { OnChange, Built } from "../shared.js";
|
|
5
5
|
import type { Meta } from "../schema.js";
|
|
@@ -13,105 +13,224 @@ type Tok = { t: "num"; v: number } | { t: "id" | "op"; v: string } | { t: "lp" |
|
|
|
13
13
|
// ── A safe expression evaluator: recursive descent, not eval/Function, so a typed formula does only arithmetic and
|
|
14
14
|
// whitelisted Math calls. Null-prototype tables, so "constructor" can't parse as an identifier. ──
|
|
15
15
|
const PLOT_FUNCS: Record<string, (...args: number[]) => number> = Object.assign(Object.create(null), {
|
|
16
|
-
sin: Math.sin,
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
16
|
+
sin: Math.sin,
|
|
17
|
+
cos: Math.cos,
|
|
18
|
+
tan: Math.tan,
|
|
19
|
+
asin: Math.asin,
|
|
20
|
+
acos: Math.acos,
|
|
21
|
+
atan: Math.atan,
|
|
22
|
+
atan2: Math.atan2,
|
|
23
|
+
sinh: Math.sinh,
|
|
24
|
+
cosh: Math.cosh,
|
|
25
|
+
tanh: Math.tanh,
|
|
26
|
+
sqrt: Math.sqrt,
|
|
27
|
+
cbrt: Math.cbrt,
|
|
28
|
+
abs: Math.abs,
|
|
29
|
+
exp: Math.exp,
|
|
30
|
+
log: Math.log,
|
|
31
|
+
ln: Math.log,
|
|
32
|
+
log10: Math.log10,
|
|
33
|
+
log2: Math.log2,
|
|
34
|
+
floor: Math.floor,
|
|
35
|
+
ceil: Math.ceil,
|
|
36
|
+
round: Math.round,
|
|
37
|
+
sign: Math.sign,
|
|
38
|
+
trunc: Math.trunc,
|
|
39
|
+
min: Math.min,
|
|
40
|
+
max: Math.max,
|
|
41
|
+
pow: Math.pow,
|
|
42
|
+
hypot: Math.hypot,
|
|
43
|
+
mod: (a: number, b: number) => a % b,
|
|
44
|
+
clamp: (v: number, lo: number, hi: number) => Math.min(Math.max(v, lo), hi),
|
|
45
|
+
});
|
|
46
|
+
// called empty these return a number (Infinity, 0), so a slip drew nothing and flagged nothing
|
|
47
|
+
const PLOT_VARIADIC = ["min", "max", "hypot"];
|
|
48
|
+
const PLOT_CONSTS: Record<string, number> = Object.assign(Object.create(null), {
|
|
49
|
+
pi: Math.PI,
|
|
50
|
+
e: Math.E,
|
|
51
|
+
tau: Math.PI * 2,
|
|
52
|
+
phi: (1 + Math.sqrt(5)) / 2,
|
|
22
53
|
});
|
|
23
|
-
const PLOT_VARIADIC = ["min", "max", "hypot"]; // called empty these return a number (Infinity, 0), so a slip drew nothing and flagged nothing
|
|
24
|
-
const PLOT_CONSTS: Record<string, number> = Object.assign(Object.create(null), { pi: Math.PI, e: Math.E, tau: Math.PI * 2, phi: (1 + Math.sqrt(5)) / 2 });
|
|
25
54
|
/** The compiled expression, or null on any parse error. */
|
|
26
55
|
export function compileExpr(src: unknown): Expr | null {
|
|
27
|
-
const r = parseExpr(src);
|
|
56
|
+
const r = parseExpr(src);
|
|
57
|
+
return typeof r === "function" ? r : null;
|
|
28
58
|
}
|
|
29
59
|
/** The compiled expression, or why it doesn't parse; the reason names the fix where there is one ("write sin(…)"). */
|
|
30
60
|
export function parseExpr(src: unknown): Expr | string {
|
|
31
61
|
if (typeof src !== "string" || !src.trim()) return "type an expression in x";
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
62
|
+
// also bounds recursion depth, so no RangeError mid-parse
|
|
63
|
+
if (src.length > 512) return "too long: 512 characters at most";
|
|
64
|
+
const s = src,
|
|
65
|
+
toks: Tok[] = [];
|
|
66
|
+
// `5.` reads as 5, as `.5` reads as 0.5. An e is an exponent only with a digit right after it (and its sign): `2e+1`
|
|
67
|
+
// is 20, while `2e+x` is the constant, 2·e + x, as `2e` alone is 2·e. Scientific notation never has a name for its
|
|
68
|
+
// power.
|
|
69
|
+
const numRe = /(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?/y,
|
|
70
|
+
idRe = /[A-Za-z_]\w*/y;
|
|
37
71
|
let i = 0;
|
|
38
72
|
while (i < s.length) {
|
|
39
73
|
const ch = s[i];
|
|
40
|
-
if (ch === " " || ch === "\t" || ch === "\n") {
|
|
74
|
+
if (ch === " " || ch === "\t" || ch === "\n") {
|
|
75
|
+
i++;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
41
78
|
if ((ch >= "0" && ch <= "9") || (ch === "." && s[i + 1] >= "0" && s[i + 1] <= "9")) {
|
|
42
|
-
numRe.lastIndex = i;
|
|
43
|
-
|
|
79
|
+
numRe.lastIndex = i;
|
|
80
|
+
const m = numRe.exec(s);
|
|
81
|
+
if (!m || m.index !== i) return `unexpected "${ch}"`;
|
|
82
|
+
toks.push({ t: "num", v: parseFloat(m[0]) });
|
|
83
|
+
i = numRe.lastIndex;
|
|
84
|
+
continue;
|
|
44
85
|
}
|
|
45
86
|
if ((ch >= "A" && ch <= "Z") || (ch >= "a" && ch <= "z") || ch === "_") {
|
|
46
|
-
idRe.lastIndex = i;
|
|
87
|
+
idRe.lastIndex = i;
|
|
88
|
+
const m = idRe.exec(s);
|
|
89
|
+
toks.push({ t: "id", v: m[0] });
|
|
90
|
+
i = idRe.lastIndex;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (ch === "*" && s[i + 1] === "*") {
|
|
94
|
+
toks.push({ t: "op", v: "^" });
|
|
95
|
+
i += 2;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if ("+-*/%^".includes(ch)) {
|
|
99
|
+
toks.push({ t: "op", v: ch });
|
|
100
|
+
i++;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (ch === "(") {
|
|
104
|
+
toks.push({ t: "lp" });
|
|
105
|
+
i++;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (ch === ")") {
|
|
109
|
+
toks.push({ t: "rp" });
|
|
110
|
+
i++;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (ch === ",") {
|
|
114
|
+
toks.push({ t: "comma" });
|
|
115
|
+
i++;
|
|
116
|
+
continue;
|
|
47
117
|
}
|
|
48
|
-
if (ch === "*" && s[i + 1] === "*") { toks.push({ t: "op", v: "^" }); i += 2; continue; }
|
|
49
|
-
if ("+-*/%^".includes(ch)) { toks.push({ t: "op", v: ch }); i++; continue; }
|
|
50
|
-
if (ch === "(") { toks.push({ t: "lp" }); i++; continue; }
|
|
51
|
-
if (ch === ")") { toks.push({ t: "rp" }); i++; continue; }
|
|
52
|
-
if (ch === ",") { toks.push({ t: "comma" }); i++; continue; }
|
|
53
118
|
return `unexpected "${ch}"`;
|
|
54
119
|
}
|
|
55
|
-
let p = 0,
|
|
56
|
-
|
|
120
|
+
let p = 0,
|
|
121
|
+
why = "";
|
|
122
|
+
// the innermost reason wins; every caller unwinds on null
|
|
123
|
+
const fail = (m: string): null => {
|
|
124
|
+
why = why || m;
|
|
125
|
+
return null;
|
|
126
|
+
};
|
|
57
127
|
const peek = () => toks[p];
|
|
58
|
-
const eat = (t?: Tok["t"]) => {
|
|
128
|
+
const eat = (t?: Tok["t"]) => {
|
|
129
|
+
const tok = toks[p];
|
|
130
|
+
if (!tok || (t && tok.t !== t)) return null;
|
|
131
|
+
p++;
|
|
132
|
+
return tok;
|
|
133
|
+
};
|
|
59
134
|
const add = (): Expr | null => {
|
|
60
|
-
let a = mul();
|
|
135
|
+
let a = mul();
|
|
136
|
+
if (!a) return null;
|
|
61
137
|
while (peek() && peek().t === "op" && (peek().v === "+" || peek().v === "-")) {
|
|
62
|
-
const op = eat("op").v,
|
|
138
|
+
const op = eat("op").v,
|
|
139
|
+
b = mul();
|
|
140
|
+
if (!b) return null;
|
|
141
|
+
const aa = a;
|
|
63
142
|
a = op === "+" ? (x) => aa(x) + b(x) : (x) => aa(x) - b(x);
|
|
64
143
|
}
|
|
65
144
|
return a;
|
|
66
145
|
};
|
|
67
146
|
const mul = (): Expr | null => {
|
|
68
|
-
let a = unary();
|
|
147
|
+
let a = unary();
|
|
148
|
+
if (!a) return null;
|
|
69
149
|
for (let t = peek(); t; t = peek()) {
|
|
70
|
-
// `2x`, `2pi`, `3(x+1)`, `pi x`, `3x(x)`, `(x)(x)`, `sin(x)cos(x)`: a number, x, a constant or ")" directly
|
|
71
|
-
// a name or "(" multiplies, as on paper. x and the constants are never functions, so `x(2)` is 2x; `xy`
|
|
72
|
-
// (unknown) name, and a number after an operand (`2 3`, `(x)2`) asks for an operator. It binds as `*`
|
|
73
|
-
|
|
150
|
+
// `2x`, `2pi`, `3(x+1)`, `pi x`, `3x(x)`, `(x)(x)`, `sin(x)cos(x)`: a number, x, a constant or ")" directly
|
|
151
|
+
// before a name or "(" multiplies, as on paper. x and the constants are never functions, so `x(2)` is 2x; `xy`
|
|
152
|
+
// stays one (unknown) name, and a number after an operand (`2 3`, `(x)2`) asks for an operator. It binds as `*`
|
|
153
|
+
// does: `2x^2` is 2·x².
|
|
154
|
+
const prev = toks[p - 1].t,
|
|
155
|
+
implied = (prev === "num" || prev === "id" || prev === "rp") && (t.t === "id" || t.t === "lp");
|
|
74
156
|
if (!implied && !(t.t === "op" && (t.v === "*" || t.v === "/" || t.v === "%"))) break;
|
|
75
|
-
const op = implied ? "*" : eat("op").v,
|
|
157
|
+
const op = implied ? "*" : eat("op").v,
|
|
158
|
+
b = unary();
|
|
159
|
+
if (!b) return null;
|
|
160
|
+
const aa = a;
|
|
76
161
|
a = op === "*" ? (x) => aa(x) * b(x) : op === "/" ? (x) => aa(x) / b(x) : (x) => aa(x) % b(x);
|
|
77
162
|
}
|
|
78
163
|
return a;
|
|
79
164
|
};
|
|
80
165
|
const unary = (): Expr | null => {
|
|
81
166
|
if (peek() && peek().t === "op" && (peek().v === "-" || peek().v === "+")) {
|
|
82
|
-
const op = eat("op").v,
|
|
167
|
+
const op = eat("op").v,
|
|
168
|
+
a = unary();
|
|
169
|
+
if (!a) return null;
|
|
83
170
|
return op === "-" ? (x) => -a(x) : a;
|
|
84
171
|
}
|
|
85
172
|
return pow();
|
|
86
173
|
};
|
|
87
174
|
const pow = (): Expr | null => {
|
|
88
|
-
const a = atom();
|
|
89
|
-
if (
|
|
175
|
+
const a = atom();
|
|
176
|
+
if (!a) return null;
|
|
177
|
+
if (peek() && peek().t === "op" && peek().v === "^") {
|
|
178
|
+
eat("op");
|
|
179
|
+
const b = unary();
|
|
180
|
+
if (!b) return null;
|
|
181
|
+
return (x) => Math.pow(a(x), b(x));
|
|
182
|
+
}
|
|
90
183
|
return a;
|
|
91
184
|
};
|
|
92
185
|
const atom = (): Expr | null => {
|
|
93
|
-
const tok = peek();
|
|
94
|
-
if (tok
|
|
95
|
-
if (tok.t === "
|
|
186
|
+
const tok = peek();
|
|
187
|
+
if (!tok) return null;
|
|
188
|
+
if (tok.t === "num") {
|
|
189
|
+
eat("num");
|
|
190
|
+
return () => tok.v;
|
|
191
|
+
}
|
|
192
|
+
if (tok.t === "lp") {
|
|
193
|
+
eat("lp");
|
|
194
|
+
const e = add();
|
|
195
|
+
if (!e || !eat("rp")) return null;
|
|
196
|
+
return e;
|
|
197
|
+
}
|
|
96
198
|
if (tok.t === "id") {
|
|
97
|
-
eat("id");
|
|
98
|
-
|
|
199
|
+
eat("id");
|
|
200
|
+
const name = tok.v.toLowerCase(),
|
|
201
|
+
fn = PLOT_FUNCS[name];
|
|
202
|
+
// `x(2)`: a product, in mul()
|
|
203
|
+
if (peek() && peek().t === "lp" && (fn || (name !== "x" && !(name in PLOT_CONSTS)))) {
|
|
99
204
|
if (!fn) return fail(`unknown function "${tok.v}"`);
|
|
100
|
-
eat("lp");
|
|
101
|
-
|
|
205
|
+
eat("lp");
|
|
206
|
+
const argv: Expr[] = [];
|
|
207
|
+
if (peek() && peek().t !== "rp") {
|
|
208
|
+
do {
|
|
209
|
+
const a = add();
|
|
210
|
+
if (!a) return null;
|
|
211
|
+
argv.push(a);
|
|
212
|
+
} while (eat("comma"));
|
|
213
|
+
}
|
|
102
214
|
if (!eat("rp")) return null;
|
|
103
215
|
if (!argv.length && PLOT_VARIADIC.includes(name)) return fail(`${name}() needs at least one value`);
|
|
104
216
|
return (x) => fn(...argv.map((a) => a(x)));
|
|
105
217
|
}
|
|
106
218
|
if (fn) {
|
|
107
|
-
// `sin x`, `cos 2x`: a one-argument function takes a bare x, constant or number-times-name, as a calculator
|
|
108
|
-
//
|
|
109
|
-
|
|
110
|
-
|
|
219
|
+
// `sin x`, `cos 2x`: a one-argument function takes a bare x, constant or number-times-name, as a calculator
|
|
220
|
+
// does.
|
|
221
|
+
// Anything longer (`sin x^2`, `sin -x`, `sin 2 cos x`) reads two ways, so it asks for the parentheses instead
|
|
222
|
+
// of guessing.
|
|
223
|
+
const a = fn.length === 1 ? bare() : null,
|
|
224
|
+
n = peek();
|
|
225
|
+
if (!a || (n && (n.t === "op" ? n.v === "^" : n.t !== "rp" && n.t !== "comma")))
|
|
226
|
+
return fail(`write ${name}(…)`);
|
|
111
227
|
return (x) => fn(a(x));
|
|
112
228
|
}
|
|
113
229
|
if (name === "x") return (x) => x;
|
|
114
|
-
if (name in PLOT_CONSTS) {
|
|
230
|
+
if (name in PLOT_CONSTS) {
|
|
231
|
+
const c = PLOT_CONSTS[name];
|
|
232
|
+
return () => c;
|
|
233
|
+
}
|
|
115
234
|
return fail(`unknown name "${tok.v}"`); // an identifier that isn't x, a constant, or a known function
|
|
116
235
|
}
|
|
117
236
|
return null;
|
|
@@ -119,102 +238,203 @@ export function parseExpr(src: unknown): Expr | string {
|
|
|
119
238
|
const named = (t: Tok) => t && t.t === "id" && !PLOT_FUNCS[t.v.toLowerCase()];
|
|
120
239
|
const bare = (): Expr | null => {
|
|
121
240
|
const t = peek();
|
|
122
|
-
if (t && t.t === "num") {
|
|
241
|
+
if (t && t.t === "num") {
|
|
242
|
+
eat("num");
|
|
243
|
+
if (!named(peek())) return () => t.v;
|
|
244
|
+
const b = atom();
|
|
245
|
+
return b && ((x) => t.v * b(x));
|
|
246
|
+
}
|
|
123
247
|
return named(t) ? atom() : null;
|
|
124
248
|
};
|
|
125
|
-
const tree = add(),
|
|
249
|
+
const tree = add(),
|
|
250
|
+
left = toks[p];
|
|
126
251
|
if (tree && !left) return tree;
|
|
127
252
|
if (why) return why;
|
|
128
253
|
// A parse that stopped short, or tokens left over: say where.
|
|
129
|
-
const last = toks[p - 1],
|
|
254
|
+
const last = toks[p - 1],
|
|
255
|
+
unclosed = toks.slice(0, p).reduce((d, t) => d + (t.t === "lp" ? 1 : t.t === "rp" ? -1 : 0), 0);
|
|
130
256
|
if (!tree && last && last.t === "op") return `"${last.v}" needs a value on each side`;
|
|
131
257
|
if (!left) return unclosed > 0 ? 'missing ")"' : "not a valid expression";
|
|
132
|
-
return left.t === "num"
|
|
133
|
-
|
|
134
|
-
: left.t === "
|
|
135
|
-
|
|
258
|
+
return left.t === "num"
|
|
259
|
+
? `missing an operator before ${left.v}`
|
|
260
|
+
: left.t === "rp"
|
|
261
|
+
? last && last.t === "lp"
|
|
262
|
+
? "nothing inside the parentheses"
|
|
263
|
+
: 'unmatched ")"'
|
|
264
|
+
: left.t === "comma"
|
|
265
|
+
? '"," outside a function\'s parentheses'
|
|
266
|
+
: left.t === "op"
|
|
267
|
+
? `"${left.v}" needs a value on each side`
|
|
268
|
+
: "not a valid expression";
|
|
136
269
|
}
|
|
137
270
|
|
|
138
271
|
// ── Plot — y = f(x) across a range with an editable expression, or a fixed `fn`. Y auto-ranges unless yMin/yMax are
|
|
139
272
|
// given; the line breaks across asymptotes. ──
|
|
140
273
|
function createPlot(meta: Meta, onChange: OnChange): Control {
|
|
141
|
-
|
|
142
|
-
|
|
274
|
+
const tr = words();
|
|
275
|
+
// Non-finite bounds take the defaults, an inverted pair swaps, an equal pair pads out (÷0 drew nothing); a degenerate
|
|
276
|
+
// y-range falls back to auto.
|
|
277
|
+
// min/max are the verbose form's aliases
|
|
278
|
+
let xMin = Number(meta.xMin ?? meta.min),
|
|
279
|
+
xMax = Number(meta.xMax ?? meta.max);
|
|
143
280
|
if (!Number.isFinite(xMin)) xMin = -10;
|
|
144
281
|
if (!Number.isFinite(xMax)) xMax = 10;
|
|
145
|
-
if (xMax < xMin) {
|
|
146
|
-
|
|
147
|
-
|
|
282
|
+
if (xMax < xMin) {
|
|
283
|
+
const t = xMin;
|
|
284
|
+
xMin = xMax;
|
|
285
|
+
xMax = t;
|
|
286
|
+
}
|
|
287
|
+
if (xMax === xMin) {
|
|
288
|
+
xMin -= 1;
|
|
289
|
+
xMax += 1;
|
|
290
|
+
}
|
|
291
|
+
// capped: 1e8 samples per keystroke froze the tab
|
|
292
|
+
const samples = Math.max(2, Math.min(4096, meta.samples | 0 || 256));
|
|
148
293
|
const fixedY = Number.isFinite(meta.yMin) && Number.isFinite(meta.yMax) && +meta.yMax > +meta.yMin;
|
|
149
294
|
const fn = typeof meta.fn === "function" ? meta.fn : null;
|
|
150
295
|
const editable = meta.editable !== false && !fn;
|
|
151
296
|
let expr = meta.expr != null ? String(meta.expr) : fn ? "" : "sin(x)"; // a sine to start from
|
|
152
297
|
let compiled = fn || parseExpr(expr); // a string is the reason it doesn't parse
|
|
153
298
|
|
|
154
|
-
const root = el("div", "
|
|
155
|
-
if (meta.label) root.append(txt("div", "
|
|
156
|
-
const graph = el("div", "
|
|
157
|
-
const svg = svgEl("svg", "
|
|
158
|
-
|
|
159
|
-
|
|
299
|
+
const root = el("div", "twk-plot");
|
|
300
|
+
if (meta.label) root.append(txt("div", "twk-plot-label", meta.label));
|
|
301
|
+
const graph = el("div", "twk-plot-graph");
|
|
302
|
+
const svg = svgEl("svg", "twk-plot-svg");
|
|
303
|
+
svg.setAttribute("preserveAspectRatio", "none");
|
|
304
|
+
const axisX = svgEl("line", "twk-plot-axis"),
|
|
305
|
+
axisY = svgEl("line", "twk-plot-axis"),
|
|
306
|
+
curve = svgEl("path", "twk-plot-curve");
|
|
307
|
+
svg.append(axisX, axisY, curve);
|
|
308
|
+
graph.append(svg);
|
|
309
|
+
root.append(graph);
|
|
160
310
|
|
|
161
311
|
let input: HTMLInputElement | null = null;
|
|
162
312
|
if (editable) {
|
|
163
|
-
const field = el("div", "
|
|
164
|
-
input = el("input", "
|
|
165
|
-
input.
|
|
166
|
-
|
|
313
|
+
const field = el("div", "twk-plot-field");
|
|
314
|
+
input = el("input", "twk-plot-input");
|
|
315
|
+
input.type = "text";
|
|
316
|
+
input.value = expr;
|
|
317
|
+
input.spellcheck = false;
|
|
318
|
+
input.autocapitalize = "off";
|
|
319
|
+
quietFocus(input);
|
|
320
|
+
/* click-to-edit stays ringless; Tab rings the field */ input.setAttribute(
|
|
321
|
+
"aria-label",
|
|
322
|
+
tr("expression", "{label} — expression in x", { label: meta.label || tr("plot", "Plot") }),
|
|
323
|
+
);
|
|
324
|
+
input.setAttribute("aria-invalid", "false");
|
|
325
|
+
// txt, not el(…, html): el's third arg is innerHTML
|
|
326
|
+
field.append(txt("span", "twk-plot-fx", "y ="), input);
|
|
327
|
+
root.append(field);
|
|
167
328
|
}
|
|
168
329
|
|
|
169
330
|
const PAD = 6;
|
|
170
331
|
const draw = () => {
|
|
171
332
|
// Flag invalid before the size guard, so a hidden tab page still reports a bad expression.
|
|
172
|
-
const f = typeof compiled === "function" ? compiled : null,
|
|
333
|
+
const f = typeof compiled === "function" ? compiled : null,
|
|
334
|
+
ok = !!f;
|
|
173
335
|
root.classList.toggle("is-invalid", editable && !ok);
|
|
174
|
-
|
|
175
|
-
|
|
336
|
+
// the title says why, and names the fix
|
|
337
|
+
if (input) {
|
|
338
|
+
input.setAttribute("aria-invalid", ok ? "false" : "true");
|
|
339
|
+
input.title = ok ? "" : String(compiled);
|
|
340
|
+
}
|
|
341
|
+
const r = graph.getBoundingClientRect(),
|
|
342
|
+
W = r.width,
|
|
343
|
+
H = r.height;
|
|
344
|
+
if (W < 2) return;
|
|
176
345
|
svg.setAttribute("viewBox", `0 0 ${W} ${H}`);
|
|
177
|
-
const ys: number[] = [];
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
346
|
+
const ys: number[] = [];
|
|
347
|
+
let yLo = Infinity,
|
|
348
|
+
yHi = -Infinity;
|
|
349
|
+
if (ok)
|
|
350
|
+
for (let k = 0; k < samples; k++) {
|
|
351
|
+
const x = xMin + (xMax - xMin) * (k / (samples - 1));
|
|
352
|
+
let y: number;
|
|
353
|
+
try {
|
|
354
|
+
y = f(x);
|
|
355
|
+
} catch {
|
|
356
|
+
y = NaN;
|
|
357
|
+
}
|
|
358
|
+
ys.push(y);
|
|
359
|
+
if (Number.isFinite(y)) {
|
|
360
|
+
if (y < yLo) yLo = y;
|
|
361
|
+
if (y > yHi) yHi = y;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
let lo = meta.yMin,
|
|
365
|
+
hi = meta.yMax;
|
|
185
366
|
if (!fixedY) {
|
|
186
|
-
if (!Number.isFinite(yLo) || yLo === yHi) {
|
|
187
|
-
|
|
367
|
+
if (!Number.isFinite(yLo) || yLo === yHi) {
|
|
368
|
+
const c = Number.isFinite(yLo) ? yLo : 0;
|
|
369
|
+
lo = c - 1;
|
|
370
|
+
hi = c + 1;
|
|
371
|
+
} else {
|
|
372
|
+
const pad = (yHi - yLo) * 0.12;
|
|
373
|
+
lo = yLo - pad;
|
|
374
|
+
hi = yHi + pad;
|
|
375
|
+
}
|
|
188
376
|
}
|
|
189
377
|
const xPx = (x: number) => PAD + ((x - xMin) / (xMax - xMin)) * (W - 2 * PAD);
|
|
190
|
-
const yPx = (y: number) =>
|
|
191
|
-
const axis = (ln: Built<SVGLineElement>, ax: number, ay: number, bx: number, by: number, show: boolean) => {
|
|
378
|
+
const yPx = (y: number) => H - PAD - ((y - lo) / (hi - lo)) * (H - 2 * PAD);
|
|
379
|
+
const axis = (ln: Built<SVGLineElement>, ax: number, ay: number, bx: number, by: number, show: boolean) => {
|
|
380
|
+
ln.style.display = show ? "" : "none";
|
|
381
|
+
if (show) {
|
|
382
|
+
ln.setAttribute("x1", ax);
|
|
383
|
+
ln.setAttribute("y1", ay);
|
|
384
|
+
ln.setAttribute("x2", bx);
|
|
385
|
+
ln.setAttribute("y2", by);
|
|
386
|
+
}
|
|
387
|
+
};
|
|
192
388
|
axis(axisX, PAD, yPx(0), W - PAD, yPx(0), lo <= 0 && hi >= 0);
|
|
193
389
|
axis(axisY, xPx(0), PAD, xPx(0), H - PAD, xMin <= 0 && xMax >= 0);
|
|
194
|
-
let d = "",
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
390
|
+
let d = "",
|
|
391
|
+
pen = false;
|
|
392
|
+
if (ok)
|
|
393
|
+
for (let k = 0; k < samples; k++) {
|
|
394
|
+
const y = ys[k];
|
|
395
|
+
if (!Number.isFinite(y)) {
|
|
396
|
+
pen = false;
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
const px = xPx(xMin + (xMax - xMin) * (k / (samples - 1))),
|
|
400
|
+
py = clamp(yPx(y), -1e4, 1e4);
|
|
401
|
+
d += (pen ? "L" : "M") + px.toFixed(1) + "," + py.toFixed(1) + " ";
|
|
402
|
+
pen = true;
|
|
403
|
+
}
|
|
201
404
|
curve.setAttribute("d", d);
|
|
202
405
|
};
|
|
203
406
|
|
|
204
|
-
if (editable)
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
407
|
+
if (editable)
|
|
408
|
+
input.addEventListener("input", () => {
|
|
409
|
+
expr = input.value;
|
|
410
|
+
compiled = parseExpr(expr); // a reason string on a parse error → draw flags is-invalid
|
|
411
|
+
draw();
|
|
412
|
+
onChange(expr);
|
|
413
|
+
});
|
|
208
414
|
|
|
209
415
|
onReady(draw);
|
|
210
|
-
|
|
416
|
+
// twk-reflow: a tab page revealing this re-measures it
|
|
417
|
+
onLive(
|
|
418
|
+
root,
|
|
419
|
+
[
|
|
420
|
+
[window, "resize"],
|
|
421
|
+
[window, "twk-reflow"],
|
|
422
|
+
],
|
|
423
|
+
draw,
|
|
424
|
+
);
|
|
211
425
|
|
|
212
426
|
return {
|
|
213
427
|
el: root,
|
|
214
428
|
get: () => expr,
|
|
215
|
-
|
|
429
|
+
// an invalid expression flags, as typing does
|
|
430
|
+
set: (v: unknown) => {
|
|
431
|
+
if (v == null) return;
|
|
432
|
+
expr = String(v);
|
|
433
|
+
if (input) input.value = expr;
|
|
434
|
+
if (!fn) compiled = parseExpr(expr);
|
|
435
|
+
draw();
|
|
436
|
+
},
|
|
216
437
|
};
|
|
217
438
|
}
|
|
218
439
|
|
|
219
440
|
registerControl("plot", createPlot);
|
|
220
|
-
|