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,189 @@
|
|
|
1
|
+
// ── Interval / range slider — dual-handle [lo,hi]. Lazy.
|
|
2
|
+
import {
|
|
3
|
+
el,
|
|
4
|
+
txt,
|
|
5
|
+
clamp,
|
|
6
|
+
words,
|
|
7
|
+
roundToStep,
|
|
8
|
+
stepPrecision,
|
|
9
|
+
gridEnds,
|
|
10
|
+
normalizeRange,
|
|
11
|
+
rangeStep,
|
|
12
|
+
overlapsText,
|
|
13
|
+
wireHoverClass,
|
|
14
|
+
onReady,
|
|
15
|
+
onLive,
|
|
16
|
+
registerControl,
|
|
17
|
+
rubberBand,
|
|
18
|
+
rubberRelease,
|
|
19
|
+
} from "../shared.js";
|
|
20
|
+
import { dragGesture } from "../heavy.js";
|
|
21
|
+
import type { OnChange } from "../shared.js";
|
|
22
|
+
import type { Meta } from "../schema.js";
|
|
23
|
+
import type { Control } from "../types.js";
|
|
24
|
+
|
|
25
|
+
function createInterval(meta: Meta, onChange: OnChange): Control {
|
|
26
|
+
const label = meta.label;
|
|
27
|
+
const t0 = +(meta.value && meta.value[0]),
|
|
28
|
+
t1 = +(meta.value && meta.value[1]);
|
|
29
|
+
const lo0 = Number.isFinite(t0) ? t0 : 0,
|
|
30
|
+
hi0 = Number.isFinite(t1) ? t1 : 1;
|
|
31
|
+
// normalizeRange, the slider's guard: an absent or non-finite bound (data-min="abc") derives from the values, each
|
|
32
|
+
// end widening it, for the shorthand and the verbose form alike: [20, 80] spans 0–240, not 20–80 (which pinned both
|
|
33
|
+
// handles).
|
|
34
|
+
let { min, max, step } = normalizeRange(meta.min, meta.max, meta.step, [lo0, hi0], label);
|
|
35
|
+
[min, max] = gridEnds(min, max, step); // the slider's rule: no value past an off-grid bound
|
|
36
|
+
const decimals = stepPrecision(step);
|
|
37
|
+
const q = (v: number) => roundToStep(v, min, step);
|
|
38
|
+
let lo = 0,
|
|
39
|
+
hi = 0;
|
|
40
|
+
// ordered, on the grid, in bounds
|
|
41
|
+
const put = (a: number, b: number) => {
|
|
42
|
+
lo = clamp(q(Math.min(a, b)), min, max);
|
|
43
|
+
hi = clamp(q(Math.max(a, b)), min, max);
|
|
44
|
+
};
|
|
45
|
+
// Missing tuple entries fall back to the bounds: value:[5] gives [5, max], not [5, NaN].
|
|
46
|
+
put(Number.isFinite(t0) ? t0 : min, Number.isFinite(t1) ? t1 : max);
|
|
47
|
+
|
|
48
|
+
const wrap = el("div", "twk-slider-wrap");
|
|
49
|
+
const track = el("div", "twk-slider twk-interval");
|
|
50
|
+
const fill = el("div", "twk-slider-fill");
|
|
51
|
+
const hLo = el("div", "twk-slider-handle"),
|
|
52
|
+
hHi = el("div", "twk-slider-handle");
|
|
53
|
+
const labelEl = txt("span", "twk-slider-label", label);
|
|
54
|
+
const valueEl = el("span", "twk-slider-value");
|
|
55
|
+
track.append(fill, hLo, hHi, labelEl, valueEl);
|
|
56
|
+
wrap.append(track);
|
|
57
|
+
|
|
58
|
+
const tr = words();
|
|
59
|
+
(
|
|
60
|
+
[
|
|
61
|
+
["intervalMin", "{label} minimum", hLo],
|
|
62
|
+
["intervalMax", "{label} maximum", hHi],
|
|
63
|
+
] as const
|
|
64
|
+
).forEach(([k, d, h]) => {
|
|
65
|
+
h.tabIndex = 0;
|
|
66
|
+
h.setAttribute("role", "slider");
|
|
67
|
+
h.setAttribute("aria-label", tr(k, d, { label }));
|
|
68
|
+
h.setAttribute("aria-valuemin", String(min));
|
|
69
|
+
h.setAttribute("aria-valuemax", String(max));
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const pctOf = (v: number) => ((v - min) / (max - min || 1)) * 100;
|
|
73
|
+
// stay inset at the extremes, like the slider handle
|
|
74
|
+
const handleLeft = (pct: number) => `clamp(5px, calc(${pct}% - 1.5px), calc(100% - 9px))`;
|
|
75
|
+
const render = () => {
|
|
76
|
+
const a = pctOf(lo),
|
|
77
|
+
b = pctOf(hi);
|
|
78
|
+
fill.style.left = a + "%";
|
|
79
|
+
fill.style.width = Math.max(0, b - a) + "%";
|
|
80
|
+
hLo.style.left = handleLeft(a);
|
|
81
|
+
hHi.style.left = handleLeft(b);
|
|
82
|
+
valueEl.textContent = `${lo.toFixed(decimals)} – ${hi.toFixed(decimals)}`;
|
|
83
|
+
hLo.setAttribute("aria-valuenow", String(lo));
|
|
84
|
+
hHi.setAttribute("aria-valuenow", String(hi));
|
|
85
|
+
// Value-dodge (overlapsText), per handle: one sliding under the label or the readout yields so the text stays
|
|
86
|
+
// legible.
|
|
87
|
+
const trackW = wrap.offsetWidth;
|
|
88
|
+
if (trackW) {
|
|
89
|
+
// The label's room is what the dual readout leaves, measured: 12px label inset + 8px gap + the readout's right
|
|
90
|
+
// inset.
|
|
91
|
+
labelEl.style.maxWidth = Math.max(0, trackW - valueEl.offsetWidth - 12 - 8 - 10) + "px";
|
|
92
|
+
const dodges = (pct: number) => overlapsText(labelEl, valueEl, (pct / 100) * trackW - 1.5, 3);
|
|
93
|
+
hLo.classList.toggle("is-dodge", dodges(pctOf(lo)));
|
|
94
|
+
hHi.classList.toggle("is-dodge", dodges(pctOf(hi)));
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
render();
|
|
98
|
+
|
|
99
|
+
const emit = () => onChange([lo, hi]);
|
|
100
|
+
const setLo = (v: number) => {
|
|
101
|
+
lo = clamp(Math.min(q(v), hi), min, max);
|
|
102
|
+
render();
|
|
103
|
+
};
|
|
104
|
+
const setHi = (v: number) => {
|
|
105
|
+
hi = clamp(Math.max(q(v), lo), min, max);
|
|
106
|
+
render();
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
let rect: DOMRect | null = null,
|
|
110
|
+
scale = 1,
|
|
111
|
+
active: "lo" | "hi" | null = null;
|
|
112
|
+
// Divide out any ancestor CSS transform (rect is visual px, offsetWidth layout px), as the single slider does.
|
|
113
|
+
const valFromX = (x: number) => {
|
|
114
|
+
const native = wrap.offsetWidth || rect.width;
|
|
115
|
+
const p = clamp((x - rect.left) / scale / native, 0, 1);
|
|
116
|
+
return clamp(min + p * (max - min), min, max);
|
|
117
|
+
};
|
|
118
|
+
const grab = (x: number) => {
|
|
119
|
+
const v = valFromX(x);
|
|
120
|
+
if (!active) active = v < lo ? "lo" : v > hi ? "hi" : Math.abs(v - lo) <= Math.abs(v - hi) ? "lo" : "hi";
|
|
121
|
+
active === "lo" ? setLo(v) : setHi(v);
|
|
122
|
+
emit();
|
|
123
|
+
};
|
|
124
|
+
dragGesture(track, {
|
|
125
|
+
onDown: (e) => {
|
|
126
|
+
e.preventDefault();
|
|
127
|
+
rect = wrap.getBoundingClientRect();
|
|
128
|
+
scale = rect.width / (wrap.offsetWidth || rect.width);
|
|
129
|
+
track.style.transition = ""; // a re-press mid spring-home stretches 1:1
|
|
130
|
+
track.classList.add("is-active", "is-dragging");
|
|
131
|
+
active = null;
|
|
132
|
+
grab(e.clientX);
|
|
133
|
+
// preventDefault killed click-to-focus; focus without a ring
|
|
134
|
+
(active === "lo" ? hLo : hHi).focus({ focusVisible: false });
|
|
135
|
+
},
|
|
136
|
+
onMove: (e) => {
|
|
137
|
+
grab(e.clientX);
|
|
138
|
+
rubberBand(track, rect, e.clientX);
|
|
139
|
+
},
|
|
140
|
+
onEnd: () => {
|
|
141
|
+
rubberRelease(track);
|
|
142
|
+
rect = null;
|
|
143
|
+
active = null;
|
|
144
|
+
track.classList.remove("is-active", "is-dragging");
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
wireHoverClass(track, render); // re-run the dodge with the real track width on hover
|
|
148
|
+
// Recompute once layout and fonts settle (a web-font swap shifts the widths the dodge measures) and on track-width
|
|
149
|
+
// change.
|
|
150
|
+
onReady(render);
|
|
151
|
+
// twk-reflow: a tab page revealing this re-measures it
|
|
152
|
+
onLive(
|
|
153
|
+
track,
|
|
154
|
+
[
|
|
155
|
+
[window, "resize"],
|
|
156
|
+
[window, "twk-reflow"],
|
|
157
|
+
],
|
|
158
|
+
render,
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
// Home/End snap a handle to its neighbor-or-limit, so the two can't cross.
|
|
162
|
+
const page = (max - min) / 10 || step * 10; // PageUp/Down: a tenth of the range, as the slider jumps
|
|
163
|
+
const onKey = (which: "lo" | "hi") => (e: KeyboardEvent) => {
|
|
164
|
+
const nv = which === "lo" ? rangeStep(e, lo, step, min, hi, page) : rangeStep(e, hi, step, lo, max, page);
|
|
165
|
+
if (nv == null) return;
|
|
166
|
+
e.preventDefault();
|
|
167
|
+
which === "lo" ? setLo(nv) : setHi(nv);
|
|
168
|
+
emit();
|
|
169
|
+
};
|
|
170
|
+
hLo.addEventListener("keydown", onKey("lo"));
|
|
171
|
+
hHi.addEventListener("keydown", onKey("hi"));
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
el: wrap,
|
|
175
|
+
set: (v: unknown) => {
|
|
176
|
+
if (Array.isArray(v)) {
|
|
177
|
+
const a = +v[0],
|
|
178
|
+
b = +v[1];
|
|
179
|
+
if (!Number.isFinite(a) || !Number.isFinite(b)) return;
|
|
180
|
+
put(a, b);
|
|
181
|
+
render();
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
get: () => [lo, hi],
|
|
185
|
+
range: [min, max],
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
registerControl("interval", createInterval);
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// ── Monitor + FPS graph — live sparkline / readout. Lazy; registers both types.
|
|
2
|
+
import { el, txt, clamp, blade, json, registerControl, words } 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 = (
|
|
8
|
+
ctx: CanvasRenderingContext2D,
|
|
9
|
+
node: Element,
|
|
10
|
+
w: number,
|
|
11
|
+
h: number,
|
|
12
|
+
samples: number[],
|
|
13
|
+
start: number,
|
|
14
|
+
frac: (s: number) => number,
|
|
15
|
+
) => {
|
|
16
|
+
const N = samples.length;
|
|
17
|
+
ctx.clearRect(0, 0, w, h);
|
|
18
|
+
ctx.strokeStyle = accentColor(node);
|
|
19
|
+
ctx.lineWidth = 1.5;
|
|
20
|
+
ctx.lineJoin = "round";
|
|
21
|
+
ctx.beginPath();
|
|
22
|
+
let pen = false;
|
|
23
|
+
for (let k = 0; k < N; k++) {
|
|
24
|
+
const s = samples[(start + k) % N];
|
|
25
|
+
if (Number.isNaN(s)) continue;
|
|
26
|
+
const x = (k / (N - 1)) * w,
|
|
27
|
+
y = h - clamp(frac(s), 0, 1) * h;
|
|
28
|
+
pen ? ctx.lineTo(x, y) : (ctx.moveTo(x, y), (pen = true));
|
|
29
|
+
}
|
|
30
|
+
ctx.stroke();
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// Manual, not onLive: these blades idle through the built-but-not-mounted window, which onLive would read as gone.
|
|
34
|
+
const watchReflow = (fn: () => void) => {
|
|
35
|
+
for (const t of ["resize", "twk-reflow"]) window.addEventListener(t, fn);
|
|
36
|
+
return () => {
|
|
37
|
+
for (const t of ["resize", "twk-reflow"]) window.removeEventListener(t, fn);
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// ── Monitor — a number as a sparkline or a readout, a string as a buffer of the last few values; read on an interval
|
|
42
|
+
// or, `perFrame` (the FPS graph), once per animation frame. ──
|
|
43
|
+
function createMonitor(meta: Meta, perFrame = false) {
|
|
44
|
+
const get: (now?: number) => unknown = typeof meta.get === "function" ? meta.get : () => meta.value;
|
|
45
|
+
// NaN would make setInterval a 0 ms busy-poll
|
|
46
|
+
const interval = Math.max(30, Number.isFinite(+meta.interval) ? +meta.interval : 200);
|
|
47
|
+
let probe: unknown;
|
|
48
|
+
try {
|
|
49
|
+
probe = get();
|
|
50
|
+
} catch {}
|
|
51
|
+
const isNum = typeof probe === "number";
|
|
52
|
+
const graph = meta.view === "graph" || (isNum && meta.graph !== false && meta.view !== "text" && meta.rows == null);
|
|
53
|
+
|
|
54
|
+
const wrap = el("div", "twk-fps twk-monitor");
|
|
55
|
+
const val = txt("span", "twk-fps-val", "—");
|
|
56
|
+
wrap.append(txt("span", "twk-fps-label", meta.label ?? words()("monitor", "Monitor")), val);
|
|
57
|
+
|
|
58
|
+
let timer = 0,
|
|
59
|
+
raf = 0,
|
|
60
|
+
unwatch = () => {},
|
|
61
|
+
wasConnected = false;
|
|
62
|
+
// toFixed throws past 100; a missing value reads as a dash.
|
|
63
|
+
const decimals = Number.isFinite(+meta.decimals) ? Math.min(20, Math.max(0, Math.floor(+meta.decimals))) : 2;
|
|
64
|
+
const fmt = (v: unknown) =>
|
|
65
|
+
v == null
|
|
66
|
+
? "—"
|
|
67
|
+
: typeof v === "number"
|
|
68
|
+
? Number.isInteger(v) || !Number.isFinite(v)
|
|
69
|
+
? String(v)
|
|
70
|
+
: v.toFixed(decimals)
|
|
71
|
+
: typeof v === "object"
|
|
72
|
+
? json(v)
|
|
73
|
+
: String(v);
|
|
74
|
+
// Also the blade's `destroy`: a panel torn down before it ever connected can never self-stop below.
|
|
75
|
+
const stop = () => {
|
|
76
|
+
if (timer) clearInterval(timer);
|
|
77
|
+
timer = 0;
|
|
78
|
+
if (raf) cancelAnimationFrame(raf);
|
|
79
|
+
raf = 0;
|
|
80
|
+
unwatch();
|
|
81
|
+
};
|
|
82
|
+
// Never-mounted-yet idles the tick; only a mount-then-remove, or destroy(), stops it. The next frame is booked before
|
|
83
|
+
// the read, so a stop() inside the tick cancels it.
|
|
84
|
+
const poll = (fn: (v: unknown) => void) => {
|
|
85
|
+
const tick = (now?: number) => {
|
|
86
|
+
if (perFrame) raf = requestAnimationFrame(tick);
|
|
87
|
+
if (!wrap.isConnected) {
|
|
88
|
+
if (wasConnected) stop();
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
wasConnected = true;
|
|
92
|
+
let v: unknown;
|
|
93
|
+
try {
|
|
94
|
+
v = get(now);
|
|
95
|
+
} catch {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
fn(v);
|
|
99
|
+
};
|
|
100
|
+
if (perFrame) raf = requestAnimationFrame(tick);
|
|
101
|
+
else timer = setInterval(tick, interval);
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
if (!graph && meta.rows) {
|
|
105
|
+
val.remove();
|
|
106
|
+
// A negative or NaN rows spun the trim loop forever.
|
|
107
|
+
const rows = Math.max(1, Math.floor(+meta.rows) || 1);
|
|
108
|
+
const buf = el("pre", "twk-monitor-buffer");
|
|
109
|
+
buf.style.setProperty("--twk-monitor-rows", rows);
|
|
110
|
+
wrap.append(buf);
|
|
111
|
+
const lines: string[] = [];
|
|
112
|
+
poll((v) => {
|
|
113
|
+
lines.push(fmt(v));
|
|
114
|
+
while (lines.length > rows) lines.shift();
|
|
115
|
+
buf.textContent = lines.join("\n");
|
|
116
|
+
});
|
|
117
|
+
return blade(wrap, stop);
|
|
118
|
+
}
|
|
119
|
+
const readout = (v: unknown) => {
|
|
120
|
+
val.textContent = fmt(v);
|
|
121
|
+
};
|
|
122
|
+
if (!graph) {
|
|
123
|
+
poll(readout);
|
|
124
|
+
return blade(wrap, stop);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const canvas = document.createElement("canvas");
|
|
128
|
+
canvas.className = "twk-fps-canvas";
|
|
129
|
+
wrap.append(canvas);
|
|
130
|
+
const ctx = canvas.getContext("2d");
|
|
131
|
+
// No 2D context (a headless DOM, a blocked canvas) degrades to the text readout.
|
|
132
|
+
if (!ctx) {
|
|
133
|
+
poll(readout);
|
|
134
|
+
return blade(wrap, stop);
|
|
135
|
+
}
|
|
136
|
+
const N = 80,
|
|
137
|
+
samples: number[] = new Array(N).fill(NaN);
|
|
138
|
+
let idx = 0,
|
|
139
|
+
w = 0,
|
|
140
|
+
h = 0;
|
|
141
|
+
const onResize = () => {
|
|
142
|
+
[w, h] = fitCanvas(canvas, ctx, 2);
|
|
143
|
+
};
|
|
144
|
+
unwatch = watchReflow(onResize);
|
|
145
|
+
const draw = () => {
|
|
146
|
+
if (!w) onResize(); // measured 0 while detached: fit on the first live draw
|
|
147
|
+
if (!w) return;
|
|
148
|
+
let lo = meta.min,
|
|
149
|
+
hi = meta.max;
|
|
150
|
+
if (lo == null || hi == null) {
|
|
151
|
+
let mn = Infinity,
|
|
152
|
+
mx = -Infinity;
|
|
153
|
+
// an Infinity sample would blank the auto-range
|
|
154
|
+
for (const s of samples)
|
|
155
|
+
if (Number.isFinite(s)) {
|
|
156
|
+
if (s < mn) mn = s;
|
|
157
|
+
if (s > mx) mx = s;
|
|
158
|
+
}
|
|
159
|
+
if (mn === Infinity) {
|
|
160
|
+
mn = 0;
|
|
161
|
+
mx = 1;
|
|
162
|
+
} else if (mn === mx) {
|
|
163
|
+
mn -= 0.5;
|
|
164
|
+
mx += 0.5;
|
|
165
|
+
}
|
|
166
|
+
const pad = (mx - mn) * 0.1;
|
|
167
|
+
if (lo == null) lo = mn - pad;
|
|
168
|
+
if (hi == null) hi = mx + pad;
|
|
169
|
+
}
|
|
170
|
+
const span = hi - lo || 1;
|
|
171
|
+
strokeSeries(ctx, wrap, w, h, samples, idx, (s) => (s - lo) / span);
|
|
172
|
+
};
|
|
173
|
+
// a non-finite sample shows in the readout, not the graph
|
|
174
|
+
poll((v) => {
|
|
175
|
+
readout(v);
|
|
176
|
+
if (typeof v === "number" && Number.isFinite(v)) {
|
|
177
|
+
samples[idx] = v;
|
|
178
|
+
idx = (idx + 1) % N;
|
|
179
|
+
draw();
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
return blade(wrap, stop);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ── FPS graph — each frame's timestamp becomes the rate since the last (none on the first or for the probe), 0–120. ──
|
|
186
|
+
const frameRate = () => {
|
|
187
|
+
let last = 0;
|
|
188
|
+
return (now?: number) => {
|
|
189
|
+
if (!(now! > 0)) return undefined;
|
|
190
|
+
const fps = last ? 1000 / (now! - last) : undefined;
|
|
191
|
+
last = now!;
|
|
192
|
+
return fps;
|
|
193
|
+
};
|
|
194
|
+
};
|
|
195
|
+
// ??: an explicit "" label renders none
|
|
196
|
+
const createFps = (meta: Meta) =>
|
|
197
|
+
createMonitor(
|
|
198
|
+
{
|
|
199
|
+
...meta,
|
|
200
|
+
label: meta.label ?? words()("fps", "FPS"),
|
|
201
|
+
get: frameRate(),
|
|
202
|
+
view: "graph",
|
|
203
|
+
min: 0,
|
|
204
|
+
max: 120,
|
|
205
|
+
decimals: 0,
|
|
206
|
+
},
|
|
207
|
+
true,
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
registerControl("fpsgraph", createFps);
|
|
211
|
+
registerControl("monitor", (meta: Meta) => createMonitor(meta)); // the registry passes (meta, onChange), not perFrame
|