tweakit 0.5.2 → 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 +32 -30
- package/README.md +55 -29
- package/THIRD-PARTY-NOTICES.md +0 -1
- package/dist/tweaks/auto.js +2 -0
- package/dist/tweaks/auto.js.map +6 -0
- 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/color-UGYMAF3W.js.map +6 -0
- package/dist/tweaks/core.js +2 -1
- package/dist/tweaks/core.js.map +6 -0
- 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-NPFIGHRM.js +2 -0
- package/dist/tweaks/heavy-NPFIGHRM.js.map +6 -0
- package/dist/tweaks/image-HTQQ4WX6.js +4 -0
- package/dist/tweaks/image-HTQQ4WX6.js.map +6 -0
- package/dist/tweaks/image-store-QD5SMZXG.js +2 -0
- 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 +7 -3
- package/dist/tweaks.js.map +6 -0
- package/dist/types/tweaks/easing.d.ts +8 -11
- package/dist/types/tweaks/enhance.d.ts +3 -2
- package/dist/types/tweaks/panel.d.ts +6 -4
- package/dist/types/tweaks/schema.d.ts +4 -8
- package/dist/types/tweaks/standalone.d.ts +3 -3
- package/dist/types/tweaks/types.d.ts +499 -116
- package/package.json +30 -5
- package/src/tweaks/auto.ts +16 -0
- package/src/tweaks/clip.ts +156 -0
- package/src/tweaks/controls/basic.ts +727 -0
- package/src/tweaks/controls/bezier.ts +204 -0
- package/src/tweaks/controls/color.ts +861 -0
- package/src/tweaks/controls/gradient.ts +419 -0
- package/src/tweaks/controls/image.ts +555 -0
- package/src/tweaks/controls/interval.ts +189 -0
- package/src/tweaks/controls/monitor.ts +211 -0
- package/src/tweaks/controls/motion.ts +316 -0
- package/src/tweaks/controls/plot.ts +440 -0
- package/src/tweaks/controls/point.ts +208 -0
- package/src/tweaks/controls/shadow.ts +156 -0
- package/src/tweaks/controls/sides.ts +196 -0
- package/src/tweaks/controls/spring.ts +308 -0
- package/src/tweaks/controls/tabs.ts +68 -0
- package/src/tweaks/core.ts +9 -0
- package/src/tweaks/easing.ts +118 -0
- package/src/tweaks/enhance.ts +35 -0
- package/src/tweaks/feedback.ts +218 -0
- package/src/tweaks/fold.ts +33 -0
- package/src/tweaks/heavy.ts +626 -0
- package/src/tweaks/icons.ts +26 -0
- package/src/tweaks/image-store.ts +189 -0
- package/src/tweaks/lazy.ts +63 -0
- package/src/tweaks/markup.ts +317 -0
- package/src/tweaks/panel.ts +1693 -0
- package/src/tweaks/presets.ts +85 -0
- package/src/tweaks/schema.ts +351 -0
- package/src/tweaks/shadow-root.ts +178 -0
- package/src/tweaks/shared.ts +1137 -0
- package/src/tweaks/single.ts +22 -0
- package/src/tweaks/standalone.ts +179 -0
- package/src/tweaks/types.ts +1027 -0
- package/src/tweaks/undo.ts +104 -0
- package/src/tweaks.css +965 -0
- package/src/wide-gamut.ts +658 -0
- package/dist/tweaks/bezier-2RABC6LD.js +0 -1
- package/dist/tweaks/chunk-4ATI34PB.js +0 -1
- package/dist/tweaks/chunk-7OSCMZ2J.js +0 -1
- package/dist/tweaks/chunk-W3JNIJ2F.js +0 -1
- package/dist/tweaks/clip-ECGCECEC.js +0 -1
- package/dist/tweaks/colour-2RE3FQ5E.js +0 -1
- package/dist/tweaks/fold-UGN7TXGX.js +0 -1
- package/dist/tweaks/gradient-DB6XTDHO.js +0 -1
- package/dist/tweaks/heavy-PKOMHB3C.js +0 -1
- package/dist/tweaks/image-7MCLBOZ3.js +0 -1
- package/dist/tweaks/interval-L2E4L3AZ.js +0 -1
- package/dist/tweaks/markup-ZJJ6CCWM.js +0 -1
- package/dist/tweaks/monitor-IEK742PG.js +0 -2
- package/dist/tweaks/motion-N5UNVR55.js +0 -1
- package/dist/tweaks/plot-VVTOY3RA.js +0 -2
- package/dist/tweaks/point-YMMHSLO7.js +0 -1
- package/dist/tweaks/shadow-IKYOV3VN.js +0 -1
- package/dist/tweaks/sides-FH227Z54.js +0 -1
- package/dist/tweaks/spring-3PKAFIH7.js +0 -1
- package/dist/tweaks/tabs-BKK5NIIY.js +0 -1
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
// ── Plot — graph y=f(x) with a safe expression evaluator (compileExpr). Lazy.
|
|
2
|
+
import { el, txt, clamp, onReady, onLive, quietFocus, registerControl, words } from "../shared.js";
|
|
3
|
+
import { svgEl } from "../heavy.js";
|
|
4
|
+
import type { OnChange, Built } from "../shared.js";
|
|
5
|
+
import type { Meta } from "../schema.js";
|
|
6
|
+
import type { Control } from "../types.js";
|
|
7
|
+
|
|
8
|
+
/** A compiled expression: y for an x. */
|
|
9
|
+
type Expr = (x: number) => number;
|
|
10
|
+
/** The tokens the expression lexer emits. */
|
|
11
|
+
type Tok = { t: "num"; v: number } | { t: "id" | "op"; v: string } | { t: "lp" | "rp" | "comma"; v?: undefined };
|
|
12
|
+
|
|
13
|
+
// ── A safe expression evaluator: recursive descent, not eval/Function, so a typed formula does only arithmetic and
|
|
14
|
+
// whitelisted Math calls. Null-prototype tables, so "constructor" can't parse as an identifier. ──
|
|
15
|
+
const PLOT_FUNCS: Record<string, (...args: number[]) => number> = Object.assign(Object.create(null), {
|
|
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,
|
|
53
|
+
});
|
|
54
|
+
/** The compiled expression, or null on any parse error. */
|
|
55
|
+
export function compileExpr(src: unknown): Expr | null {
|
|
56
|
+
const r = parseExpr(src);
|
|
57
|
+
return typeof r === "function" ? r : null;
|
|
58
|
+
}
|
|
59
|
+
/** The compiled expression, or why it doesn't parse; the reason names the fix where there is one ("write sin(…)"). */
|
|
60
|
+
export function parseExpr(src: unknown): Expr | string {
|
|
61
|
+
if (typeof src !== "string" || !src.trim()) return "type an expression in x";
|
|
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;
|
|
71
|
+
let i = 0;
|
|
72
|
+
while (i < s.length) {
|
|
73
|
+
const ch = s[i];
|
|
74
|
+
if (ch === " " || ch === "\t" || ch === "\n") {
|
|
75
|
+
i++;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if ((ch >= "0" && ch <= "9") || (ch === "." && s[i + 1] >= "0" && s[i + 1] <= "9")) {
|
|
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;
|
|
85
|
+
}
|
|
86
|
+
if ((ch >= "A" && ch <= "Z") || (ch >= "a" && ch <= "z") || ch === "_") {
|
|
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;
|
|
117
|
+
}
|
|
118
|
+
return `unexpected "${ch}"`;
|
|
119
|
+
}
|
|
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
|
+
};
|
|
127
|
+
const peek = () => toks[p];
|
|
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
|
+
};
|
|
134
|
+
const add = (): Expr | null => {
|
|
135
|
+
let a = mul();
|
|
136
|
+
if (!a) return null;
|
|
137
|
+
while (peek() && peek().t === "op" && (peek().v === "+" || peek().v === "-")) {
|
|
138
|
+
const op = eat("op").v,
|
|
139
|
+
b = mul();
|
|
140
|
+
if (!b) return null;
|
|
141
|
+
const aa = a;
|
|
142
|
+
a = op === "+" ? (x) => aa(x) + b(x) : (x) => aa(x) - b(x);
|
|
143
|
+
}
|
|
144
|
+
return a;
|
|
145
|
+
};
|
|
146
|
+
const mul = (): Expr | null => {
|
|
147
|
+
let a = unary();
|
|
148
|
+
if (!a) return null;
|
|
149
|
+
for (let t = peek(); t; t = peek()) {
|
|
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");
|
|
156
|
+
if (!implied && !(t.t === "op" && (t.v === "*" || t.v === "/" || t.v === "%"))) break;
|
|
157
|
+
const op = implied ? "*" : eat("op").v,
|
|
158
|
+
b = unary();
|
|
159
|
+
if (!b) return null;
|
|
160
|
+
const aa = a;
|
|
161
|
+
a = op === "*" ? (x) => aa(x) * b(x) : op === "/" ? (x) => aa(x) / b(x) : (x) => aa(x) % b(x);
|
|
162
|
+
}
|
|
163
|
+
return a;
|
|
164
|
+
};
|
|
165
|
+
const unary = (): Expr | null => {
|
|
166
|
+
if (peek() && peek().t === "op" && (peek().v === "-" || peek().v === "+")) {
|
|
167
|
+
const op = eat("op").v,
|
|
168
|
+
a = unary();
|
|
169
|
+
if (!a) return null;
|
|
170
|
+
return op === "-" ? (x) => -a(x) : a;
|
|
171
|
+
}
|
|
172
|
+
return pow();
|
|
173
|
+
};
|
|
174
|
+
const pow = (): Expr | null => {
|
|
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
|
+
}
|
|
183
|
+
return a;
|
|
184
|
+
};
|
|
185
|
+
const atom = (): Expr | null => {
|
|
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
|
+
}
|
|
198
|
+
if (tok.t === "id") {
|
|
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)))) {
|
|
204
|
+
if (!fn) return fail(`unknown function "${tok.v}"`);
|
|
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
|
+
}
|
|
214
|
+
if (!eat("rp")) return null;
|
|
215
|
+
if (!argv.length && PLOT_VARIADIC.includes(name)) return fail(`${name}() needs at least one value`);
|
|
216
|
+
return (x) => fn(...argv.map((a) => a(x)));
|
|
217
|
+
}
|
|
218
|
+
if (fn) {
|
|
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}(…)`);
|
|
227
|
+
return (x) => fn(a(x));
|
|
228
|
+
}
|
|
229
|
+
if (name === "x") return (x) => x;
|
|
230
|
+
if (name in PLOT_CONSTS) {
|
|
231
|
+
const c = PLOT_CONSTS[name];
|
|
232
|
+
return () => c;
|
|
233
|
+
}
|
|
234
|
+
return fail(`unknown name "${tok.v}"`); // an identifier that isn't x, a constant, or a known function
|
|
235
|
+
}
|
|
236
|
+
return null;
|
|
237
|
+
};
|
|
238
|
+
const named = (t: Tok) => t && t.t === "id" && !PLOT_FUNCS[t.v.toLowerCase()];
|
|
239
|
+
const bare = (): Expr | null => {
|
|
240
|
+
const t = peek();
|
|
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
|
+
}
|
|
247
|
+
return named(t) ? atom() : null;
|
|
248
|
+
};
|
|
249
|
+
const tree = add(),
|
|
250
|
+
left = toks[p];
|
|
251
|
+
if (tree && !left) return tree;
|
|
252
|
+
if (why) return why;
|
|
253
|
+
// A parse that stopped short, or tokens left over: say where.
|
|
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);
|
|
256
|
+
if (!tree && last && last.t === "op") return `"${last.v}" needs a value on each side`;
|
|
257
|
+
if (!left) return unclosed > 0 ? 'missing ")"' : "not a valid expression";
|
|
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";
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ── Plot — y = f(x) across a range with an editable expression, or a fixed `fn`. Y auto-ranges unless yMin/yMax are
|
|
272
|
+
// given; the line breaks across asymptotes. ──
|
|
273
|
+
function createPlot(meta: Meta, onChange: OnChange): Control {
|
|
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);
|
|
280
|
+
if (!Number.isFinite(xMin)) xMin = -10;
|
|
281
|
+
if (!Number.isFinite(xMax)) xMax = 10;
|
|
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));
|
|
293
|
+
const fixedY = Number.isFinite(meta.yMin) && Number.isFinite(meta.yMax) && +meta.yMax > +meta.yMin;
|
|
294
|
+
const fn = typeof meta.fn === "function" ? meta.fn : null;
|
|
295
|
+
const editable = meta.editable !== false && !fn;
|
|
296
|
+
let expr = meta.expr != null ? String(meta.expr) : fn ? "" : "sin(x)"; // a sine to start from
|
|
297
|
+
let compiled = fn || parseExpr(expr); // a string is the reason it doesn't parse
|
|
298
|
+
|
|
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);
|
|
310
|
+
|
|
311
|
+
let input: HTMLInputElement | null = null;
|
|
312
|
+
if (editable) {
|
|
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);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const PAD = 6;
|
|
331
|
+
const draw = () => {
|
|
332
|
+
// Flag invalid before the size guard, so a hidden tab page still reports a bad expression.
|
|
333
|
+
const f = typeof compiled === "function" ? compiled : null,
|
|
334
|
+
ok = !!f;
|
|
335
|
+
root.classList.toggle("is-invalid", editable && !ok);
|
|
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;
|
|
345
|
+
svg.setAttribute("viewBox", `0 0 ${W} ${H}`);
|
|
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;
|
|
366
|
+
if (!fixedY) {
|
|
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
|
+
}
|
|
376
|
+
}
|
|
377
|
+
const xPx = (x: number) => PAD + ((x - xMin) / (xMax - xMin)) * (W - 2 * PAD);
|
|
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
|
+
};
|
|
388
|
+
axis(axisX, PAD, yPx(0), W - PAD, yPx(0), lo <= 0 && hi >= 0);
|
|
389
|
+
axis(axisY, xPx(0), PAD, xPx(0), H - PAD, xMin <= 0 && xMax >= 0);
|
|
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
|
+
}
|
|
404
|
+
curve.setAttribute("d", d);
|
|
405
|
+
};
|
|
406
|
+
|
|
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
|
+
});
|
|
414
|
+
|
|
415
|
+
onReady(draw);
|
|
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
|
+
);
|
|
425
|
+
|
|
426
|
+
return {
|
|
427
|
+
el: root,
|
|
428
|
+
get: () => expr,
|
|
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
|
+
},
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
registerControl("plot", createPlot);
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// ── Point — 2D/3D/4D vector. Lazy.
|
|
2
|
+
import { el, clamp, stepPrecision, defaultRange, popover, registerControl, words } from "../shared.js";
|
|
3
|
+
import {
|
|
4
|
+
numField,
|
|
5
|
+
svgEl,
|
|
6
|
+
grabSurface,
|
|
7
|
+
boxFrac,
|
|
8
|
+
arrowStep,
|
|
9
|
+
sayWhenKeysStop,
|
|
10
|
+
triggerRow,
|
|
11
|
+
decimalStep,
|
|
12
|
+
} from "../heavy.js";
|
|
13
|
+
import type { OnChange, Built } from "../shared.js";
|
|
14
|
+
import type { Meta, PointComponent } from "../schema.js";
|
|
15
|
+
import type { Control } from "../types.js";
|
|
16
|
+
|
|
17
|
+
// A trigger row opens the 2D pad over the component fields in a popover. The fields hold the values; readout, thumb
|
|
18
|
+
// and emitted map read off them. `pad: false` opts out.
|
|
19
|
+
function createPoint(meta: Meta, onChange: OnChange): Control {
|
|
20
|
+
const tr = words();
|
|
21
|
+
const comps = meta.components; // [{ key, label, value, step, min, max }]
|
|
22
|
+
|
|
23
|
+
// ── Trigger row — label + value + a mini pad preview. ──
|
|
24
|
+
// ??: an explicit "" label renders none
|
|
25
|
+
const { root, trigger, right } = triggerRow("twk-point", meta.label ?? tr("point", "Point"));
|
|
26
|
+
const valueEl = el("span", "twk-trigger-value");
|
|
27
|
+
const preview = el("div", "twk-trigger-chip twk-point-preview");
|
|
28
|
+
const previewDot = el("div", "twk-point-preview-dot");
|
|
29
|
+
preview.append(previewDot);
|
|
30
|
+
right.append(preview, valueEl); // chip first, as every chip row
|
|
31
|
+
|
|
32
|
+
// ── Popover — the 2D pad over the component fields. ──
|
|
33
|
+
const pop = el("div", "twk-color-pop twk-point-pop");
|
|
34
|
+
const body = el("div", "twk-point-body");
|
|
35
|
+
pop.append(body);
|
|
36
|
+
|
|
37
|
+
const hasPad = meta.pad !== false && comps.length >= 2;
|
|
38
|
+
// An absent bound is the unit range widened to hold the value, mirrored about the origin (5 spans ±15); an absent
|
|
39
|
+
// step is the decade at or below range/200, so the pad is continuous, and finer when the default or min sits between
|
|
40
|
+
// its steps (337 over ±1011 stepped by 10 to 340), as the slider's step fits its default.
|
|
41
|
+
const rangeOf = (c: PointComponent) => {
|
|
42
|
+
const half = defaultRange(Math.abs(Number.isFinite(+c.value) ? +c.value : 0))[1];
|
|
43
|
+
let lo = c.min ?? -half,
|
|
44
|
+
hi = c.max ?? half;
|
|
45
|
+
// A lone bound at or past the other end spans from itself instead, as the slider's does (min 5 over 0 spans 5–35,
|
|
46
|
+
// not 5–1, swapped).
|
|
47
|
+
if ((c.min == null) !== (c.max == null) && !(hi > lo)) {
|
|
48
|
+
const w = 2 * defaultRange(Math.abs(+(c.min ?? c.max)!))[1];
|
|
49
|
+
if (c.min == null) lo = hi - w;
|
|
50
|
+
else hi = lo + w;
|
|
51
|
+
}
|
|
52
|
+
// a degenerate range (hi ≤ lo, NaN) keeps a step of 1
|
|
53
|
+
const fine = 10 ** Math.floor(Math.log10((hi - lo) / 200)),
|
|
54
|
+
nudge = c.step ?? (fine > 0 && Number.isFinite(fine) ? fine : 1);
|
|
55
|
+
// the pad's arrows keep the decade: 3.14159's 0.00001 would crawl
|
|
56
|
+
return { lo, hi, nudge, step: c.step ?? Math.min(nudge, decimalStep(c.value), decimalStep(c.min)) };
|
|
57
|
+
};
|
|
58
|
+
const ranges = comps.map(rangeOf);
|
|
59
|
+
const rx = ranges[0],
|
|
60
|
+
ry = ranges[1];
|
|
61
|
+
const minX = rx?.lo ?? -1,
|
|
62
|
+
maxX = rx?.hi ?? 1,
|
|
63
|
+
minY = ry?.lo ?? -1,
|
|
64
|
+
maxY = ry?.hi ?? 1;
|
|
65
|
+
const frac = (v: number, lo: number, hi: number) => (hi > lo ? clamp((v - lo) / (hi - lo), 0, 1) : 0.5);
|
|
66
|
+
|
|
67
|
+
let padHost: HTMLDivElement | null = null,
|
|
68
|
+
padThumb: HTMLDivElement | null = null,
|
|
69
|
+
padLine: Built<SVGLineElement> | null = null;
|
|
70
|
+
if (hasPad) {
|
|
71
|
+
padHost = el("div", "twk-pad");
|
|
72
|
+
padThumb = el("div", "twk-pad-thumb");
|
|
73
|
+
// A tether from the origin to the thumb, so the point reads as a vector; the pad is square so the angle is true.
|
|
74
|
+
const padSvg = svgEl("svg", "twk-pad-line");
|
|
75
|
+
padSvg.setAttribute("viewBox", "0 0 100 100");
|
|
76
|
+
padSvg.setAttribute("preserveAspectRatio", "none");
|
|
77
|
+
padLine = svgEl("line");
|
|
78
|
+
padSvg.append(padLine);
|
|
79
|
+
// The origin is fixed for the control's life: set x1/y1 once.
|
|
80
|
+
const oy0 = frac(0, minY, maxY);
|
|
81
|
+
padLine.setAttribute("x1", frac(0, minX, maxX) * 100);
|
|
82
|
+
padLine.setAttribute("y1", (meta.invertY ? oy0 : 1 - oy0) * 100);
|
|
83
|
+
padHost.append(
|
|
84
|
+
el("div", "twk-pad-axis twk-pad-axis-x"),
|
|
85
|
+
el("div", "twk-pad-axis twk-pad-axis-y"),
|
|
86
|
+
padSvg,
|
|
87
|
+
padThumb,
|
|
88
|
+
);
|
|
89
|
+
body.append(padHost);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const fields = el("div", "twk-fields");
|
|
93
|
+
body.append(fields);
|
|
94
|
+
const sync = () => {
|
|
95
|
+
positionPad();
|
|
96
|
+
paintValue();
|
|
97
|
+
};
|
|
98
|
+
const emit = () => onChange(read());
|
|
99
|
+
const flds = comps.map((c, k) => {
|
|
100
|
+
const fld = numField(
|
|
101
|
+
{ label: c.label ?? c.key, value: c.value ?? 0, step: ranges[k].step, min: c.min, max: c.max },
|
|
102
|
+
() => {
|
|
103
|
+
sync();
|
|
104
|
+
emit();
|
|
105
|
+
},
|
|
106
|
+
);
|
|
107
|
+
fields.append(fld.el);
|
|
108
|
+
return fld;
|
|
109
|
+
});
|
|
110
|
+
// the emitted map, straight off the fields
|
|
111
|
+
const read = () => Object.fromEntries(comps.map((c, k) => [c.key, flds[k].get()]));
|
|
112
|
+
|
|
113
|
+
// Readout: the components at each step's precision, so a value reads "−1.00, −0.46" (steady columns), not
|
|
114
|
+
// "−1, −0.46".
|
|
115
|
+
const fmt = (v: number, step: number) => (+v).toFixed(stepPrecision(step));
|
|
116
|
+
const paintValue = () => {
|
|
117
|
+
valueEl.textContent = flds.map((f, k) => fmt(f.get(), ranges[k].step)).join(", ");
|
|
118
|
+
};
|
|
119
|
+
let positionPad = () => {
|
|
120
|
+
previewDot.style.left = "50%";
|
|
121
|
+
previewDot.style.top = "50%";
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
if (hasPad) {
|
|
125
|
+
// The mini preview is clipped, so inset the 5px dot: its center travels from +2.5px at 0 to −2.5px at 1.
|
|
126
|
+
const DOT = 5;
|
|
127
|
+
const inset = (f: number) => `calc(${(f * 100).toFixed(2)}% + ${((0.5 - f) * DOT).toFixed(2)}px)`;
|
|
128
|
+
positionPad = () => {
|
|
129
|
+
const fx = frac(flds[0].get(), minX, maxX),
|
|
130
|
+
fyv = frac(flds[1].get(), minY, maxY);
|
|
131
|
+
const tyFrac = meta.invertY ? fyv : 1 - fyv;
|
|
132
|
+
const tx = fx * 100,
|
|
133
|
+
ty = tyFrac * 100;
|
|
134
|
+
padThumb.style.left = tx + "%";
|
|
135
|
+
padThumb.style.top = ty + "%";
|
|
136
|
+
previewDot.style.left = inset(fx);
|
|
137
|
+
previewDot.style.top = inset(tyFrac); // mini pad: dot stays in bounds
|
|
138
|
+
padLine.setAttribute("x2", tx);
|
|
139
|
+
padLine.setAttribute("y2", ty);
|
|
140
|
+
// the pad's live readout for assistive tech
|
|
141
|
+
padHost.setAttribute(
|
|
142
|
+
"aria-description",
|
|
143
|
+
`${comps[0].label ?? comps[0].key} ${fmt(flds[0].get(), rx.step)}, ${comps[1].label ?? comps[1].key} ${fmt(flds[1].get(), ry.step)}`,
|
|
144
|
+
);
|
|
145
|
+
};
|
|
146
|
+
const padSet = (e: PointerEvent) => {
|
|
147
|
+
const [fx, fy] = boxFrac(e, padHost);
|
|
148
|
+
const yFrac = meta.invertY ? fy : 1 - fy;
|
|
149
|
+
flds[0].set(minX + fx * (maxX - minX));
|
|
150
|
+
flds[1].set(minY + yFrac * (maxY - minY));
|
|
151
|
+
sync();
|
|
152
|
+
emit();
|
|
153
|
+
};
|
|
154
|
+
grabSurface(padHost, padSet);
|
|
155
|
+
// Keyboard: a focusable group (no ARIA role fits a 2D point; the readout rides on aria-description). Arrows move a
|
|
156
|
+
// pad step (⇧ = ten), ↑ always up on screen; Home restores the default. Escape is the popover's — a blur here would
|
|
157
|
+
// close it first.
|
|
158
|
+
padHost.tabIndex = 0;
|
|
159
|
+
padHost.setAttribute("role", "group");
|
|
160
|
+
padHost.setAttribute(
|
|
161
|
+
"aria-label",
|
|
162
|
+
tr("pointPad", "{label} pad: {x} and {y}", {
|
|
163
|
+
label: meta.label ?? tr("point", "Point"),
|
|
164
|
+
x: comps[0].label ?? comps[0].key,
|
|
165
|
+
y: comps[1].label ?? comps[1].key,
|
|
166
|
+
}),
|
|
167
|
+
);
|
|
168
|
+
const say = sayWhenKeysStop(padHost);
|
|
169
|
+
padHost.addEventListener("keydown", (e) => {
|
|
170
|
+
if (e.key === "Home") {
|
|
171
|
+
e.preventDefault();
|
|
172
|
+
comps.forEach((c, k) => flds[k].set(c.value ?? 0));
|
|
173
|
+
sync();
|
|
174
|
+
emit();
|
|
175
|
+
say();
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
const [dx, dy] = arrowStep(e);
|
|
179
|
+
if (!dx && !dy) return;
|
|
180
|
+
e.preventDefault();
|
|
181
|
+
const k = e.shiftKey ? 10 : 1;
|
|
182
|
+
if (dx) flds[0].set(clamp(flds[0].get() + dx * rx.nudge * k, minX, maxX));
|
|
183
|
+
if (dy) flds[1].set(clamp(flds[1].get() + (meta.invertY ? -dy : dy) * ry.nudge * k, minY, maxY));
|
|
184
|
+
sync();
|
|
185
|
+
emit();
|
|
186
|
+
say();
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
root.append(pop);
|
|
191
|
+
popover(root, trigger, pop, { fallbackH: 240, onOpen: sync });
|
|
192
|
+
sync();
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
el: root,
|
|
196
|
+
set: (v: Record<string, number | string>) => {
|
|
197
|
+
if (v)
|
|
198
|
+
comps.forEach((c, k) => {
|
|
199
|
+
if (v[c.key] != null) flds[k].set(v[c.key]);
|
|
200
|
+
});
|
|
201
|
+
sync();
|
|
202
|
+
},
|
|
203
|
+
get: read,
|
|
204
|
+
range: Object.fromEntries(comps.map((c, k) => [c.key, flds[k].range])),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
registerControl("point", createPoint);
|