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.
Files changed (120) hide show
  1. package/AGENTS.md +32 -30
  2. package/README.md +55 -29
  3. package/THIRD-PARTY-NOTICES.md +0 -1
  4. package/dist/tweaks/auto.js +2 -0
  5. package/dist/tweaks/auto.js.map +6 -0
  6. package/dist/tweaks/bezier-S2BOXQBM.js +2 -0
  7. package/dist/tweaks/bezier-S2BOXQBM.js.map +6 -0
  8. package/dist/tweaks/chunk-3MZAAMC2.js +2 -0
  9. package/dist/tweaks/chunk-3MZAAMC2.js.map +6 -0
  10. package/dist/tweaks/chunk-ETB35KFV.js +2 -0
  11. package/dist/tweaks/chunk-ETB35KFV.js.map +6 -0
  12. package/dist/tweaks/chunk-YB5GKWZI.js +2 -0
  13. package/dist/tweaks/chunk-YB5GKWZI.js.map +6 -0
  14. package/dist/tweaks/clip-A3VI53BK.js +2 -0
  15. package/dist/tweaks/clip-A3VI53BK.js.map +6 -0
  16. package/dist/tweaks/color-UGYMAF3W.js +2 -0
  17. package/dist/tweaks/color-UGYMAF3W.js.map +6 -0
  18. package/dist/tweaks/core.js +2 -1
  19. package/dist/tweaks/core.js.map +6 -0
  20. package/dist/tweaks/fold-GY47HIQA.js +2 -0
  21. package/dist/tweaks/fold-GY47HIQA.js.map +6 -0
  22. package/dist/tweaks/gradient-BTX25WSH.js +2 -0
  23. package/dist/tweaks/gradient-BTX25WSH.js.map +6 -0
  24. package/dist/tweaks/heavy-NPFIGHRM.js +2 -0
  25. package/dist/tweaks/heavy-NPFIGHRM.js.map +6 -0
  26. package/dist/tweaks/image-HTQQ4WX6.js +4 -0
  27. package/dist/tweaks/image-HTQQ4WX6.js.map +6 -0
  28. package/dist/tweaks/image-store-QD5SMZXG.js +2 -0
  29. package/dist/tweaks/image-store-QD5SMZXG.js.map +6 -0
  30. package/dist/tweaks/interval-BHPI4LRN.js +2 -0
  31. package/dist/tweaks/interval-BHPI4LRN.js.map +6 -0
  32. package/dist/tweaks/markup-QM53TQJZ.js +2 -0
  33. package/dist/tweaks/markup-QM53TQJZ.js.map +6 -0
  34. package/dist/tweaks/monitor-722WIYGW.js +3 -0
  35. package/dist/tweaks/monitor-722WIYGW.js.map +6 -0
  36. package/dist/tweaks/motion-HMCZK6HJ.js +2 -0
  37. package/dist/tweaks/motion-HMCZK6HJ.js.map +6 -0
  38. package/dist/tweaks/plot-D3NQ6XNN.js +3 -0
  39. package/dist/tweaks/plot-D3NQ6XNN.js.map +6 -0
  40. package/dist/tweaks/point-XDXKMDXH.js +2 -0
  41. package/dist/tweaks/point-XDXKMDXH.js.map +6 -0
  42. package/dist/tweaks/presets-EUWNSCUW.js +2 -0
  43. package/dist/tweaks/presets-EUWNSCUW.js.map +6 -0
  44. package/dist/tweaks/shadow-AM7VSRNS.js +2 -0
  45. package/dist/tweaks/shadow-AM7VSRNS.js.map +6 -0
  46. package/dist/tweaks/shadow-root-RYUV3AGS.js +3 -0
  47. package/dist/tweaks/shadow-root-RYUV3AGS.js.map +6 -0
  48. package/dist/tweaks/sides-JWIURDA4.js +2 -0
  49. package/dist/tweaks/sides-JWIURDA4.js.map +6 -0
  50. package/dist/tweaks/spring-BF5GBTPM.js +2 -0
  51. package/dist/tweaks/spring-BF5GBTPM.js.map +6 -0
  52. package/dist/tweaks/tabs-PRVUUZXZ.js +2 -0
  53. package/dist/tweaks/tabs-PRVUUZXZ.js.map +6 -0
  54. package/dist/tweaks.css +1 -1
  55. package/dist/tweaks.js +7 -3
  56. package/dist/tweaks.js.map +6 -0
  57. package/dist/types/tweaks/easing.d.ts +8 -11
  58. package/dist/types/tweaks/enhance.d.ts +3 -2
  59. package/dist/types/tweaks/panel.d.ts +6 -4
  60. package/dist/types/tweaks/schema.d.ts +4 -8
  61. package/dist/types/tweaks/standalone.d.ts +3 -3
  62. package/dist/types/tweaks/types.d.ts +499 -116
  63. package/package.json +30 -5
  64. package/src/tweaks/auto.ts +16 -0
  65. package/src/tweaks/clip.ts +156 -0
  66. package/src/tweaks/controls/basic.ts +727 -0
  67. package/src/tweaks/controls/bezier.ts +204 -0
  68. package/src/tweaks/controls/color.ts +861 -0
  69. package/src/tweaks/controls/gradient.ts +419 -0
  70. package/src/tweaks/controls/image.ts +555 -0
  71. package/src/tweaks/controls/interval.ts +189 -0
  72. package/src/tweaks/controls/monitor.ts +211 -0
  73. package/src/tweaks/controls/motion.ts +316 -0
  74. package/src/tweaks/controls/plot.ts +440 -0
  75. package/src/tweaks/controls/point.ts +208 -0
  76. package/src/tweaks/controls/shadow.ts +156 -0
  77. package/src/tweaks/controls/sides.ts +196 -0
  78. package/src/tweaks/controls/spring.ts +308 -0
  79. package/src/tweaks/controls/tabs.ts +68 -0
  80. package/src/tweaks/core.ts +9 -0
  81. package/src/tweaks/easing.ts +118 -0
  82. package/src/tweaks/enhance.ts +35 -0
  83. package/src/tweaks/feedback.ts +218 -0
  84. package/src/tweaks/fold.ts +33 -0
  85. package/src/tweaks/heavy.ts +626 -0
  86. package/src/tweaks/icons.ts +26 -0
  87. package/src/tweaks/image-store.ts +189 -0
  88. package/src/tweaks/lazy.ts +63 -0
  89. package/src/tweaks/markup.ts +317 -0
  90. package/src/tweaks/panel.ts +1693 -0
  91. package/src/tweaks/presets.ts +85 -0
  92. package/src/tweaks/schema.ts +351 -0
  93. package/src/tweaks/shadow-root.ts +178 -0
  94. package/src/tweaks/shared.ts +1137 -0
  95. package/src/tweaks/single.ts +22 -0
  96. package/src/tweaks/standalone.ts +179 -0
  97. package/src/tweaks/types.ts +1027 -0
  98. package/src/tweaks/undo.ts +104 -0
  99. package/src/tweaks.css +965 -0
  100. package/src/wide-gamut.ts +658 -0
  101. package/dist/tweaks/bezier-2RABC6LD.js +0 -1
  102. package/dist/tweaks/chunk-4ATI34PB.js +0 -1
  103. package/dist/tweaks/chunk-7OSCMZ2J.js +0 -1
  104. package/dist/tweaks/chunk-W3JNIJ2F.js +0 -1
  105. package/dist/tweaks/clip-ECGCECEC.js +0 -1
  106. package/dist/tweaks/colour-2RE3FQ5E.js +0 -1
  107. package/dist/tweaks/fold-UGN7TXGX.js +0 -1
  108. package/dist/tweaks/gradient-DB6XTDHO.js +0 -1
  109. package/dist/tweaks/heavy-PKOMHB3C.js +0 -1
  110. package/dist/tweaks/image-7MCLBOZ3.js +0 -1
  111. package/dist/tweaks/interval-L2E4L3AZ.js +0 -1
  112. package/dist/tweaks/markup-ZJJ6CCWM.js +0 -1
  113. package/dist/tweaks/monitor-IEK742PG.js +0 -2
  114. package/dist/tweaks/motion-N5UNVR55.js +0 -1
  115. package/dist/tweaks/plot-VVTOY3RA.js +0 -2
  116. package/dist/tweaks/point-YMMHSLO7.js +0 -1
  117. package/dist/tweaks/shadow-IKYOV3VN.js +0 -1
  118. package/dist/tweaks/sides-FH227Z54.js +0 -1
  119. package/dist/tweaks/spring-3PKAFIH7.js +0 -1
  120. 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);