tweakit 0.5.2 → 0.6.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 +31 -29
- 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-JFEZMCLP.js +2 -0
- package/dist/tweaks/bezier-JFEZMCLP.js.map +6 -0
- package/dist/tweaks/chunk-6UO4DHBN.js +2 -0
- package/dist/tweaks/chunk-6UO4DHBN.js.map +6 -0
- package/dist/tweaks/chunk-DIQMLBAS.js +2 -0
- package/dist/tweaks/chunk-DIQMLBAS.js.map +6 -0
- package/dist/tweaks/chunk-XE6OUJBG.js +2 -0
- package/dist/tweaks/chunk-XE6OUJBG.js.map +6 -0
- package/dist/tweaks/clip-WW7JYO4X.js +2 -0
- package/dist/tweaks/clip-WW7JYO4X.js.map +6 -0
- package/dist/tweaks/color-YMMFURP6.js +2 -0
- package/dist/tweaks/color-YMMFURP6.js.map +6 -0
- package/dist/tweaks/core.js +2 -1
- package/dist/tweaks/core.js.map +6 -0
- package/dist/tweaks/{fold-UGN7TXGX.js → fold-U6YRZLCJ.js} +2 -1
- package/dist/tweaks/fold-U6YRZLCJ.js.map +6 -0
- package/dist/tweaks/gradient-JER7PW4D.js +2 -0
- package/dist/tweaks/gradient-JER7PW4D.js.map +6 -0
- package/dist/tweaks/heavy-CLD7UAVI.js +2 -0
- package/dist/tweaks/heavy-CLD7UAVI.js.map +6 -0
- package/dist/tweaks/image-H6QSLGO3.js +4 -0
- package/dist/tweaks/image-H6QSLGO3.js.map +6 -0
- package/dist/tweaks/image-store-WRKY2NPU.js +2 -0
- package/dist/tweaks/image-store-WRKY2NPU.js.map +6 -0
- package/dist/tweaks/interval-TEUHQGZN.js +2 -0
- package/dist/tweaks/interval-TEUHQGZN.js.map +6 -0
- package/dist/tweaks/markup-NIBJD6E2.js +2 -0
- package/dist/tweaks/markup-NIBJD6E2.js.map +6 -0
- package/dist/tweaks/monitor-3BY7YCGX.js +3 -0
- package/dist/tweaks/monitor-3BY7YCGX.js.map +6 -0
- package/dist/tweaks/motion-FQPUBKKM.js +2 -0
- package/dist/tweaks/motion-FQPUBKKM.js.map +6 -0
- package/dist/tweaks/plot-2HBNBZSG.js +3 -0
- package/dist/tweaks/plot-2HBNBZSG.js.map +6 -0
- package/dist/tweaks/point-VV6XV3I6.js +2 -0
- package/dist/tweaks/point-VV6XV3I6.js.map +6 -0
- package/dist/tweaks/shadow-5E4E55I2.js +2 -0
- package/dist/tweaks/shadow-5E4E55I2.js.map +6 -0
- package/dist/tweaks/sides-P3STEVYY.js +2 -0
- package/dist/tweaks/sides-P3STEVYY.js.map +6 -0
- package/dist/tweaks/spring-HUVSXNVN.js +2 -0
- package/dist/tweaks/spring-HUVSXNVN.js.map +6 -0
- package/dist/tweaks/tabs-76FW7LQ7.js +2 -0
- package/dist/tweaks/tabs-76FW7LQ7.js.map +6 -0
- package/dist/tweaks.css +1 -1
- package/dist/tweaks.js +6 -3
- package/dist/tweaks.js.map +6 -0
- package/dist/types/tweaks/easing.d.ts +8 -11
- 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 +278 -101
- package/package.json +27 -5
- package/src/tweaks/auto.ts +12 -0
- package/src/tweaks/clip.ts +94 -0
- package/src/tweaks/controls/basic.ts +403 -0
- package/src/tweaks/controls/bezier.ts +99 -0
- package/src/tweaks/controls/color.ts +412 -0
- package/src/tweaks/controls/gradient.ts +240 -0
- package/src/tweaks/controls/image.ts +299 -0
- package/src/tweaks/controls/interval.ts +104 -0
- package/src/tweaks/controls/monitor.ts +105 -0
- package/src/tweaks/controls/motion.ts +141 -0
- package/src/tweaks/controls/plot.ts +220 -0
- package/src/tweaks/controls/point.ts +119 -0
- package/src/tweaks/controls/shadow.ts +83 -0
- package/src/tweaks/controls/sides.ts +86 -0
- package/src/tweaks/controls/spring.ts +148 -0
- package/src/tweaks/controls/tabs.ts +47 -0
- package/src/tweaks/core.ts +9 -0
- package/src/tweaks/easing.ts +93 -0
- package/src/tweaks/enhance.ts +20 -0
- package/src/tweaks/feedback.ts +119 -0
- package/src/tweaks/fold.ts +21 -0
- package/src/tweaks/heavy.ts +302 -0
- package/src/tweaks/icons.ts +19 -0
- package/src/tweaks/image-store.ts +101 -0
- package/src/tweaks/lazy.ts +46 -0
- package/src/tweaks/markup.ts +154 -0
- package/src/tweaks/panel.ts +869 -0
- package/src/tweaks/schema.ts +189 -0
- package/src/tweaks/shared.ts +525 -0
- package/src/tweaks/single.ts +22 -0
- package/src/tweaks/standalone.ts +89 -0
- package/src/tweaks/types.ts +526 -0
- package/src/tweaks.css +959 -0
- package/src/wide-gamut.ts +310 -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/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,104 @@
|
|
|
1
|
+
// ── Interval / range slider — dual-handle [lo,hi]. Lazy.
|
|
2
|
+
import { el, txt, clamp, roundToStep, stepPrecision, gridEnds, normalizeRange, rangeStep, overlapsText, wireHoverClass, onReady, onLive, registerControl, rubberBand, rubberRelease } from "../shared.js";
|
|
3
|
+
import { dragGesture } from "../heavy.js";
|
|
4
|
+
import type { OnChange } from "../shared.js";
|
|
5
|
+
import type { Meta } from "../schema.js";
|
|
6
|
+
import type { Control } from "../types.js";
|
|
7
|
+
|
|
8
|
+
function createInterval(meta: Meta, onChange: OnChange): Control {
|
|
9
|
+
const label = meta.label;
|
|
10
|
+
const t0 = +(meta.value && meta.value[0]), t1 = +(meta.value && meta.value[1]);
|
|
11
|
+
const lo0 = Number.isFinite(t0) ? t0 : 0, hi0 = Number.isFinite(t1) ? t1 : 1;
|
|
12
|
+
// normalizeRange, the slider's guard: an absent or non-finite bound (data-min="abc") derives from the values, each end
|
|
13
|
+
// widening it, for the shorthand and the verbose form alike: [20, 80] spans 0–240, not 20–80 (which pinned both handles).
|
|
14
|
+
let { min, max, step } = normalizeRange(meta.min, meta.max, meta.step, [lo0, hi0], label);
|
|
15
|
+
[min, max] = gridEnds(min, max, step); // the slider's rule: no value past an off-grid bound
|
|
16
|
+
const decimals = stepPrecision(step);
|
|
17
|
+
const q = (v: number) => roundToStep(v, min, step);
|
|
18
|
+
let lo = 0, hi = 0;
|
|
19
|
+
const put = (a: number, b: number) => { lo = clamp(q(Math.min(a, b)), min, max); hi = clamp(q(Math.max(a, b)), min, max); }; // ordered, on the grid, in bounds
|
|
20
|
+
// Missing tuple entries fall back to the bounds: value:[5] gives [5, max], not [5, NaN].
|
|
21
|
+
put(Number.isFinite(t0) ? t0 : min, Number.isFinite(t1) ? t1 : max);
|
|
22
|
+
|
|
23
|
+
const wrap = el("div", "tw-slider-wrap");
|
|
24
|
+
const track = el("div", "tw-slider tw-interval");
|
|
25
|
+
const fill = el("div", "tw-slider-fill");
|
|
26
|
+
const hLo = el("div", "tw-slider-handle"), hHi = el("div", "tw-slider-handle");
|
|
27
|
+
const labelEl = txt("span", "tw-slider-label", label);
|
|
28
|
+
const valueEl = el("span", "tw-slider-value");
|
|
29
|
+
track.append(fill, hLo, hHi, labelEl, valueEl);
|
|
30
|
+
wrap.append(track);
|
|
31
|
+
|
|
32
|
+
([["minimum", hLo], ["maximum", hHi]] as const).forEach(([lab, h]) => {
|
|
33
|
+
h.tabIndex = 0; h.setAttribute("role", "slider"); h.setAttribute("aria-label", `${label} ${lab}`);
|
|
34
|
+
h.setAttribute("aria-valuemin", String(min)); h.setAttribute("aria-valuemax", String(max));
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const pctOf = (v: number) => ((v - min) / ((max - min) || 1)) * 100;
|
|
38
|
+
const handleLeft = (pct: number) => `clamp(5px, calc(${pct}% - 1.5px), calc(100% - 9px))`; // stay inset at the extremes, like the slider handle
|
|
39
|
+
const render = () => {
|
|
40
|
+
const a = pctOf(lo), b = pctOf(hi);
|
|
41
|
+
fill.style.left = a + "%"; fill.style.width = Math.max(0, b - a) + "%";
|
|
42
|
+
hLo.style.left = handleLeft(a); hHi.style.left = handleLeft(b);
|
|
43
|
+
valueEl.textContent = `${lo.toFixed(decimals)} – ${hi.toFixed(decimals)}`;
|
|
44
|
+
hLo.setAttribute("aria-valuenow", String(lo)); hHi.setAttribute("aria-valuenow", String(hi));
|
|
45
|
+
// Value-dodge (overlapsText), per handle: one sliding under the label or the readout yields so the text stays legible.
|
|
46
|
+
const trackW = wrap.offsetWidth;
|
|
47
|
+
if (trackW) {
|
|
48
|
+
// The label's room is what the dual readout leaves, measured: 12px label inset + 8px gap + the readout's right inset.
|
|
49
|
+
labelEl.style.maxWidth = Math.max(0, trackW - valueEl.offsetWidth - 12 - 8 - 10) + "px";
|
|
50
|
+
const dodges = (pct: number) => overlapsText(labelEl, valueEl, (pct / 100) * trackW - 1.5, 3);
|
|
51
|
+
hLo.classList.toggle("is-dodge", dodges(pctOf(lo)));
|
|
52
|
+
hHi.classList.toggle("is-dodge", dodges(pctOf(hi)));
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
render();
|
|
56
|
+
|
|
57
|
+
const emit = () => onChange([lo, hi]);
|
|
58
|
+
const setLo = (v: number) => { lo = clamp(Math.min(q(v), hi), min, max); render(); };
|
|
59
|
+
const setHi = (v: number) => { hi = clamp(Math.max(q(v), lo), min, max); render(); };
|
|
60
|
+
|
|
61
|
+
let rect: DOMRect | null = null, scale = 1, active: "lo" | "hi" | null = null;
|
|
62
|
+
// Divide out any ancestor CSS transform (rect is visual px, offsetWidth layout px), as the single slider does.
|
|
63
|
+
const valFromX = (x: number) => { const native = wrap.offsetWidth || rect.width; const p = clamp((x - rect.left) / scale / native, 0, 1); return clamp(min + p * (max - min), min, max); };
|
|
64
|
+
const grab = (x: number) => {
|
|
65
|
+
const v = valFromX(x);
|
|
66
|
+
if (!active) active = v < lo ? "lo" : v > hi ? "hi" : (Math.abs(v - lo) <= Math.abs(v - hi) ? "lo" : "hi");
|
|
67
|
+
active === "lo" ? setLo(v) : setHi(v); emit();
|
|
68
|
+
};
|
|
69
|
+
dragGesture(track, {
|
|
70
|
+
onDown: (e) => {
|
|
71
|
+
e.preventDefault();
|
|
72
|
+
rect = wrap.getBoundingClientRect(); scale = rect.width / (wrap.offsetWidth || rect.width);
|
|
73
|
+
track.style.transition = ""; // a re-press mid spring-home stretches 1:1
|
|
74
|
+
track.classList.add("is-active", "is-dragging"); active = null;
|
|
75
|
+
grab(e.clientX);
|
|
76
|
+
(active === "lo" ? hLo : hHi).focus({ focusVisible: false }); // preventDefault killed click-to-focus; focus without a ring
|
|
77
|
+
},
|
|
78
|
+
onMove: (e) => { grab(e.clientX); rubberBand(track, rect, e.clientX); },
|
|
79
|
+
onEnd: () => { rubberRelease(track); rect = null; active = null; track.classList.remove("is-active", "is-dragging"); },
|
|
80
|
+
});
|
|
81
|
+
wireHoverClass(track, render); // re-run the dodge with the real track width on hover
|
|
82
|
+
// Recompute once layout and fonts settle (a web-font swap shifts the widths the dodge measures) and on track-width change.
|
|
83
|
+
onReady(render);
|
|
84
|
+
onLive(track, [[window, "resize"], [window, "tw-reflow"]], render); // tw-reflow: a tab page revealing this re-measures it
|
|
85
|
+
|
|
86
|
+
// Home/End snap a handle to its neighbor-or-limit, so the two can't cross.
|
|
87
|
+
const page = (max - min) / 10 || step * 10; // PageUp/Down: a tenth of the range, as the slider jumps
|
|
88
|
+
const onKey = (which: "lo" | "hi") => (e: KeyboardEvent) => {
|
|
89
|
+
const nv = which === "lo" ? rangeStep(e, lo, step, min, hi, page) : rangeStep(e, hi, step, lo, max, page);
|
|
90
|
+
if (nv == null) return;
|
|
91
|
+
e.preventDefault(); which === "lo" ? setLo(nv) : setHi(nv); emit();
|
|
92
|
+
};
|
|
93
|
+
hLo.addEventListener("keydown", onKey("lo")); hHi.addEventListener("keydown", onKey("hi"));
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
el: wrap,
|
|
97
|
+
set: (v: unknown) => { if (Array.isArray(v)) { const a = +v[0], b = +v[1]; if (!Number.isFinite(a) || !Number.isFinite(b)) return; put(a, b); render(); } },
|
|
98
|
+
get: () => [lo, hi],
|
|
99
|
+
range: [min, max],
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
registerControl("interval", createInterval);
|
|
104
|
+
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// ── Monitor + FPS graph — live sparkline / readout. Lazy; registers both types.
|
|
2
|
+
import { el, txt, clamp, blade, json, registerControl } from "../shared.js";
|
|
3
|
+
import { fitCanvas, accentColor } from "../heavy.js";
|
|
4
|
+
import type { Meta } from "../schema.js";
|
|
5
|
+
|
|
6
|
+
// NaN means no sample yet: the pen lifts rather than drawing a false zero.
|
|
7
|
+
const strokeSeries = (ctx: CanvasRenderingContext2D, node: Element, w: number, h: number, samples: number[], start: number, frac: (s: number) => number) => {
|
|
8
|
+
const N = samples.length;
|
|
9
|
+
ctx.clearRect(0, 0, w, h);
|
|
10
|
+
ctx.strokeStyle = accentColor(node);
|
|
11
|
+
ctx.lineWidth = 1.5; ctx.lineJoin = "round"; ctx.beginPath();
|
|
12
|
+
let pen = false;
|
|
13
|
+
for (let k = 0; k < N; k++) {
|
|
14
|
+
const s = samples[(start + k) % N];
|
|
15
|
+
if (Number.isNaN(s)) continue;
|
|
16
|
+
const x = (k / (N - 1)) * w, y = h - clamp(frac(s), 0, 1) * h;
|
|
17
|
+
pen ? ctx.lineTo(x, y) : (ctx.moveTo(x, y), pen = true);
|
|
18
|
+
}
|
|
19
|
+
ctx.stroke();
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// Manual, not onLive: these blades idle through the built-but-not-mounted window, which onLive would read as gone.
|
|
23
|
+
const watchReflow = (fn: () => void) => {
|
|
24
|
+
for (const t of ["resize", "tw-reflow"]) window.addEventListener(t, fn);
|
|
25
|
+
return () => { for (const t of ["resize", "tw-reflow"]) window.removeEventListener(t, fn); };
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// ── Monitor — a number as a sparkline or a readout, a string as a buffer of the last few values; read on an interval
|
|
29
|
+
// or, `perFrame` (the FPS graph), once per animation frame. ──
|
|
30
|
+
function createMonitor(meta: Meta, perFrame = false) {
|
|
31
|
+
const get: (now?: number) => unknown = typeof meta.get === "function" ? meta.get : () => meta.value;
|
|
32
|
+
const interval = Math.max(30, Number.isFinite(+meta.interval) ? +meta.interval : 200); // NaN would make setInterval a 0 ms busy-poll
|
|
33
|
+
let probe: unknown; try { probe = get(); } catch {}
|
|
34
|
+
const isNum = typeof probe === "number";
|
|
35
|
+
const graph = meta.view === "graph" || (isNum && meta.graph !== false && meta.view !== "text" && meta.rows == null);
|
|
36
|
+
|
|
37
|
+
const wrap = el("div", "tw-fps tw-monitor");
|
|
38
|
+
const val = txt("span", "tw-fps-val", "—");
|
|
39
|
+
wrap.append(txt("span", "tw-fps-label", meta.label ?? "Monitor"), val);
|
|
40
|
+
|
|
41
|
+
let timer = 0, raf = 0, unwatch = () => {}, wasConnected = false;
|
|
42
|
+
// toFixed throws past 100; a missing value reads as a dash.
|
|
43
|
+
const decimals = Number.isFinite(+meta.decimals) ? Math.min(20, Math.max(0, Math.floor(+meta.decimals))) : 2;
|
|
44
|
+
const fmt = (v: unknown) => (v == null ? "—" : typeof v === "number" ? (Number.isInteger(v) || !Number.isFinite(v) ? String(v) : v.toFixed(decimals)) : typeof v === "object" ? json(v) : String(v));
|
|
45
|
+
// Also the blade's `destroy`: a panel torn down before it ever connected can never self-stop below.
|
|
46
|
+
const stop = () => { if (timer) clearInterval(timer); timer = 0; if (raf) cancelAnimationFrame(raf); raf = 0; unwatch(); };
|
|
47
|
+
// Never-mounted-yet idles the tick; only a mount-then-remove, or destroy(), stops it. The next frame is booked before
|
|
48
|
+
// the read, so a stop() inside the tick cancels it.
|
|
49
|
+
const poll = (fn: (v: unknown) => void) => {
|
|
50
|
+
const tick = (now?: number) => {
|
|
51
|
+
if (perFrame) raf = requestAnimationFrame(tick);
|
|
52
|
+
if (!wrap.isConnected) { if (wasConnected) stop(); return; }
|
|
53
|
+
wasConnected = true;
|
|
54
|
+
let v: unknown; try { v = get(now); } catch { return; }
|
|
55
|
+
fn(v);
|
|
56
|
+
};
|
|
57
|
+
if (perFrame) raf = requestAnimationFrame(tick); else timer = setInterval(tick, interval);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
if (!graph && meta.rows) {
|
|
61
|
+
val.remove();
|
|
62
|
+
// A negative or NaN rows spun the trim loop forever.
|
|
63
|
+
const rows = Math.max(1, Math.floor(+meta.rows) || 1);
|
|
64
|
+
const buf = el("pre", "tw-monitor-buffer"); buf.style.setProperty("--tw-monitor-rows", rows);
|
|
65
|
+
wrap.append(buf);
|
|
66
|
+
const lines: string[] = [];
|
|
67
|
+
poll((v) => { lines.push(fmt(v)); while (lines.length > rows) lines.shift(); buf.textContent = lines.join("\n"); });
|
|
68
|
+
return blade(wrap, stop);
|
|
69
|
+
}
|
|
70
|
+
const readout = (v: unknown) => { val.textContent = fmt(v); };
|
|
71
|
+
if (!graph) { poll(readout); return blade(wrap, stop); }
|
|
72
|
+
|
|
73
|
+
const canvas = document.createElement("canvas"); canvas.className = "tw-fps-canvas";
|
|
74
|
+
wrap.append(canvas);
|
|
75
|
+
const ctx = canvas.getContext("2d");
|
|
76
|
+
// No 2D context (a headless DOM, a blocked canvas) degrades to the text readout.
|
|
77
|
+
if (!ctx) { poll(readout); return blade(wrap, stop); }
|
|
78
|
+
const N = 80, samples: number[] = new Array(N).fill(NaN);
|
|
79
|
+
let idx = 0, w = 0, h = 0;
|
|
80
|
+
const onResize = () => { [w, h] = fitCanvas(canvas, ctx, 2); };
|
|
81
|
+
unwatch = watchReflow(onResize);
|
|
82
|
+
const draw = () => {
|
|
83
|
+
if (!w) onResize(); // measured 0 while detached: fit on the first live draw
|
|
84
|
+
if (!w) return;
|
|
85
|
+
let lo = meta.min, hi = meta.max;
|
|
86
|
+
if (lo == null || hi == null) {
|
|
87
|
+
let mn = Infinity, mx = -Infinity;
|
|
88
|
+
for (const s of samples) if (Number.isFinite(s)) { if (s < mn) mn = s; if (s > mx) mx = s; } // an Infinity sample would blank the auto-range
|
|
89
|
+
if (mn === Infinity) { mn = 0; mx = 1; } else if (mn === mx) { mn -= 0.5; mx += 0.5; }
|
|
90
|
+
const pad = (mx - mn) * 0.1;
|
|
91
|
+
if (lo == null) lo = mn - pad; if (hi == null) hi = mx + pad;
|
|
92
|
+
}
|
|
93
|
+
const span = (hi - lo) || 1;
|
|
94
|
+
strokeSeries(ctx, wrap, w, h, samples, idx, (s) => (s - lo) / span);
|
|
95
|
+
};
|
|
96
|
+
poll((v) => { readout(v); if (typeof v === "number" && Number.isFinite(v)) { samples[idx] = v; idx = (idx + 1) % N; draw(); } }); // a non-finite sample shows in the readout, not the graph
|
|
97
|
+
return blade(wrap, stop);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── FPS graph — each frame's timestamp becomes the rate since the last (none on the first or for the probe), 0–120. ──
|
|
101
|
+
const frameRate = () => { let last = 0; return (now?: number) => { if (!(now! > 0)) return undefined; const fps = last ? 1000 / (now! - last) : undefined; last = now!; return fps; }; };
|
|
102
|
+
const createFps = (meta: Meta) => createMonitor({ ...meta, label: meta.label ?? "FPS", get: frameRate(), view: "graph", min: 0, max: 120, decimals: 0 }, true); // ??: an explicit "" label renders none
|
|
103
|
+
|
|
104
|
+
registerControl("fpsgraph", createFps);
|
|
105
|
+
registerControl("monitor", (meta: Meta) => createMonitor(meta)); // the registry passes (meta, onChange), not perFrame
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// ── Motion — a transition's timing: an easing curve with a duration, or a spring, in one popover over one value. The
|
|
2
|
+
// value carries the CSS it resolves to — `duration` in ms and an `easing` string, cubic-bezier() or a sampled linear(). Lazy. ──
|
|
3
|
+
import { el, txt, clamp, isObj, createSegmented, getControl, popover, registerControl, REDUCE_MOTION } from "../shared.js";
|
|
4
|
+
import { numField, triggerRow, svgEl, springCurve, springRun, timeFromSpring, notice, SPRING_DUR } from "../heavy.js";
|
|
5
|
+
import { KEYWORDS } from "../easing.js";
|
|
6
|
+
import type { OnChange } from "../shared.js";
|
|
7
|
+
import type { Meta } from "../schema.js";
|
|
8
|
+
import type { Control, MotionValue, MotionInput } from "../types.js";
|
|
9
|
+
|
|
10
|
+
type Mode = "easing" | "spring";
|
|
11
|
+
type Curve = [number, number, number, number];
|
|
12
|
+
interface SpringOut { stiffness: number; damping: number; mass: number; visualDuration?: number; bounce?: number }
|
|
13
|
+
|
|
14
|
+
const DUR_DEF = 300, DUR_MAX = 10000, STAGE_PAD = 16; // ms; the stage's side padding (tweaks.css)
|
|
15
|
+
const BOUNCE_MAX = 1; // Motion's whole range: its damping-ratio floor of 0.05 keeps a bounce of 1 settling
|
|
16
|
+
const has = (x: unknown) => (typeof x === "number" ? Number.isFinite(x) : typeof x === "string" && x.trim() !== "" && Number.isFinite(+x)); // a number, or a numeric string — not `true`, `[]` or " "
|
|
17
|
+
const curveOf = (c: unknown): Curve | null => {
|
|
18
|
+
if (typeof c === "string") { const k = c === "linear" ? [0, 0, 1, 1] : KEYWORDS[c]; return k ? (k.slice() as Curve) : null; } // the CSS keywords, linear's identity included; null-prototype, so "constructor" is not one
|
|
19
|
+
if (Array.isArray(c) && c.length === 4) { const m = c.map(Number); return m.every(Number.isFinite) ? (m as Curve) : null; }
|
|
20
|
+
return null;
|
|
21
|
+
};
|
|
22
|
+
const springKeys = (v: MotionInput) => has(v.stiffness) || has(v.damping) || has(v.mass) || has(v.visualDuration) || has(v.bounce);
|
|
23
|
+
// Physics keys map to the visualDuration + bounce Motion would resolve to them (timeFromSpring). An explicit duration/bounce wins.
|
|
24
|
+
const springTime = (v: MotionInput) => {
|
|
25
|
+
const out: { mode: "time"; visualDuration?: number; bounce?: number } = { mode: "time" };
|
|
26
|
+
if (has(v.stiffness)) { const k = +v.stiffness!, m = has(v.mass) ? +v.mass! : 1, t = timeFromSpring(k, has(v.damping) ? +v.damping! : 2 * Math.sqrt(k * m), m); out.visualDuration = t.visualDuration; if (has(v.damping)) out.bounce = clamp(t.bounce, -1, BOUNCE_MAX); }
|
|
27
|
+
if (has(v.visualDuration)) out.visualDuration = +v.visualDuration!;
|
|
28
|
+
if (has(v.bounce)) out.bounce = clamp(+v.bounce!, -1, BOUNCE_MAX);
|
|
29
|
+
return out;
|
|
30
|
+
};
|
|
31
|
+
// A stiffness and damping the host wrote, kept as written rather than resolved through the editor's time pair, which
|
|
32
|
+
// rounds and clamps: unless a time pair rides along that isn't the one they resolve to (this control's own value back).
|
|
33
|
+
const exactSpring = (v: MotionInput): SpringOut | null => {
|
|
34
|
+
if (!has(v.stiffness) || !has(v.damping)) return null;
|
|
35
|
+
const k = +v.stiffness!, d = +v.damping!, m = has(v.mass) ? +v.mass! : 1;
|
|
36
|
+
if (!(k > 0 && d >= 0 && m > 0)) return null;
|
|
37
|
+
const t = timeFromSpring(k, d, m), off = (a: unknown, b: number) => has(a) && Math.abs(+a! - b) > 1e-6 * Math.max(1, Math.abs(b));
|
|
38
|
+
return off(v.visualDuration, t.visualDuration) || off(v.bounce, t.bounce) ? null : { stiffness: k, damping: d, mass: m, ...t };
|
|
39
|
+
};
|
|
40
|
+
const modeOf = (v: MotionInput, keep: Mode): Mode => (v.mode === "spring" || v.mode === "easing" ? v.mode : springKeys(v) ? "spring" : curveOf(v.curve) ? "easing" : keep); // a curve that doesn't parse is no reason to switch
|
|
41
|
+
|
|
42
|
+
function createMotion(meta: Meta, onChange: OnChange): Control {
|
|
43
|
+
const Bezier = getControl("cubicbezier"), Spring = getControl("spring");
|
|
44
|
+
if (!Bezier || !Spring) throw new Error("[tweaks] motion: the bezier and spring controls are not loaded");
|
|
45
|
+
const init: MotionInput = isObj(meta.value) ? meta.value : {};
|
|
46
|
+
let mode = modeOf(init, "easing");
|
|
47
|
+
let curve = curveOf(init.curve) ?? (KEYWORDS.ease.slice() as Curve), dur = has(init.duration) ? clamp(+init.duration!, 0, DUR_MAX) : DUR_DEF;
|
|
48
|
+
|
|
49
|
+
// ── Row — label, a mini curve, a summary. ──
|
|
50
|
+
const { root, trigger, right } = triggerRow("tw-motion", meta.label ?? "Motion"); // ??: an explicit "" label renders none
|
|
51
|
+
const chip = el("span", "tw-trigger-chip tw-motion-chip");
|
|
52
|
+
const chipSvg = svgEl("svg"); chipSvg.setAttribute("viewBox", "0 0 100 100"); chipSvg.setAttribute("preserveAspectRatio", "none");
|
|
53
|
+
const chipPath = svgEl("path"); chipSvg.append(chipPath); chip.append(chipSvg);
|
|
54
|
+
const valueEl = el("span", "tw-trigger-value");
|
|
55
|
+
right.append(chip, valueEl);
|
|
56
|
+
|
|
57
|
+
// ── Popover — the mode switch, the preview, one editor at a time. ──
|
|
58
|
+
const pop = el("div", "tw-color-pop tw-motion-pop"), body = el("div", "tw-motion-body"); pop.append(body);
|
|
59
|
+
const bezier = Bezier({ ...meta, type: "cubicbezier", label: "", value: curve.slice() } as Meta, (v) => { curve = v as Curve; update(); emit(); });
|
|
60
|
+
curve = bezier.get() as Curve; // as the editor fitted it, so no invalid cubic-bezier()
|
|
61
|
+
// Host-written physics the editor can't draw exactly: it shows the nearest, clamped here so it doesn't warn as well.
|
|
62
|
+
let exact: SpringOut | null = null, said = false;
|
|
63
|
+
const editorTime = (inp: MotionInput) => {
|
|
64
|
+
exact = exactSpring(inp);
|
|
65
|
+
if (!exact) return springTime(inp);
|
|
66
|
+
const vd = clamp(exact.visualDuration!, SPRING_DUR[0], SPRING_DUR[1]), z = exact.damping / (2 * Math.sqrt(exact.stiffness * exact.mass));
|
|
67
|
+
if ((vd !== exact.visualDuration || z < 0.05 || z > 20) && !said) { said = true; notice("springKept", [meta.key, exact.stiffness, exact.damping, exact.mass]); }
|
|
68
|
+
return { mode: "time" as const, visualDuration: vd, bounce: exact.bounce };
|
|
69
|
+
};
|
|
70
|
+
// Time mode only; its Mode row is hidden (tweaks.css). An edit there drops the kept physics.
|
|
71
|
+
const spring = Spring({ ...meta, type: "spring", label: "", value: editorTime(init) } as Meta, () => { exact = null; update(); emit(); });
|
|
72
|
+
const springNow = () => exact ?? (spring.get() as SpringOut);
|
|
73
|
+
const durFld = numField({ label: "Duration", value: dur, step: 10, min: 0, max: DUR_MAX, unit: "ms" }, (v) => { dur = clamp(v, 0, DUR_MAX); update(); emit(); });
|
|
74
|
+
dur = durFld.get(); // as the field snapped it
|
|
75
|
+
const easingPane = el("div", "tw-motion-pane"), springPane = el("div", "tw-motion-pane"), durRow = el("div", "tw-fields");
|
|
76
|
+
durRow.append(durFld.el); easingPane.append(bezier.el, durRow); springPane.append(spring.el);
|
|
77
|
+
const stage = el("div", "tw-motion-stage"), box = el("div", "tw-motion-box"); stage.append(box);
|
|
78
|
+
const modeSeg = createSegmented([{ value: "easing", label: "Easing" }, { value: "spring", label: "Spring" }], mode, (m: unknown) => { if ((m === "easing" || m === "spring") && m !== mode) { mode = m; showMode(); update(); emit(); } }, "Motion type");
|
|
79
|
+
const typeRow = el("div", "tw-row"); typeRow.append(txt("span", "tw-row-label", "Type"), modeSeg.el);
|
|
80
|
+
body.append(typeRow, easingPane, springPane, stage);
|
|
81
|
+
root.append(pop);
|
|
82
|
+
|
|
83
|
+
const value = (): MotionValue => {
|
|
84
|
+
if (mode === "easing") return { mode, curve: curve.slice() as Curve, duration: dur, easing: `cubic-bezier(${curve.join(", ")})` };
|
|
85
|
+
// The run lasts until the spring is at rest (the drawn curve stops at 2.2 s): springRun.
|
|
86
|
+
const s = springNow();
|
|
87
|
+
return { mode, ...s, ...springRun(s.stiffness, s.damping, s.mass) };
|
|
88
|
+
};
|
|
89
|
+
const emit = () => onChange(value());
|
|
90
|
+
|
|
91
|
+
// Web Animations, no frame loop to own. A click or an open flips direction and restarts; an edit lets a run in flight
|
|
92
|
+
// finish — a handle drag fires per pointer move, and restarting each strobed the box.
|
|
93
|
+
let far = false, anim: Animation | null = null;
|
|
94
|
+
const replay = (flip: boolean) => {
|
|
95
|
+
if (flip) far = !far; else if (anim && anim.playState === "running") return;
|
|
96
|
+
const v = value();
|
|
97
|
+
const travel = Math.max(0, stage.clientWidth - box.offsetWidth - 2 * STAGE_PAD); // measured now: the stage is 0 wide until the pop opens
|
|
98
|
+
const from = `translateX(${far ? 0 : travel}px)`, to = `translateX(${far ? travel : 0}px)`;
|
|
99
|
+
if (REDUCE_MOTION.matches || typeof box.animate !== "function") { box.style.transform = to; return; }
|
|
100
|
+
anim?.cancel();
|
|
101
|
+
const frames = [{ transform: from }, { transform: to }];
|
|
102
|
+
try { anim = box.animate(frames, { duration: v.duration, easing: v.easing, fill: "forwards" }); }
|
|
103
|
+
catch { anim = box.animate(frames, { duration: v.duration, fill: "forwards" }); } // no linear() support: a plain run rather than nothing
|
|
104
|
+
};
|
|
105
|
+
stage.addEventListener("click", () => replay(true));
|
|
106
|
+
stage.tabIndex = 0; stage.setAttribute("role", "button"); stage.setAttribute("aria-label", "Replay the motion");
|
|
107
|
+
stage.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); replay(true); } });
|
|
108
|
+
|
|
109
|
+
// The chip's mini curve, y up, scaled so an overshoot still fits.
|
|
110
|
+
const curvePath = () => {
|
|
111
|
+
let pts: [number, number][];
|
|
112
|
+
if (mode === "easing") { const [x1, y1, x2, y2] = curve; pts = []; for (let i = 0; i <= 24; i++) { const t = i / 24, u = 1 - t, a = 3 * u * u * t, b = 3 * u * t * t, c = t * t * t; pts.push([a * x1 + b * x2 + c, a * y1 + b * y2 + c]); } }
|
|
113
|
+
else { const s = springNow(); pts = springCurve(s.stiffness, s.damping, s.mass, 32).map((y, i) => [i / 31, y]); }
|
|
114
|
+
const hi = Math.max(1, ...pts.map((p) => p[1])), lo = Math.min(0, ...pts.map((p) => p[1]));
|
|
115
|
+
return pts.map(([x, y], i) => `${i ? "L" : "M"}${(10 + x * 80).toFixed(1)},${(90 - ((y - lo) / (hi - lo)) * 80).toFixed(1)}`).join(" ");
|
|
116
|
+
};
|
|
117
|
+
// The revealed editor built at 0×0 and onLive may have dropped its resize listener while the pop was detached, so
|
|
118
|
+
// re-point it at its own value, which redraws at real size.
|
|
119
|
+
const resync = () => { if (mode === "easing") bezier.set(curve.slice()); else spring.set(spring.get()); };
|
|
120
|
+
const showMode = () => { easingPane.style.display = mode === "easing" ? "" : "none"; springPane.style.display = mode === "spring" ? "" : "none"; resync(); };
|
|
121
|
+
// The summary shows the authored duration; a spring's emitted run (its settle) is longer.
|
|
122
|
+
const summary = () => (mode === "easing" ? `Easing · ${dur} ms` : `Spring · ${Math.round((springNow().visualDuration ?? 0) * 1000)} ms`);
|
|
123
|
+
const update = () => { chipPath.setAttribute("d", curvePath()); valueEl.textContent = summary(); replay(false); };
|
|
124
|
+
|
|
125
|
+
popover(root, trigger, pop, { fallbackH: 380, onOpen: () => { modeSeg.set(mode); resync(); replay(true); }, initialFocus: () => modeSeg.el.querySelector<HTMLElement>('[tabindex="0"]') }); // hidden until open: measure now; focus the checked option
|
|
126
|
+
showMode(); update();
|
|
127
|
+
|
|
128
|
+
// Set/restore: either mode's shape or a bare keyword; derived fields (easing, a spring's duration) are ignored.
|
|
129
|
+
const set = (v: unknown) => {
|
|
130
|
+
const inp: MotionInput | null = typeof v === "string" ? { curve: v } : Array.isArray(v) && v.length === 4 ? { curve: v as Curve } : isObj(v) ? v : null; // a bare 4-array is the bezier's own shape
|
|
131
|
+
if (!inp) return;
|
|
132
|
+
mode = modeOf(inp, mode);
|
|
133
|
+
const c = curveOf(inp.curve); if (c) { bezier.set(c.slice()); curve = bezier.get() as Curve; } // read back fitted
|
|
134
|
+
if (mode === "easing" && has(inp.duration)) { durFld.set(clamp(+inp.duration!, 0, DUR_MAX)); dur = durFld.get(); } // read back snapped
|
|
135
|
+
if (springKeys(inp)) spring.set(editorTime(inp));
|
|
136
|
+
modeSeg.set(mode); showMode(); update();
|
|
137
|
+
};
|
|
138
|
+
return { el: root, set, get: value, destroy: () => spring.destroy && spring.destroy() };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
registerControl("motion", createMotion);
|
|
@@ -0,0 +1,220 @@
|
|
|
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";
|
|
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, cos: Math.cos, tan: Math.tan, asin: Math.asin, acos: Math.acos,
|
|
17
|
+
atan: Math.atan, atan2: Math.atan2, sinh: Math.sinh, cosh: Math.cosh, tanh: Math.tanh,
|
|
18
|
+
sqrt: Math.sqrt, cbrt: Math.cbrt, abs: Math.abs, exp: Math.exp, log: Math.log, ln: Math.log,
|
|
19
|
+
log10: Math.log10, log2: Math.log2, floor: Math.floor, ceil: Math.ceil, round: Math.round,
|
|
20
|
+
sign: Math.sign, trunc: Math.trunc, min: Math.min, max: Math.max, pow: Math.pow, hypot: Math.hypot,
|
|
21
|
+
mod: (a: number, b: number) => a % b, clamp: (v: number, lo: number, hi: number) => Math.min(Math.max(v, lo), hi),
|
|
22
|
+
});
|
|
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
|
+
/** The compiled expression, or null on any parse error. */
|
|
26
|
+
export function compileExpr(src: unknown): Expr | null {
|
|
27
|
+
const r = parseExpr(src); return typeof r === "function" ? r : null;
|
|
28
|
+
}
|
|
29
|
+
/** The compiled expression, or why it doesn't parse; the reason names the fix where there is one ("write sin(…)"). */
|
|
30
|
+
export function parseExpr(src: unknown): Expr | string {
|
|
31
|
+
if (typeof src !== "string" || !src.trim()) return "type an expression in x";
|
|
32
|
+
if (src.length > 512) return "too long: 512 characters at most"; // also bounds recursion depth, so no RangeError mid-parse
|
|
33
|
+
const s = src, toks: Tok[] = [];
|
|
34
|
+
// `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` is
|
|
35
|
+
// 20, while `2e+x` is the constant, 2·e + x, as `2e` alone is 2·e. Scientific notation never has a name for its power.
|
|
36
|
+
const numRe = /(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?/y, idRe = /[A-Za-z_]\w*/y;
|
|
37
|
+
let i = 0;
|
|
38
|
+
while (i < s.length) {
|
|
39
|
+
const ch = s[i];
|
|
40
|
+
if (ch === " " || ch === "\t" || ch === "\n") { i++; continue; }
|
|
41
|
+
if ((ch >= "0" && ch <= "9") || (ch === "." && s[i + 1] >= "0" && s[i + 1] <= "9")) {
|
|
42
|
+
numRe.lastIndex = i; const m = numRe.exec(s); if (!m || m.index !== i) return `unexpected "${ch}"`;
|
|
43
|
+
toks.push({ t: "num", v: parseFloat(m[0]) }); i = numRe.lastIndex; continue;
|
|
44
|
+
}
|
|
45
|
+
if ((ch >= "A" && ch <= "Z") || (ch >= "a" && ch <= "z") || ch === "_") {
|
|
46
|
+
idRe.lastIndex = i; const m = idRe.exec(s); toks.push({ t: "id", v: m[0] }); i = idRe.lastIndex; continue;
|
|
47
|
+
}
|
|
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
|
+
return `unexpected "${ch}"`;
|
|
54
|
+
}
|
|
55
|
+
let p = 0, why = "";
|
|
56
|
+
const fail = (m: string): null => { why = why || m; return null; }; // the innermost reason wins; every caller unwinds on null
|
|
57
|
+
const peek = () => toks[p];
|
|
58
|
+
const eat = (t?: Tok["t"]) => { const tok = toks[p]; if (!tok || (t && tok.t !== t)) return null; p++; return tok; };
|
|
59
|
+
const add = (): Expr | null => {
|
|
60
|
+
let a = mul(); if (!a) return null;
|
|
61
|
+
while (peek() && peek().t === "op" && (peek().v === "+" || peek().v === "-")) {
|
|
62
|
+
const op = eat("op").v, b = mul(); if (!b) return null; const aa = a;
|
|
63
|
+
a = op === "+" ? (x) => aa(x) + b(x) : (x) => aa(x) - b(x);
|
|
64
|
+
}
|
|
65
|
+
return a;
|
|
66
|
+
};
|
|
67
|
+
const mul = (): Expr | null => {
|
|
68
|
+
let a = unary(); if (!a) return null;
|
|
69
|
+
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 before
|
|
71
|
+
// a name or "(" multiplies, as on paper. x and the constants are never functions, so `x(2)` is 2x; `xy` stays one
|
|
72
|
+
// (unknown) name, and a number after an operand (`2 3`, `(x)2`) asks for an operator. It binds as `*` does: `2x^2` is 2·x².
|
|
73
|
+
const prev = toks[p - 1].t, implied = (prev === "num" || prev === "id" || prev === "rp") && (t.t === "id" || t.t === "lp");
|
|
74
|
+
if (!implied && !(t.t === "op" && (t.v === "*" || t.v === "/" || t.v === "%"))) break;
|
|
75
|
+
const op = implied ? "*" : eat("op").v, b = unary(); if (!b) return null; const aa = a;
|
|
76
|
+
a = op === "*" ? (x) => aa(x) * b(x) : op === "/" ? (x) => aa(x) / b(x) : (x) => aa(x) % b(x);
|
|
77
|
+
}
|
|
78
|
+
return a;
|
|
79
|
+
};
|
|
80
|
+
const unary = (): Expr | null => {
|
|
81
|
+
if (peek() && peek().t === "op" && (peek().v === "-" || peek().v === "+")) {
|
|
82
|
+
const op = eat("op").v, a = unary(); if (!a) return null;
|
|
83
|
+
return op === "-" ? (x) => -a(x) : a;
|
|
84
|
+
}
|
|
85
|
+
return pow();
|
|
86
|
+
};
|
|
87
|
+
const pow = (): Expr | null => {
|
|
88
|
+
const a = atom(); if (!a) return null;
|
|
89
|
+
if (peek() && peek().t === "op" && peek().v === "^") { eat("op"); const b = unary(); if (!b) return null; return (x) => Math.pow(a(x), b(x)); }
|
|
90
|
+
return a;
|
|
91
|
+
};
|
|
92
|
+
const atom = (): Expr | null => {
|
|
93
|
+
const tok = peek(); if (!tok) return null;
|
|
94
|
+
if (tok.t === "num") { eat("num"); return () => tok.v; }
|
|
95
|
+
if (tok.t === "lp") { eat("lp"); const e = add(); if (!e || !eat("rp")) return null; return e; }
|
|
96
|
+
if (tok.t === "id") {
|
|
97
|
+
eat("id"); const name = tok.v.toLowerCase(), fn = PLOT_FUNCS[name];
|
|
98
|
+
if (peek() && peek().t === "lp" && (fn || (name !== "x" && !(name in PLOT_CONSTS)))) { // `x(2)`: a product, in mul()
|
|
99
|
+
if (!fn) return fail(`unknown function "${tok.v}"`);
|
|
100
|
+
eat("lp"); const argv: Expr[] = [];
|
|
101
|
+
if (peek() && peek().t !== "rp") { do { const a = add(); if (!a) return null; argv.push(a); } while (eat("comma")); }
|
|
102
|
+
if (!eat("rp")) return null;
|
|
103
|
+
if (!argv.length && PLOT_VARIADIC.includes(name)) return fail(`${name}() needs at least one value`);
|
|
104
|
+
return (x) => fn(...argv.map((a) => a(x)));
|
|
105
|
+
}
|
|
106
|
+
if (fn) {
|
|
107
|
+
// `sin x`, `cos 2x`: a one-argument function takes a bare x, constant or number-times-name, as a calculator does.
|
|
108
|
+
// Anything longer (`sin x^2`, `sin -x`, `sin 2 cos x`) reads two ways, so it asks for the parentheses instead of guessing.
|
|
109
|
+
const a = fn.length === 1 ? bare() : null, n = peek();
|
|
110
|
+
if (!a || (n && (n.t === "op" ? n.v === "^" : n.t !== "rp" && n.t !== "comma"))) return fail(`write ${name}(…)`);
|
|
111
|
+
return (x) => fn(a(x));
|
|
112
|
+
}
|
|
113
|
+
if (name === "x") return (x) => x;
|
|
114
|
+
if (name in PLOT_CONSTS) { const c = PLOT_CONSTS[name]; return () => c; }
|
|
115
|
+
return fail(`unknown name "${tok.v}"`); // an identifier that isn't x, a constant, or a known function
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
};
|
|
119
|
+
const named = (t: Tok) => t && t.t === "id" && !PLOT_FUNCS[t.v.toLowerCase()];
|
|
120
|
+
const bare = (): Expr | null => {
|
|
121
|
+
const t = peek();
|
|
122
|
+
if (t && t.t === "num") { eat("num"); if (!named(peek())) return () => t.v; const b = atom(); return b && ((x) => t.v * b(x)); }
|
|
123
|
+
return named(t) ? atom() : null;
|
|
124
|
+
};
|
|
125
|
+
const tree = add(), left = toks[p];
|
|
126
|
+
if (tree && !left) return tree;
|
|
127
|
+
if (why) return why;
|
|
128
|
+
// A parse that stopped short, or tokens left over: say where.
|
|
129
|
+
const last = toks[p - 1], unclosed = toks.slice(0, p).reduce((d, t) => d + (t.t === "lp" ? 1 : t.t === "rp" ? -1 : 0), 0);
|
|
130
|
+
if (!tree && last && last.t === "op") return `"${last.v}" needs a value on each side`;
|
|
131
|
+
if (!left) return unclosed > 0 ? 'missing ")"' : "not a valid expression";
|
|
132
|
+
return left.t === "num" ? `missing an operator before ${left.v}`
|
|
133
|
+
: left.t === "rp" ? (last && last.t === "lp" ? "nothing inside the parentheses" : 'unmatched ")"')
|
|
134
|
+
: left.t === "comma" ? '"," outside a function\'s parentheses'
|
|
135
|
+
: left.t === "op" ? `"${left.v}" needs a value on each side` : "not a valid expression";
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ── Plot — y = f(x) across a range with an editable expression, or a fixed `fn`. Y auto-ranges unless yMin/yMax are
|
|
139
|
+
// given; the line breaks across asymptotes. ──
|
|
140
|
+
function createPlot(meta: Meta, onChange: OnChange): Control {
|
|
141
|
+
// Non-finite bounds take the defaults, an inverted pair swaps, an equal pair pads out (÷0 drew nothing); a degenerate y-range falls back to auto.
|
|
142
|
+
let xMin = Number(meta.xMin ?? meta.min), xMax = Number(meta.xMax ?? meta.max); // min/max are the verbose form's aliases
|
|
143
|
+
if (!Number.isFinite(xMin)) xMin = -10;
|
|
144
|
+
if (!Number.isFinite(xMax)) xMax = 10;
|
|
145
|
+
if (xMax < xMin) { const t = xMin; xMin = xMax; xMax = t; }
|
|
146
|
+
if (xMax === xMin) { xMin -= 1; xMax += 1; }
|
|
147
|
+
const samples = Math.max(2, Math.min(4096, (meta.samples | 0) || 256)); // capped: 1e8 samples per keystroke froze the tab
|
|
148
|
+
const fixedY = Number.isFinite(meta.yMin) && Number.isFinite(meta.yMax) && +meta.yMax > +meta.yMin;
|
|
149
|
+
const fn = typeof meta.fn === "function" ? meta.fn : null;
|
|
150
|
+
const editable = meta.editable !== false && !fn;
|
|
151
|
+
let expr = meta.expr != null ? String(meta.expr) : fn ? "" : "sin(x)"; // a sine to start from
|
|
152
|
+
let compiled = fn || parseExpr(expr); // a string is the reason it doesn't parse
|
|
153
|
+
|
|
154
|
+
const root = el("div", "tw-plot");
|
|
155
|
+
if (meta.label) root.append(txt("div", "tw-plot-label", meta.label));
|
|
156
|
+
const graph = el("div", "tw-plot-graph");
|
|
157
|
+
const svg = svgEl("svg", "tw-plot-svg"); svg.setAttribute("preserveAspectRatio", "none");
|
|
158
|
+
const axisX = svgEl("line", "tw-plot-axis"), axisY = svgEl("line", "tw-plot-axis"), curve = svgEl("path", "tw-plot-curve");
|
|
159
|
+
svg.append(axisX, axisY, curve); graph.append(svg); root.append(graph);
|
|
160
|
+
|
|
161
|
+
let input: HTMLInputElement | null = null;
|
|
162
|
+
if (editable) {
|
|
163
|
+
const field = el("div", "tw-plot-field");
|
|
164
|
+
input = el("input", "tw-plot-input"); input.type = "text"; input.value = expr; input.spellcheck = false;
|
|
165
|
+
input.autocapitalize = "off"; quietFocus(input); /* click-to-edit stays ringless; Tab rings the field */ input.setAttribute("aria-label", `${meta.label || "Plot"} — expression in x`); input.setAttribute("aria-invalid", "false");
|
|
166
|
+
field.append(txt("span", "tw-plot-fx", "y ="), input); root.append(field); // txt, not el(…, html): el's third arg is innerHTML
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const PAD = 6;
|
|
170
|
+
const draw = () => {
|
|
171
|
+
// Flag invalid before the size guard, so a hidden tab page still reports a bad expression.
|
|
172
|
+
const f = typeof compiled === "function" ? compiled : null, ok = !!f;
|
|
173
|
+
root.classList.toggle("is-invalid", editable && !ok);
|
|
174
|
+
if (input) { input.setAttribute("aria-invalid", ok ? "false" : "true"); input.title = ok ? "" : String(compiled); } // the title says why, and names the fix
|
|
175
|
+
const r = graph.getBoundingClientRect(), W = r.width, H = r.height; if (W < 2) return;
|
|
176
|
+
svg.setAttribute("viewBox", `0 0 ${W} ${H}`);
|
|
177
|
+
const ys: number[] = []; let yLo = Infinity, yHi = -Infinity;
|
|
178
|
+
if (ok) for (let k = 0; k < samples; k++) {
|
|
179
|
+
const x = xMin + (xMax - xMin) * (k / (samples - 1));
|
|
180
|
+
let y: number; try { y = f(x); } catch { y = NaN; }
|
|
181
|
+
ys.push(y);
|
|
182
|
+
if (Number.isFinite(y)) { if (y < yLo) yLo = y; if (y > yHi) yHi = y; }
|
|
183
|
+
}
|
|
184
|
+
let lo = meta.yMin, hi = meta.yMax;
|
|
185
|
+
if (!fixedY) {
|
|
186
|
+
if (!Number.isFinite(yLo) || yLo === yHi) { const c = Number.isFinite(yLo) ? yLo : 0; lo = c - 1; hi = c + 1; }
|
|
187
|
+
else { const pad = (yHi - yLo) * 0.12; lo = yLo - pad; hi = yHi + pad; }
|
|
188
|
+
}
|
|
189
|
+
const xPx = (x: number) => PAD + ((x - xMin) / (xMax - xMin)) * (W - 2 * PAD);
|
|
190
|
+
const yPx = (y: number) => (H - PAD) - ((y - lo) / (hi - lo)) * (H - 2 * PAD);
|
|
191
|
+
const axis = (ln: Built<SVGLineElement>, ax: number, ay: number, bx: number, by: number, show: boolean) => { ln.style.display = show ? "" : "none"; if (show) { ln.setAttribute("x1", ax); ln.setAttribute("y1", ay); ln.setAttribute("x2", bx); ln.setAttribute("y2", by); } };
|
|
192
|
+
axis(axisX, PAD, yPx(0), W - PAD, yPx(0), lo <= 0 && hi >= 0);
|
|
193
|
+
axis(axisY, xPx(0), PAD, xPx(0), H - PAD, xMin <= 0 && xMax >= 0);
|
|
194
|
+
let d = "", pen = false;
|
|
195
|
+
if (ok) for (let k = 0; k < samples; k++) {
|
|
196
|
+
const y = ys[k];
|
|
197
|
+
if (!Number.isFinite(y)) { pen = false; continue; }
|
|
198
|
+
const px = xPx(xMin + (xMax - xMin) * (k / (samples - 1))), py = clamp(yPx(y), -1e4, 1e4);
|
|
199
|
+
d += (pen ? "L" : "M") + px.toFixed(1) + "," + py.toFixed(1) + " "; pen = true;
|
|
200
|
+
}
|
|
201
|
+
curve.setAttribute("d", d);
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
if (editable) input.addEventListener("input", () => {
|
|
205
|
+
expr = input.value; compiled = parseExpr(expr); // a reason string on a parse error → draw flags is-invalid
|
|
206
|
+
draw(); onChange(expr);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
onReady(draw);
|
|
210
|
+
onLive(root, [[window, "resize"], [window, "tw-reflow"]], draw); // tw-reflow: a tab page revealing this re-measures it
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
el: root,
|
|
214
|
+
get: () => expr,
|
|
215
|
+
set: (v: unknown) => { if (v == null) return; expr = String(v); if (input) input.value = expr; if (!fn) compiled = parseExpr(expr); draw(); }, // an invalid expression flags, as typing does
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
registerControl("plot", createPlot);
|
|
220
|
+
|