@zakkster/lite-ui-fx 1.1.0 → 1.3.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/CHANGELOG.md +91 -1
- package/README.md +24 -32
- package/UIFXController.js +12 -2
- package/UIFXRecipes.d.ts +253 -0
- package/UIFXRecipes.js +2797 -0
- package/llms.txt +16 -9
- package/package.json +12 -5
- /package/{recipes/UIFX-RECIPE-GUIDE.md → UIFX-RECIPE-GUIDE.md} +0 -0
package/UIFXRecipes.js
ADDED
|
@@ -0,0 +1,2797 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @zakkster/lite-ui-fx -- Recipe Collection (all 50)
|
|
3
|
+
*
|
|
4
|
+
* The three recipe volumes consolidated into one shipped, typed, versioned
|
|
5
|
+
* module, exposed as the ./recipes subpath export. See
|
|
6
|
+
* decisions/0001-recipes-position.md.
|
|
7
|
+
*
|
|
8
|
+
* Vol.1 (10): SwarmToggle, LiquidToggle, NeonPulseToggle, MagneticButton,
|
|
9
|
+
* ShatterButton, ConfettiButton, GlitchButton, SparkSlider,
|
|
10
|
+
* CosmicSlider, LaserSlider
|
|
11
|
+
* Vol.2 (20): PendulumToggle, CircuitToggle, LightningToggle, DNAToggle,
|
|
12
|
+
* HeartbeatButton, BreathingButton, InkSplashButton,
|
|
13
|
+
* PixelDissolveButton, FireworkButton, AuroraSlider, WaveSlider,
|
|
14
|
+
* ElasticBandSlider, GravitySlider, OrbitLoader, HelixLoader,
|
|
15
|
+
* RippleCheck, MorphCheck, FlameCounter, GlitchCounter, BubbleRating
|
|
16
|
+
* Vol.3 (20): VolumeKnob, CompassKnob, RingProgress, BatteryGauge, SignalMeter,
|
|
17
|
+
* PillTabs, Stepper, RadioOrbit, PasswordStrength, WaterLevel,
|
|
18
|
+
* HeatMap, DayNightToggle, ReactionPicker, NotificationBell,
|
|
19
|
+
* TypewriterField, SoundWaveBtn, UploadProgress, ScratchReveal,
|
|
20
|
+
* TimerCountdown, PullRefresh
|
|
21
|
+
*
|
|
22
|
+
* Registry: RECIPES (null-prototype), RECIPE_META (live), RECIPE_NAMES,
|
|
23
|
+
* registerRecipe(id, factory, meta?), mountRecipe(container, id, options?).
|
|
24
|
+
*
|
|
25
|
+
* Recipe bodies are VERBATIM from the three volumes -- no behaviour change.
|
|
26
|
+
*
|
|
27
|
+
* Uses:
|
|
28
|
+
* @zakkster/lite-lerp -- lerp, clamp, easeOut, easeIn, easeInOut
|
|
29
|
+
* @zakkster/lite-random -- deterministic particle effects
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { lerp, clamp, easeOut, easeIn, easeInOut } from '@zakkster/lite-lerp';
|
|
33
|
+
import { Random } from '@zakkster/lite-random';
|
|
34
|
+
import { mountUIFX, UIType } from './UIFXController.js';
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------
|
|
38
|
+
// SHARED COLD CONSTANTS (U3) -- built once at module load, never per frame
|
|
39
|
+
// ---------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
// Focus-ring dash patterns: one shared array each, so a focused frame's
|
|
42
|
+
// setLineDash() reuses them instead of allocating a fresh array (U-03).
|
|
43
|
+
const DASH_FOCUS = [4, 3];
|
|
44
|
+
const DASH_NONE = [];
|
|
45
|
+
|
|
46
|
+
// Percentage readouts '0%'..'100%', precomputed so a value label is a const
|
|
47
|
+
// lookup -- PCT[Math.round(st.val * 100)] -- not a per-frame template build.
|
|
48
|
+
const PCT = Array.from({ length: 101 }, (_, i) => i + '%');
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------
|
|
52
|
+
// SHARED HELPERS
|
|
53
|
+
// ---------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
/** Draw a rounded rect (safe for Safari < 17.4). */
|
|
56
|
+
function roundRect(ctx, x, y, w, h, r) {
|
|
57
|
+
ctx.beginPath();
|
|
58
|
+
ctx.moveTo(x + r, y);
|
|
59
|
+
ctx.lineTo(x + w - r, y);
|
|
60
|
+
ctx.arcTo(x + w, y, x + w, y + r, r);
|
|
61
|
+
ctx.lineTo(x + w, y + h - r);
|
|
62
|
+
ctx.arcTo(x + w, y + h, x + w - r, y + h, r);
|
|
63
|
+
ctx.lineTo(x + r, y + h);
|
|
64
|
+
ctx.arcTo(x, y + h, x, y + h - r, r);
|
|
65
|
+
ctx.lineTo(x, y + r);
|
|
66
|
+
ctx.arcTo(x, y, x + r, y, r);
|
|
67
|
+
ctx.closePath();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Draw a keyboard focus ring (accessibility indicator). */
|
|
71
|
+
function drawFocusRing(ctx, w, h, r) {
|
|
72
|
+
ctx.strokeStyle = 'rgba(110,231,182,.6)';
|
|
73
|
+
ctx.lineWidth = 2;
|
|
74
|
+
ctx.setLineDash(DASH_FOCUS);
|
|
75
|
+
roundRect(ctx, -2, -2, w + 4, h + 4, r + 2);
|
|
76
|
+
ctx.stroke();
|
|
77
|
+
ctx.setLineDash(DASH_NONE);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Draw a state label so the user knows the current state. font defaults to the
|
|
81
|
+
* historical literal, so an omitted font renders exactly as before. */
|
|
82
|
+
function drawStateLabel(ctx, text, x, y, color, font) {
|
|
83
|
+
ctx.fillStyle = color;
|
|
84
|
+
ctx.font = font || "500 9px 'JetBrains Mono',monospace";
|
|
85
|
+
ctx.textAlign = 'center';
|
|
86
|
+
ctx.fillText(text, x, y);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function label(ctx, text, x, y, color = '#9999b8', font) {
|
|
90
|
+
ctx.fillStyle = color; ctx.font = font || "500 9px 'JetBrains Mono',monospace"; ctx.textAlign = 'center'; ctx.fillText(text, x, y);
|
|
91
|
+
}
|
|
92
|
+
function focusRing(ctx, w, h, r) {
|
|
93
|
+
ctx.strokeStyle = 'rgba(110,231,182,.5)'; ctx.lineWidth = 2; ctx.setLineDash(DASH_FOCUS);
|
|
94
|
+
roundRect(ctx, -2, -2, w + 4, h + 4, r + 2); ctx.stroke(); ctx.setLineDash(DASH_NONE);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function rr(c,x,y,w,h,r){c.beginPath();c.moveTo(x+r,y);c.lineTo(x+w-r,y);c.arcTo(x+w,y,x+w,y+r,r);c.lineTo(x+w,y+h-r);c.arcTo(x+w,y+h,x+w-r,y+h,r);c.lineTo(x+r,y+h);c.arcTo(x,y+h,x,y+h-r,r);c.lineTo(x,y+r);c.arcTo(x,y,x+r,y,r);c.closePath()}
|
|
98
|
+
function lbl(c,t,x,y,col='#9999b8',font){c.fillStyle=col;c.font=font||"500 9px 'JetBrains Mono',monospace";c.textAlign='center';c.fillText(t,x,y)}
|
|
99
|
+
function fr(c,w,h,r){c.strokeStyle='rgba(110,231,182,.5)';c.lineWidth=2;c.setLineDash(DASH_FOCUS);rr(c,-2,-2,w+4,h+4,r+2);c.stroke();c.setLineDash(DASH_NONE)}
|
|
100
|
+
const PI2=Math.PI*2;
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
// ===========================================================
|
|
105
|
+
// TOGGLE RECIPES
|
|
106
|
+
// ===========================================================
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Swarm Toggle -- 150 particles form the knob shape.
|
|
110
|
+
* On toggle, they explode outward then regroup at the new position.
|
|
111
|
+
* Uses sunflower phyllotaxis for the packed formation.
|
|
112
|
+
*/
|
|
113
|
+
export function SwarmToggle({ seed = 42, count = 150 } = {}) {
|
|
114
|
+
const rng = new Random(seed);
|
|
115
|
+
const N = count;
|
|
116
|
+
const px = new Float32Array(N), py = new Float32Array(N);
|
|
117
|
+
const vx = new Float32Array(N), vy = new Float32Array(N);
|
|
118
|
+
const ox = new Float32Array(N), oy = new Float32Array(N);
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
init(ctx, w, h) {
|
|
122
|
+
// Sunflower phyllotaxis distribution for the knob shape
|
|
123
|
+
for (let i = 0; i < N; i++) {
|
|
124
|
+
const r = 12 * Math.sqrt(i / N);
|
|
125
|
+
const th = i * Math.PI * (3 - Math.sqrt(5)); // Golden angle
|
|
126
|
+
ox[i] = Math.cos(th) * r;
|
|
127
|
+
oy[i] = Math.sin(th) * r;
|
|
128
|
+
px[i] = 18 + ox[i];
|
|
129
|
+
py[i] = h / 2 + oy[i];
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
onToggle(checked) {
|
|
134
|
+
// Explode particles outward
|
|
135
|
+
for (let i = 0; i < N; i++) {
|
|
136
|
+
const a = rng.range(0, Math.PI * 2);
|
|
137
|
+
const f = rng.range(100, 300);
|
|
138
|
+
vx[i] += Math.cos(a) * f;
|
|
139
|
+
vy[i] += Math.sin(a) * f;
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
tick(ctx, dt, now, st) {
|
|
144
|
+
// Track
|
|
145
|
+
ctx.fillStyle = st.toggled ? 'rgba(110,231,182,.2)' : 'rgba(255,255,255,.06)';
|
|
146
|
+
roundRect(ctx, 0, 0, st.w, st.h, st.h / 2);
|
|
147
|
+
ctx.fill();
|
|
148
|
+
|
|
149
|
+
// State label
|
|
150
|
+
drawStateLabel(ctx, st.toggled ? 'ON' : 'OFF', st.w / 2, st.h + 14,
|
|
151
|
+
st.toggled ? '#6ee7b6' : '#8888aa');
|
|
152
|
+
|
|
153
|
+
// Knob target position
|
|
154
|
+
const tx = st.toggled ? st.w - 18 : 18;
|
|
155
|
+
|
|
156
|
+
// Render particles (spring toward formation)
|
|
157
|
+
ctx.fillStyle = st.toggled ? '#6ee7b6' : '#9999b8';
|
|
158
|
+
for (let i = 0; i < N; i++) {
|
|
159
|
+
vx[i] += ((tx + ox[i]) - px[i]) * 15 * dt;
|
|
160
|
+
vy[i] += ((st.h / 2 + oy[i]) - py[i]) * 15 * dt;
|
|
161
|
+
vx[i] *= 0.85;
|
|
162
|
+
vy[i] *= 0.85;
|
|
163
|
+
px[i] += vx[i] * dt;
|
|
164
|
+
py[i] += vy[i] * dt;
|
|
165
|
+
ctx.fillRect(px[i], py[i], 1.5, 1.5);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (st.focused) drawFocusRing(ctx, st.w, st.h, st.h / 2);
|
|
169
|
+
},
|
|
170
|
+
|
|
171
|
+
destroy() {
|
|
172
|
+
// TypedArrays are GC'd automatically -- no manual cleanup needed
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Liquid Toggle -- Metaball-style stretching knob.
|
|
180
|
+
* The knob elongates in the direction of motion, squashes perpendicular.
|
|
181
|
+
*/
|
|
182
|
+
export function LiquidToggle() {
|
|
183
|
+
let knobX = 18;
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
tick(ctx, dt, now, st) {
|
|
187
|
+
const tx = st.toggled ? st.w - 18 : 18;
|
|
188
|
+
knobX = lerp(knobX, tx, dt * 12);
|
|
189
|
+
const stretch = Math.abs(knobX - tx) * 0.5;
|
|
190
|
+
|
|
191
|
+
// Track
|
|
192
|
+
ctx.fillStyle = st.toggled ? 'rgba(110,231,182,.2)' : 'rgba(255,255,255,.06)';
|
|
193
|
+
roundRect(ctx, 0, 0, st.w, st.h, st.h / 2);
|
|
194
|
+
ctx.fill();
|
|
195
|
+
|
|
196
|
+
// Knob (stretched ellipse)
|
|
197
|
+
ctx.fillStyle = st.toggled ? '#6ee7b6' : '#9999b8';
|
|
198
|
+
ctx.beginPath();
|
|
199
|
+
ctx.ellipse(knobX, st.h / 2, 14 + stretch, 14 - stretch * 0.2, 0, 0, Math.PI * 2);
|
|
200
|
+
ctx.fill();
|
|
201
|
+
|
|
202
|
+
// State label
|
|
203
|
+
drawStateLabel(ctx, st.toggled ? 'ON' : 'OFF', st.w / 2, st.h + 14,
|
|
204
|
+
st.toggled ? '#6ee7b6' : '#8888aa');
|
|
205
|
+
|
|
206
|
+
if (st.focused) drawFocusRing(ctx, st.w, st.h, st.h / 2);
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Neon Pulse Toggle -- Expanding shockwave rings on toggle.
|
|
214
|
+
*/
|
|
215
|
+
export function NeonPulseToggle() {
|
|
216
|
+
// Fixed ring pool (dead when life <= 0) -- no push/splice on the hot path.
|
|
217
|
+
const RINGS = 8;
|
|
218
|
+
const ringR = new Float64Array(RINGS);
|
|
219
|
+
const ringLife = new Float64Array(RINGS);
|
|
220
|
+
const ringX = new Float64Array(RINGS);
|
|
221
|
+
let knobX = 18;
|
|
222
|
+
let lastW = 64; // last track width, so onToggle (which gets no state) can
|
|
223
|
+
// place a ring at the knob position derived from st.w (U-05).
|
|
224
|
+
|
|
225
|
+
function spawnRing(x) {
|
|
226
|
+
for (let i = 0; i < RINGS; i++) {
|
|
227
|
+
if (ringLife[i] <= 0) { ringR[i] = 14; ringLife[i] = 1; ringX[i] = x; return; }
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
onToggle(checked) {
|
|
233
|
+
spawnRing(checked ? lastW - 18 : 18);
|
|
234
|
+
},
|
|
235
|
+
|
|
236
|
+
tick(ctx, dt, now, st) {
|
|
237
|
+
lastW = st.w;
|
|
238
|
+
knobX = lerp(knobX, st.toggled ? st.w - 18 : 18, dt * 15);
|
|
239
|
+
|
|
240
|
+
// Track
|
|
241
|
+
ctx.fillStyle = st.toggled ? 'rgba(56,189,248,.2)' : 'rgba(255,255,255,.06)';
|
|
242
|
+
roundRect(ctx, 0, 0, st.w, st.h, st.h / 2);
|
|
243
|
+
ctx.fill();
|
|
244
|
+
|
|
245
|
+
// Shockwave rings -- const stroke color, per-ring alpha via globalAlpha
|
|
246
|
+
// (was `rgba(56,189,248,${life})` built per ring per frame).
|
|
247
|
+
ctx.strokeStyle = '#38bdf8';
|
|
248
|
+
ctx.lineWidth = 2;
|
|
249
|
+
for (let i = 0; i < RINGS; i++) {
|
|
250
|
+
if (ringLife[i] <= 0) continue;
|
|
251
|
+
ringR[i] += 100 * dt;
|
|
252
|
+
ringLife[i] -= 2 * dt;
|
|
253
|
+
if (ringLife[i] <= 0) continue;
|
|
254
|
+
ctx.globalAlpha = ringLife[i];
|
|
255
|
+
ctx.beginPath();
|
|
256
|
+
ctx.arc(ringX[i], st.h / 2, ringR[i], 0, Math.PI * 2);
|
|
257
|
+
ctx.stroke();
|
|
258
|
+
}
|
|
259
|
+
ctx.globalAlpha = 1;
|
|
260
|
+
|
|
261
|
+
// Knob
|
|
262
|
+
ctx.fillStyle = st.toggled ? '#38bdf8' : '#9999b8';
|
|
263
|
+
ctx.beginPath();
|
|
264
|
+
ctx.arc(knobX, st.h / 2, 14, 0, Math.PI * 2);
|
|
265
|
+
ctx.fill();
|
|
266
|
+
|
|
267
|
+
drawStateLabel(ctx, st.toggled ? 'ON' : 'OFF', st.w / 2, st.h + 14,
|
|
268
|
+
st.toggled ? '#38bdf8' : '#8888aa');
|
|
269
|
+
|
|
270
|
+
if (st.focused) drawFocusRing(ctx, st.w, st.h, st.h / 2);
|
|
271
|
+
},
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
// ===========================================================
|
|
277
|
+
// BUTTON RECIPES
|
|
278
|
+
// ===========================================================
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Magnetic Button -- The entire button follows the cursor with spring physics.
|
|
282
|
+
* Squashes on click, springs back.
|
|
283
|
+
*/
|
|
284
|
+
export function MagneticButton({ maxPull = 15 } = {}) {
|
|
285
|
+
let bx = 0, by = 0, textScale = 1;
|
|
286
|
+
|
|
287
|
+
return {
|
|
288
|
+
onClick() { textScale = 0.85; },
|
|
289
|
+
|
|
290
|
+
tick(ctx, dt, now, st, ptr) {
|
|
291
|
+
const tx = st.hover ? clamp(ptr.x - st.w / 2, -maxPull, maxPull) : 0;
|
|
292
|
+
const ty = st.hover ? clamp(ptr.y - st.h / 2, -maxPull, maxPull) : 0;
|
|
293
|
+
bx = lerp(bx, tx, dt * 10);
|
|
294
|
+
by = lerp(by, ty, dt * 10);
|
|
295
|
+
textScale = lerp(textScale, 1, dt * 10);
|
|
296
|
+
|
|
297
|
+
ctx.translate(bx, by);
|
|
298
|
+
|
|
299
|
+
// Button body
|
|
300
|
+
ctx.fillStyle = st.hover ? '#1e1e2f' : 'rgba(255,255,255,.05)';
|
|
301
|
+
ctx.strokeStyle = st.hover ? '#a78bfa' : 'rgba(255,255,255,.1)';
|
|
302
|
+
ctx.lineWidth = 1;
|
|
303
|
+
roundRect(ctx, 0, 0, st.w, st.h, 10);
|
|
304
|
+
ctx.fill(); ctx.stroke();
|
|
305
|
+
|
|
306
|
+
// Label
|
|
307
|
+
ctx.fillStyle = st.hover ? '#a78bfa' : '#e2e2f0';
|
|
308
|
+
ctx.font = "600 13px 'Space Grotesk',sans-serif";
|
|
309
|
+
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
|
310
|
+
ctx.translate(st.w / 2, st.h / 2);
|
|
311
|
+
ctx.scale(textScale, textScale);
|
|
312
|
+
ctx.fillText('MAGNETIC', 0, 0);
|
|
313
|
+
|
|
314
|
+
if (st.focused) {
|
|
315
|
+
ctx.setTransform(st.dpr, 0, 0, st.dpr, 0, 0);
|
|
316
|
+
ctx.translate(st.padding + bx, st.padding + by);
|
|
317
|
+
drawFocusRing(ctx, st.w, st.h, 10);
|
|
318
|
+
}
|
|
319
|
+
},
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Shatter Button -- Click explodes into falling shards, reforms after 1.5s.
|
|
326
|
+
*/
|
|
327
|
+
export function ShatterButton({ seed = 42 } = {}) {
|
|
328
|
+
const rng = new Random(seed);
|
|
329
|
+
// Fixed shard pool (live flag) -- no push/splice on the hot path.
|
|
330
|
+
const SHARDS = 32;
|
|
331
|
+
const shard = [];
|
|
332
|
+
for (let i = 0; i < SHARDS; i++) shard[i] = { x: 0, y: 0, vx: 0, vy: 0, r: 0, vr: 0, sz: 0, live: false };
|
|
333
|
+
let visible = true;
|
|
334
|
+
let respawnTimer = 0;
|
|
335
|
+
|
|
336
|
+
return {
|
|
337
|
+
onClick(x, y, st) {
|
|
338
|
+
if (!visible) return;
|
|
339
|
+
visible = false;
|
|
340
|
+
respawnTimer = 1.5;
|
|
341
|
+
let n = 0;
|
|
342
|
+
for (let i = 0; i < SHARDS && n < 30; i++) {
|
|
343
|
+
const s = shard[i];
|
|
344
|
+
if (s.live) continue;
|
|
345
|
+
s.x = rng.range(0, st.w); s.y = rng.range(0, st.h);
|
|
346
|
+
s.vx = rng.range(-150, 150); s.vy = rng.range(-150, 50);
|
|
347
|
+
s.r = rng.range(0, 6); s.vr = rng.range(-5, 5);
|
|
348
|
+
s.sz = rng.range(4, 12); s.live = true;
|
|
349
|
+
n++;
|
|
350
|
+
}
|
|
351
|
+
},
|
|
352
|
+
|
|
353
|
+
tick(ctx, dt, now, st) {
|
|
354
|
+
if (respawnTimer > 0) {
|
|
355
|
+
respawnTimer -= dt;
|
|
356
|
+
if (respawnTimer <= 0) visible = true;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (visible) {
|
|
360
|
+
ctx.fillStyle = st.active ? 'rgba(255,255,255,.15)' : 'rgba(255,255,255,.08)';
|
|
361
|
+
roundRect(ctx, 0, 0, st.w, st.h, 10);
|
|
362
|
+
ctx.fill();
|
|
363
|
+
ctx.fillStyle = '#e2e2f0';
|
|
364
|
+
ctx.font = "600 13px 'Space Grotesk',sans-serif";
|
|
365
|
+
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
|
366
|
+
ctx.fillText('SHATTER', st.w / 2, st.h / 2);
|
|
367
|
+
|
|
368
|
+
if (st.focused) drawFocusRing(ctx, st.w, st.h, 10);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Falling shards -- fixed pool
|
|
372
|
+
ctx.fillStyle = 'rgba(255,255,255,.5)';
|
|
373
|
+
for (let i = 0; i < SHARDS; i++) {
|
|
374
|
+
const s = shard[i];
|
|
375
|
+
if (!s.live) continue;
|
|
376
|
+
s.vy += 400 * dt;
|
|
377
|
+
s.x += s.vx * dt; s.y += s.vy * dt;
|
|
378
|
+
s.r += s.vr * dt;
|
|
379
|
+
ctx.save();
|
|
380
|
+
ctx.translate(s.x, s.y); ctx.rotate(s.r);
|
|
381
|
+
ctx.fillRect(-s.sz / 2, -s.sz / 2, s.sz, s.sz);
|
|
382
|
+
ctx.restore();
|
|
383
|
+
if (s.y > st.h + 100) s.live = false;
|
|
384
|
+
}
|
|
385
|
+
},
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Confetti Button -- 3D tumbling confetti burst from click point.
|
|
392
|
+
*/
|
|
393
|
+
export function ConfettiButton({ seed = 42, colors = ['#6ee7b6', '#38bdf8', '#a78bfa', '#fbbf24', '#f43f5e'] } = {}) {
|
|
394
|
+
const rng = new Random(seed);
|
|
395
|
+
// Fixed confetti pool (live flag) -- no push/splice on the hot path.
|
|
396
|
+
const CONF = 80;
|
|
397
|
+
const conf = [];
|
|
398
|
+
for (let i = 0; i < CONF; i++) conf[i] = { x: 0, y: 0, vx: 0, vy: 0, c: '#fff', w: 0, h: 0, rx: 0, ry: 0, vrx: 0, vry: 0, live: false };
|
|
399
|
+
let pressScale = 1;
|
|
400
|
+
|
|
401
|
+
return {
|
|
402
|
+
onClick(x, y) {
|
|
403
|
+
pressScale = 0.85;
|
|
404
|
+
let n = 0;
|
|
405
|
+
for (let i = 0; i < CONF && n < 40; i++) {
|
|
406
|
+
const c = conf[i];
|
|
407
|
+
if (c.live) continue;
|
|
408
|
+
const a = rng.range(Math.PI, Math.PI * 2);
|
|
409
|
+
const v = rng.range(100, 300);
|
|
410
|
+
c.x = x; c.y = y; c.vx = Math.cos(a) * v; c.vy = Math.sin(a) * v;
|
|
411
|
+
c.c = rng.pick(colors);
|
|
412
|
+
c.w = rng.range(4, 8); c.h = rng.range(8, 14);
|
|
413
|
+
c.rx = rng.range(0, Math.PI); c.ry = rng.range(0, Math.PI);
|
|
414
|
+
c.vrx = rng.range(5, 15); c.vry = rng.range(5, 15);
|
|
415
|
+
c.live = true;
|
|
416
|
+
n++;
|
|
417
|
+
}
|
|
418
|
+
},
|
|
419
|
+
|
|
420
|
+
tick(ctx, dt, now, st) {
|
|
421
|
+
pressScale = lerp(pressScale, 1, dt * 10);
|
|
422
|
+
|
|
423
|
+
ctx.translate(st.w / 2, st.h / 2);
|
|
424
|
+
ctx.scale(pressScale, pressScale);
|
|
425
|
+
ctx.translate(-st.w / 2, -st.h / 2);
|
|
426
|
+
|
|
427
|
+
// Button body
|
|
428
|
+
ctx.fillStyle = '#DC143C';
|
|
429
|
+
roundRect(ctx, 0, 0, st.w, st.h, 10);
|
|
430
|
+
ctx.fill();
|
|
431
|
+
ctx.fillStyle = '#fff';
|
|
432
|
+
ctx.font = "600 13px 'Space Grotesk',sans-serif";
|
|
433
|
+
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
|
434
|
+
ctx.fillText('CONFETTI', st.w / 2, st.h / 2);
|
|
435
|
+
|
|
436
|
+
// Tumbling confetti -- fixed pool
|
|
437
|
+
for (let i = 0; i < CONF; i++) {
|
|
438
|
+
const c = conf[i];
|
|
439
|
+
if (!c.live) continue;
|
|
440
|
+
c.vy += 300 * dt;
|
|
441
|
+
c.x += c.vx * dt; c.y += c.vy * dt;
|
|
442
|
+
c.rx += c.vrx * dt; c.ry += c.vry * dt;
|
|
443
|
+
ctx.save();
|
|
444
|
+
ctx.translate(c.x, c.y); ctx.rotate(c.rx);
|
|
445
|
+
ctx.scale(1, Math.cos(c.ry)); // 3D tumble
|
|
446
|
+
ctx.fillStyle = c.c;
|
|
447
|
+
ctx.fillRect(-c.w / 2, -c.h / 2, c.w, c.h);
|
|
448
|
+
ctx.restore();
|
|
449
|
+
if (c.y > st.h + 100) c.live = false;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
if (st.focused) {
|
|
453
|
+
ctx.setTransform(st.dpr, 0, 0, st.dpr, 0, 0);
|
|
454
|
+
ctx.translate(st.padding, st.padding);
|
|
455
|
+
drawFocusRing(ctx, st.w, st.h, 10);
|
|
456
|
+
}
|
|
457
|
+
},
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Glitch Button -- RGB channel split on hover. Random slice displacement.
|
|
464
|
+
*/
|
|
465
|
+
export function GlitchButton({ seed = 42 } = {}) {
|
|
466
|
+
const rng = new Random(seed);
|
|
467
|
+
let glitchIntensity = 0;
|
|
468
|
+
|
|
469
|
+
// Hoisted out of tick (was a per-frame arrow closure). Body alpha via
|
|
470
|
+
// globalAlpha (was two `rgba(...,${intensity})` templates); label at full.
|
|
471
|
+
function drawBase(ctx, st, ox, oy, color, alpha) {
|
|
472
|
+
ctx.globalAlpha = alpha;
|
|
473
|
+
ctx.fillStyle = color;
|
|
474
|
+
roundRect(ctx, ox, oy, st.w, st.h, 10);
|
|
475
|
+
ctx.fill();
|
|
476
|
+
ctx.globalAlpha = 1;
|
|
477
|
+
ctx.fillStyle = '#fff';
|
|
478
|
+
ctx.font = "600 13px 'Space Grotesk',sans-serif";
|
|
479
|
+
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
|
480
|
+
ctx.fillText('GLITCH', st.w / 2 + ox, st.h / 2 + oy);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
return {
|
|
484
|
+
tick(ctx, dt, now, st) {
|
|
485
|
+
glitchIntensity = lerp(glitchIntensity, st.hover ? 1 : 0, dt * 10);
|
|
486
|
+
|
|
487
|
+
if (glitchIntensity > 0.01) {
|
|
488
|
+
ctx.globalCompositeOperation = 'screen';
|
|
489
|
+
const off = rng.range(2, 6) * glitchIntensity;
|
|
490
|
+
// Alternate between split and normal for flicker
|
|
491
|
+
if (rng.next() > 0.4) {
|
|
492
|
+
drawBase(ctx, st, -off, 0, '#ff0055', 0.6 * glitchIntensity);
|
|
493
|
+
drawBase(ctx, st, off, 0, '#00ffcc', 0.6 * glitchIntensity);
|
|
494
|
+
} else {
|
|
495
|
+
drawBase(ctx, st, 0, 0, 'rgba(255,255,255,.1)', 1);
|
|
496
|
+
}
|
|
497
|
+
ctx.globalCompositeOperation = 'source-over';
|
|
498
|
+
} else {
|
|
499
|
+
drawBase(ctx, st, 0, 0, 'rgba(255,255,255,.06)', 1);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
if (st.focused) drawFocusRing(ctx, st.w, st.h, 10);
|
|
503
|
+
},
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
// ===========================================================
|
|
509
|
+
// SLIDER RECIPES
|
|
510
|
+
// ===========================================================
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Spark Slider -- Emits directional sparks based on drag velocity.
|
|
514
|
+
* Sparks fly opposite to drag direction with motion-blur stretch.
|
|
515
|
+
*/
|
|
516
|
+
export function SparkSlider({ seed = 42, color = '#fbbf24' } = {}) {
|
|
517
|
+
const rng = new Random(seed);
|
|
518
|
+
// Fixed spark pool (life <= 0 == dead) -- no push/splice on the hot path.
|
|
519
|
+
const SPARKS = 64;
|
|
520
|
+
const spark = [];
|
|
521
|
+
for (let i = 0; i < SPARKS; i++) spark[i] = { x: 0, y: 0, vx: 0, vy: 0, life: 0 };
|
|
522
|
+
let lastVal = 0.5;
|
|
523
|
+
let lastW = 200; // last track width, so onDrag (no state) spawns at the
|
|
524
|
+
// thumb position derived from st.w (was hardcoded val*200).
|
|
525
|
+
function spawn(val, dir) {
|
|
526
|
+
for (let i = 0; i < SPARKS; i++) {
|
|
527
|
+
const s = spark[i];
|
|
528
|
+
if (s.life <= 0) {
|
|
529
|
+
s.x = val * lastW; s.y = 14;
|
|
530
|
+
s.vx = -dir * rng.range(100, 300); s.vy = rng.range(-150, 50); s.life = 1;
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
return {
|
|
537
|
+
onDrag(val) {
|
|
538
|
+
const delta = val - lastVal;
|
|
539
|
+
if (Math.abs(delta) > 0.001) {
|
|
540
|
+
const dir = Math.sign(delta);
|
|
541
|
+
for (let i = 0; i < 4; i++) spawn(val, dir);
|
|
542
|
+
}
|
|
543
|
+
lastVal = val;
|
|
544
|
+
},
|
|
545
|
+
|
|
546
|
+
tick(ctx, dt, now, st) {
|
|
547
|
+
lastW = st.w;
|
|
548
|
+
// Track background
|
|
549
|
+
ctx.fillStyle = 'rgba(255,255,255,.08)';
|
|
550
|
+
roundRect(ctx, 0, 12, st.w, 4, 2);
|
|
551
|
+
ctx.fill();
|
|
552
|
+
|
|
553
|
+
// Filled track
|
|
554
|
+
const tx = st.val * st.w;
|
|
555
|
+
ctx.fillStyle = color;
|
|
556
|
+
roundRect(ctx, 0, 12, tx, 4, 2);
|
|
557
|
+
ctx.fill();
|
|
558
|
+
|
|
559
|
+
// Thumb
|
|
560
|
+
ctx.beginPath();
|
|
561
|
+
ctx.arc(tx, 14, 10, 0, Math.PI * 2);
|
|
562
|
+
ctx.fill();
|
|
563
|
+
|
|
564
|
+
// Value label
|
|
565
|
+
drawStateLabel(ctx, PCT[Math.round(st.val * 100)], st.w / 2, st.h + 10, '#9999b8');
|
|
566
|
+
|
|
567
|
+
// Sparks -- fixed pool, themed color, alpha via globalAlpha
|
|
568
|
+
// (was `rgba(251,191,36,${life})` built per spark per frame).
|
|
569
|
+
ctx.globalCompositeOperation = 'screen';
|
|
570
|
+
ctx.fillStyle = color;
|
|
571
|
+
for (let i = 0; i < SPARKS; i++) {
|
|
572
|
+
const s = spark[i];
|
|
573
|
+
if (s.life <= 0) continue;
|
|
574
|
+
s.vy += 400 * dt;
|
|
575
|
+
s.vx *= 0.95;
|
|
576
|
+
s.x += s.vx * dt; s.y += s.vy * dt;
|
|
577
|
+
s.life -= 2.0 * dt;
|
|
578
|
+
if (s.life <= 0) continue;
|
|
579
|
+
ctx.globalAlpha = s.life;
|
|
580
|
+
ctx.save();
|
|
581
|
+
ctx.translate(s.x, s.y);
|
|
582
|
+
ctx.rotate(Math.atan2(s.vy, s.vx));
|
|
583
|
+
ctx.fillRect(0, -1, Math.max(3, Math.abs(s.vx) * 0.05), 2);
|
|
584
|
+
ctx.restore();
|
|
585
|
+
}
|
|
586
|
+
ctx.globalAlpha = 1;
|
|
587
|
+
ctx.globalCompositeOperation = 'source-over';
|
|
588
|
+
|
|
589
|
+
if (st.focused) drawFocusRing(ctx, st.w, st.h, 4);
|
|
590
|
+
},
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Cosmic Slider -- Thumb is a black hole that sucks in background dust.
|
|
597
|
+
* Particles respawn when consumed.
|
|
598
|
+
*/
|
|
599
|
+
export function CosmicSlider({ seed = 42, dustCount = 80 } = {}) {
|
|
600
|
+
const rng = new Random(seed);
|
|
601
|
+
const dust = [];
|
|
602
|
+
|
|
603
|
+
return {
|
|
604
|
+
init(ctx, w, h) {
|
|
605
|
+
for (let i = 0; i < dustCount; i++) {
|
|
606
|
+
dust.push({ x: rng.range(0, w), y: rng.range(-10, 34), vx: 0, vy: 0 });
|
|
607
|
+
}
|
|
608
|
+
},
|
|
609
|
+
|
|
610
|
+
tick(ctx, dt, now, st) {
|
|
611
|
+
// Track
|
|
612
|
+
ctx.fillStyle = 'rgba(255,255,255,.06)';
|
|
613
|
+
roundRect(ctx, 0, 12, st.w, 4, 2);
|
|
614
|
+
ctx.fill();
|
|
615
|
+
|
|
616
|
+
const tx = st.val * st.w;
|
|
617
|
+
|
|
618
|
+
// Dust particles attracted to thumb (indexed loop -- for..of over an
|
|
619
|
+
// array can allocate an iterator on the hot path)
|
|
620
|
+
ctx.fillStyle = '#fff';
|
|
621
|
+
for (let di = 0; di < dust.length; di++) {
|
|
622
|
+
const d = dust[di];
|
|
623
|
+
const dx = tx - d.x, dy = 14 - d.y;
|
|
624
|
+
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
625
|
+
if (dist < 40) {
|
|
626
|
+
d.vx += (dx / dist) * 200 * dt;
|
|
627
|
+
d.vy += (dy / dist) * 200 * dt;
|
|
628
|
+
}
|
|
629
|
+
d.vx *= 0.9; d.vy *= 0.9;
|
|
630
|
+
d.x += d.vx * dt; d.y += d.vy * dt;
|
|
631
|
+
if (dist < 5) {
|
|
632
|
+
d.x = rng.range(0, st.w);
|
|
633
|
+
d.y = rng.range(-20, 44);
|
|
634
|
+
d.vx = 0; d.vy = 0;
|
|
635
|
+
}
|
|
636
|
+
ctx.fillRect(d.x, d.y, 1, 1);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// Black hole thumb with glow
|
|
640
|
+
ctx.shadowBlur = 10; ctx.shadowColor = '#a78bfa';
|
|
641
|
+
ctx.fillStyle = '#000'; ctx.strokeStyle = '#a78bfa'; ctx.lineWidth = 2;
|
|
642
|
+
ctx.beginPath(); ctx.arc(tx, 14, 12, 0, Math.PI * 2);
|
|
643
|
+
ctx.fill(); ctx.stroke();
|
|
644
|
+
ctx.shadowBlur = 0;
|
|
645
|
+
|
|
646
|
+
drawStateLabel(ctx, PCT[Math.round(st.val * 100)], st.w / 2, st.h + 10, '#9999b8');
|
|
647
|
+
|
|
648
|
+
if (st.focused) drawFocusRing(ctx, st.w, st.h, 4);
|
|
649
|
+
},
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* Laser Slider -- Energy beam traces the filled track. Pulsing plasma thumb.
|
|
656
|
+
*/
|
|
657
|
+
export function LaserSlider() {
|
|
658
|
+
const REF = 100; // gradient reference width; scaled to tx at paint
|
|
659
|
+
let pulseTime = 0, beam = null;
|
|
660
|
+
|
|
661
|
+
return {
|
|
662
|
+
init(ctx) {
|
|
663
|
+
// Fixed-stop beam gradient built once (0..REF). Scaling the space to
|
|
664
|
+
// tx/REF at fill maps it to 0..tx with the white tip at the thumb --
|
|
665
|
+
// no per-frame createLinearGradient.
|
|
666
|
+
beam = ctx.createLinearGradient(0, 0, REF, 0);
|
|
667
|
+
beam.addColorStop(0, 'transparent');
|
|
668
|
+
beam.addColorStop(0.8, '#38bdf8');
|
|
669
|
+
beam.addColorStop(1, '#fff');
|
|
670
|
+
},
|
|
671
|
+
tick(ctx, dt, now, st) {
|
|
672
|
+
pulseTime += dt * 10;
|
|
673
|
+
|
|
674
|
+
// Track background
|
|
675
|
+
ctx.fillStyle = 'rgba(255,255,255,.06)';
|
|
676
|
+
roundRect(ctx, 0, 12, st.w, 4, 2);
|
|
677
|
+
ctx.fill();
|
|
678
|
+
|
|
679
|
+
const tx = st.val * st.w;
|
|
680
|
+
|
|
681
|
+
// Laser beam (gradient from init, scaled to 0..tx)
|
|
682
|
+
if (tx > 2) {
|
|
683
|
+
ctx.save();
|
|
684
|
+
ctx.scale(tx / REF, 1);
|
|
685
|
+
ctx.fillStyle = beam || '#38bdf8';
|
|
686
|
+
ctx.shadowBlur = 10; ctx.shadowColor = '#38bdf8';
|
|
687
|
+
roundRect(ctx, 0, 12, REF, 4, 2);
|
|
688
|
+
ctx.fill();
|
|
689
|
+
ctx.restore();
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// Plasma thumb (pulsing) -- keep the glow the beam left on
|
|
693
|
+
ctx.shadowBlur = 10; ctx.shadowColor = '#38bdf8';
|
|
694
|
+
ctx.fillStyle = '#fff';
|
|
695
|
+
ctx.beginPath();
|
|
696
|
+
ctx.arc(tx, 14, 8 + Math.sin(pulseTime) * 2, 0, Math.PI * 2);
|
|
697
|
+
ctx.fill();
|
|
698
|
+
ctx.shadowBlur = 0;
|
|
699
|
+
|
|
700
|
+
drawStateLabel(ctx, PCT[Math.round(st.val * 100)], st.w / 2, st.h + 10, '#9999b8');
|
|
701
|
+
|
|
702
|
+
if (st.focused) drawFocusRing(ctx, st.w, st.h, 4);
|
|
703
|
+
},
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
// ===========================================================
|
|
709
|
+
// TOGGLES
|
|
710
|
+
// ===========================================================
|
|
711
|
+
|
|
712
|
+
/** 1. Pendulum Toggle -- The knob swings like a pendulum with overshoot. */
|
|
713
|
+
export function PendulumToggle() {
|
|
714
|
+
let angle = -0.4, velocity = 0;
|
|
715
|
+
const STIFFNESS = 12, DAMPING = 3.5;
|
|
716
|
+
return {
|
|
717
|
+
onToggle() { velocity += 8; },
|
|
718
|
+
tick(ctx, dt, now, st) {
|
|
719
|
+
const target = st.toggled ? 0.4 : -0.4;
|
|
720
|
+
velocity += (target - angle) * STIFFNESS * dt;
|
|
721
|
+
velocity *= (1 - DAMPING * dt);
|
|
722
|
+
angle += velocity * dt;
|
|
723
|
+
|
|
724
|
+
ctx.fillStyle = st.toggled ? 'rgba(110,231,182,.2)' : 'rgba(255,255,255,.06)';
|
|
725
|
+
roundRect(ctx, 0, 0, st.w, st.h, st.h / 2); ctx.fill();
|
|
726
|
+
|
|
727
|
+
const cx = st.w / 2, cy = -10, len = st.h / 2 + 14;
|
|
728
|
+
const bx = cx + Math.sin(angle) * len, by = cy + Math.cos(angle) * len;
|
|
729
|
+
|
|
730
|
+
ctx.strokeStyle = st.toggled ? 'rgba(110,231,182,.3)' : 'rgba(255,255,255,.08)';
|
|
731
|
+
ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(bx, by); ctx.stroke();
|
|
732
|
+
ctx.fillStyle = st.toggled ? '#6ee7b6' : '#9999b8';
|
|
733
|
+
ctx.beginPath(); ctx.arc(bx, by, 12, 0, Math.PI * 2); ctx.fill();
|
|
734
|
+
label(ctx, st.toggled ? 'ON' : 'OFF', st.w / 2, st.h + 14, st.toggled ? '#6ee7b6' : '#8888aa');
|
|
735
|
+
if (st.focused) focusRing(ctx, st.w, st.h, st.h / 2);
|
|
736
|
+
},
|
|
737
|
+
};
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/** 2. Circuit Toggle -- Electricity flows through a circuit path when ON. */
|
|
741
|
+
export function CircuitToggle({ seed = 42 } = {}) {
|
|
742
|
+
const rng = new Random(seed);
|
|
743
|
+
// Fixed spark pool (life <= 0 == dead) -- no push/splice on the hot path.
|
|
744
|
+
const SPARKS = 16;
|
|
745
|
+
const spark = [];
|
|
746
|
+
for (let i = 0; i < SPARKS; i++) spark[i] = { t: 0, life: 0, speed: 0 };
|
|
747
|
+
const DASH_FLOW = [4, 4]; // one shared dash array, reused every frame
|
|
748
|
+
let knobX = 18, flowT = 0;
|
|
749
|
+
|
|
750
|
+
function spawn(t) {
|
|
751
|
+
for (let i = 0; i < SPARKS; i++) {
|
|
752
|
+
const s = spark[i];
|
|
753
|
+
if (s.life <= 0) { s.t = t; s.life = 1; s.speed = rng.range(0.3, 0.8); return; }
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
return {
|
|
758
|
+
onToggle(checked) {
|
|
759
|
+
if (checked) for (let i = 0; i < 8; i++) spawn(rng.range(0, 1));
|
|
760
|
+
},
|
|
761
|
+
tick(ctx, dt, now, st) {
|
|
762
|
+
knobX = lerp(knobX, st.toggled ? st.w - 18 : 18, dt * 12);
|
|
763
|
+
flowT = (flowT + dt * 2) % 1;
|
|
764
|
+
|
|
765
|
+
ctx.fillStyle = 'rgba(255,255,255,.04)';
|
|
766
|
+
roundRect(ctx, 0, 0, st.w, st.h, st.h / 2); ctx.fill();
|
|
767
|
+
|
|
768
|
+
// Circuit path
|
|
769
|
+
ctx.strokeStyle = st.toggled ? '#22d3ee' : 'rgba(255,255,255,.06)';
|
|
770
|
+
ctx.lineWidth = 2; ctx.setLineDash(DASH_FLOW); ctx.lineDashOffset = -flowT * 16;
|
|
771
|
+
ctx.beginPath(); ctx.moveTo(8, st.h / 2); ctx.lineTo(st.w - 8, st.h / 2); ctx.stroke();
|
|
772
|
+
ctx.setLineDash(DASH_NONE); ctx.lineDashOffset = 0;
|
|
773
|
+
|
|
774
|
+
// Flowing sparks -- fixed pool, per-spark alpha via globalAlpha
|
|
775
|
+
if (st.toggled) {
|
|
776
|
+
ctx.fillStyle = '#22d3ee';
|
|
777
|
+
let alive = 0;
|
|
778
|
+
for (let i = 0; i < SPARKS; i++) {
|
|
779
|
+
const s = spark[i];
|
|
780
|
+
if (s.life <= 0) continue;
|
|
781
|
+
s.t += s.speed * dt; s.life -= dt * 0.5;
|
|
782
|
+
if (s.life <= 0 || s.t > 1) { s.life = 0; continue; }
|
|
783
|
+
alive++;
|
|
784
|
+
const sx = 8 + s.t * (st.w - 16);
|
|
785
|
+
ctx.globalAlpha = s.life;
|
|
786
|
+
ctx.beginPath(); ctx.arc(sx, st.h / 2, 2, 0, Math.PI * 2); ctx.fill();
|
|
787
|
+
}
|
|
788
|
+
ctx.globalAlpha = 1;
|
|
789
|
+
// Continuous spawn
|
|
790
|
+
if (alive < 12) spawn(0);
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
ctx.fillStyle = st.toggled ? '#22d3ee' : '#9999b8';
|
|
794
|
+
ctx.beginPath(); ctx.arc(knobX, st.h / 2, 12, 0, Math.PI * 2); ctx.fill();
|
|
795
|
+
label(ctx, st.toggled ? 'ON' : 'OFF', st.w / 2, st.h + 14, st.toggled ? '#22d3ee' : '#8888aa');
|
|
796
|
+
if (st.focused) focusRing(ctx, st.w, st.h, st.h / 2);
|
|
797
|
+
},
|
|
798
|
+
};
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/** 3. Lightning Toggle -- Electric arc between endpoints. */
|
|
802
|
+
export function LightningToggle({ seed = 42 } = {}) {
|
|
803
|
+
const rng = new Random(seed);
|
|
804
|
+
let knobX = 18, arcTime = 0;
|
|
805
|
+
function bolt(ctx, x1, y1, x2, y2, depth) {
|
|
806
|
+
if (depth <= 0) { ctx.lineTo(x2, y2); return; }
|
|
807
|
+
const mx = (x1 + x2) / 2 + (rng.next() - 0.5) * 12 * depth;
|
|
808
|
+
const my = (y1 + y2) / 2 + (rng.next() - 0.5) * 8 * depth;
|
|
809
|
+
bolt(ctx, x1, y1, mx, my, depth - 1);
|
|
810
|
+
bolt(ctx, mx, my, x2, y2, depth - 1);
|
|
811
|
+
}
|
|
812
|
+
return {
|
|
813
|
+
tick(ctx, dt, now, st) {
|
|
814
|
+
knobX = lerp(knobX, st.toggled ? st.w - 18 : 18, dt * 14);
|
|
815
|
+
arcTime += dt;
|
|
816
|
+
|
|
817
|
+
ctx.fillStyle = st.toggled ? 'rgba(251,191,36,.15)' : 'rgba(255,255,255,.04)';
|
|
818
|
+
roundRect(ctx, 0, 0, st.w, st.h, st.h / 2); ctx.fill();
|
|
819
|
+
|
|
820
|
+
if (st.toggled && ((arcTime * 8) | 0) % 3 !== 0) {
|
|
821
|
+
rng.reset(((now / 80) | 0) * 7 + 1);
|
|
822
|
+
ctx.strokeStyle = 'rgba(251,191,36,.7)'; ctx.lineWidth = 1.5;
|
|
823
|
+
ctx.beginPath(); ctx.moveTo(8, st.h / 2); bolt(ctx, 8, st.h / 2, st.w - 8, st.h / 2, 3); ctx.stroke();
|
|
824
|
+
ctx.strokeStyle = 'rgba(255,255,255,.3)'; ctx.lineWidth = 0.5;
|
|
825
|
+
ctx.beginPath(); ctx.moveTo(8, st.h / 2); bolt(ctx, 8, st.h / 2, st.w - 8, st.h / 2, 3); ctx.stroke();
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
ctx.fillStyle = st.toggled ? '#fbbf24' : '#9999b8';
|
|
829
|
+
ctx.beginPath(); ctx.arc(knobX, st.h / 2, 12, 0, Math.PI * 2); ctx.fill();
|
|
830
|
+
label(ctx, st.toggled ? 'ON' : 'OFF', st.w / 2, st.h + 14, st.toggled ? '#fbbf24' : '#8888aa');
|
|
831
|
+
if (st.focused) focusRing(ctx, st.w, st.h, st.h / 2);
|
|
832
|
+
},
|
|
833
|
+
};
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/** 4. DNA Toggle -- Double helix wraps around the track. */
|
|
837
|
+
export function DNAToggle() {
|
|
838
|
+
let knobX = 18, phase = 0;
|
|
839
|
+
return {
|
|
840
|
+
tick(ctx, dt, now, st) {
|
|
841
|
+
knobX = lerp(knobX, st.toggled ? st.w - 18 : 18, dt * 10);
|
|
842
|
+
phase += dt * (st.toggled ? 4 : 1.5);
|
|
843
|
+
|
|
844
|
+
ctx.fillStyle = 'rgba(255,255,255,.04)';
|
|
845
|
+
roundRect(ctx, 0, 0, st.w, st.h, st.h / 2); ctx.fill();
|
|
846
|
+
|
|
847
|
+
const cy = st.h / 2;
|
|
848
|
+
for (let i = 0; i < 20; i++) {
|
|
849
|
+
const t = i / 20, x = 6 + t * (st.w - 12);
|
|
850
|
+
const y1 = cy + Math.sin(phase + t * Math.PI * 3) * 8;
|
|
851
|
+
const y2 = cy - Math.sin(phase + t * Math.PI * 3) * 8;
|
|
852
|
+
// Const strand colors, per-dot alpha via globalAlpha (was two
|
|
853
|
+
// `rgba(...,${...})` templates built per dot per frame).
|
|
854
|
+
if (st.toggled) {
|
|
855
|
+
ctx.fillStyle = '#a78bfa'; ctx.globalAlpha = 0.3 + Math.sin(phase + t * 6) * 0.2;
|
|
856
|
+
ctx.beginPath(); ctx.arc(x, y1, 2, 0, Math.PI * 2); ctx.fill();
|
|
857
|
+
ctx.fillStyle = '#f472b6'; ctx.globalAlpha = 0.3 + Math.cos(phase + t * 6) * 0.2;
|
|
858
|
+
ctx.beginPath(); ctx.arc(x, y2, 2, 0, Math.PI * 2); ctx.fill();
|
|
859
|
+
ctx.globalAlpha = 1;
|
|
860
|
+
} else {
|
|
861
|
+
ctx.fillStyle = 'rgba(255,255,255,.08)';
|
|
862
|
+
ctx.beginPath(); ctx.arc(x, y1, 2, 0, Math.PI * 2); ctx.fill();
|
|
863
|
+
ctx.fillStyle = 'rgba(255,255,255,.06)';
|
|
864
|
+
ctx.beginPath(); ctx.arc(x, y2, 2, 0, Math.PI * 2); ctx.fill();
|
|
865
|
+
}
|
|
866
|
+
if (i % 3 === 0) {
|
|
867
|
+
ctx.strokeStyle = 'rgba(255,255,255,.04)'; ctx.lineWidth = 0.5;
|
|
868
|
+
ctx.beginPath(); ctx.moveTo(x, y1); ctx.lineTo(x, y2); ctx.stroke();
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
ctx.fillStyle = st.toggled ? '#c084fc' : '#9999b8';
|
|
873
|
+
ctx.beginPath(); ctx.arc(knobX, cy, 12, 0, Math.PI * 2); ctx.fill();
|
|
874
|
+
label(ctx, st.toggled ? 'ON' : 'OFF', st.w / 2, st.h + 14, st.toggled ? '#c084fc' : '#8888aa');
|
|
875
|
+
if (st.focused) focusRing(ctx, st.w, st.h, st.h / 2);
|
|
876
|
+
},
|
|
877
|
+
};
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
// ===========================================================
|
|
882
|
+
// BUTTONS
|
|
883
|
+
// ===========================================================
|
|
884
|
+
|
|
885
|
+
/** 5. Heartbeat Button -- Heart icon pumps with particle burst on click. */
|
|
886
|
+
export function HeartbeatButton({ seed = 42 } = {}) {
|
|
887
|
+
const rng = new Random(seed);
|
|
888
|
+
// Fixed particle pool (life <= 0 == dead) -- no push/splice on the hot path.
|
|
889
|
+
const PARTS = 32;
|
|
890
|
+
const part = [];
|
|
891
|
+
for (let i = 0; i < PARTS; i++) part[i] = { x: 0, y: 0, vx: 0, vy: 0, life: 0 };
|
|
892
|
+
let scale = 1, beatPhase = 0;
|
|
893
|
+
function spawn(x, y) {
|
|
894
|
+
for (let i = 0; i < PARTS; i++) {
|
|
895
|
+
const p = part[i];
|
|
896
|
+
if (p.life <= 0) {
|
|
897
|
+
const a = rng.range(0, Math.PI * 2), v = rng.range(40, 120);
|
|
898
|
+
p.x = x; p.y = y; p.vx = Math.cos(a) * v; p.vy = Math.sin(a) * v; p.life = 1;
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
return {
|
|
904
|
+
onClick(x, y) {
|
|
905
|
+
scale = 1.3;
|
|
906
|
+
for (let i = 0; i < 12; i++) spawn(x || 80, y || 24);
|
|
907
|
+
},
|
|
908
|
+
tick(ctx, dt, now, st) {
|
|
909
|
+
scale = lerp(scale, 1, dt * 8);
|
|
910
|
+
beatPhase += dt * 5;
|
|
911
|
+
const pulse = 1 + Math.sin(beatPhase) * 0.03;
|
|
912
|
+
|
|
913
|
+
ctx.fillStyle = st.active ? 'rgba(220,20,60,.2)' : 'rgba(220,20,60,.08)';
|
|
914
|
+
roundRect(ctx, 0, 0, st.w, st.h, 10); ctx.fill();
|
|
915
|
+
|
|
916
|
+
// Heart shape
|
|
917
|
+
ctx.save(); ctx.translate(st.w / 2, st.h / 2); ctx.scale(scale * pulse, scale * pulse);
|
|
918
|
+
ctx.fillStyle = '#DC143C';
|
|
919
|
+
ctx.beginPath();
|
|
920
|
+
ctx.moveTo(0, 4); ctx.bezierCurveTo(-10, -6, -20, -2, -20, 4);
|
|
921
|
+
ctx.bezierCurveTo(-20, 14, 0, 20, 0, 20);
|
|
922
|
+
ctx.bezierCurveTo(0, 20, 20, 14, 20, 4);
|
|
923
|
+
ctx.bezierCurveTo(20, -2, 10, -6, 0, 4);
|
|
924
|
+
ctx.fill(); ctx.restore();
|
|
925
|
+
|
|
926
|
+
// Particles -- fixed pool, const color, alpha via globalAlpha
|
|
927
|
+
ctx.globalCompositeOperation = 'screen';
|
|
928
|
+
ctx.fillStyle = '#ff3c64';
|
|
929
|
+
for (let i = 0; i < PARTS; i++) {
|
|
930
|
+
const p = part[i];
|
|
931
|
+
if (p.life <= 0) continue;
|
|
932
|
+
p.x += p.vx * dt; p.y += p.vy * dt; p.life -= 2 * dt;
|
|
933
|
+
if (p.life <= 0) continue;
|
|
934
|
+
ctx.globalAlpha = p.life;
|
|
935
|
+
ctx.beginPath(); ctx.arc(p.x, p.y, 2, 0, Math.PI * 2); ctx.fill();
|
|
936
|
+
}
|
|
937
|
+
ctx.globalAlpha = 1;
|
|
938
|
+
ctx.globalCompositeOperation = 'source-over';
|
|
939
|
+
if (st.focused) focusRing(ctx, st.w, st.h, 10);
|
|
940
|
+
},
|
|
941
|
+
};
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
/** 6. Breathing Button -- Inhale/exhale pulse with particle halo. */
|
|
945
|
+
export function BreathingButton() {
|
|
946
|
+
// Alpha-graded green LUT (same RGB #6ee7b6, quantized alpha) built once, so
|
|
947
|
+
// the pulsing glow is a const-string lookup instead of three
|
|
948
|
+
// `rgba(110,231,182,${...})` templates per frame. shadowColor's alpha is not
|
|
949
|
+
// globalAlpha, so a LUT (not globalAlpha) is the zero-alloc fit here.
|
|
950
|
+
const GREEN = [];
|
|
951
|
+
for (let i = 0; i <= 64; i++) GREEN[i] = 'rgba(110,231,182,' + (i / 64).toFixed(3) + ')';
|
|
952
|
+
function greenA(a) { const i = a <= 0 ? 0 : a >= 1 ? 64 : (a * 64) | 0; return GREEN[i]; }
|
|
953
|
+
let phase = 0;
|
|
954
|
+
return {
|
|
955
|
+
tick(ctx, dt, now, st) {
|
|
956
|
+
phase += dt * 1.5;
|
|
957
|
+
const breath = (Math.sin(phase) + 1) / 2; // 0--1
|
|
958
|
+
const radius = 4 + breath * 6;
|
|
959
|
+
|
|
960
|
+
// Outer glow
|
|
961
|
+
ctx.shadowBlur = 10 + breath * 15; ctx.shadowColor = greenA(0.2 + breath * 0.3);
|
|
962
|
+
ctx.fillStyle = greenA(0.05 + breath * 0.08);
|
|
963
|
+
roundRect(ctx, 0, 0, st.w, st.h, 10); ctx.fill();
|
|
964
|
+
ctx.shadowBlur = 0;
|
|
965
|
+
|
|
966
|
+
// Halo ring
|
|
967
|
+
ctx.strokeStyle = greenA(0.1 + breath * 0.2);
|
|
968
|
+
ctx.lineWidth = 1;
|
|
969
|
+
ctx.beginPath(); ctx.arc(st.w / 2, st.h / 2, st.w / 2 + radius, 0, Math.PI * 2); ctx.stroke();
|
|
970
|
+
|
|
971
|
+
ctx.fillStyle = '#6ee7b6';
|
|
972
|
+
ctx.font = "600 12px 'Space Grotesk',sans-serif"; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
|
973
|
+
ctx.fillText('BREATHE', st.w / 2, st.h / 2);
|
|
974
|
+
if (st.focused) focusRing(ctx, st.w, st.h, 10);
|
|
975
|
+
},
|
|
976
|
+
};
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
/** 7. Ink Splash Button -- Calligraphy ink splatter on click. */
|
|
980
|
+
export function InkSplashButton({ seed = 42 } = {}) {
|
|
981
|
+
const rng = new Random(seed);
|
|
982
|
+
// Fixed splat pool (life <= 0 == dead).
|
|
983
|
+
const SPLATS = 32;
|
|
984
|
+
const splat = [];
|
|
985
|
+
for (let i = 0; i < SPLATS; i++) splat[i] = { x: 0, y: 0, r: 0, life: 0 };
|
|
986
|
+
let pressScale = 1;
|
|
987
|
+
function spawn(cx, cy) {
|
|
988
|
+
for (let i = 0; i < SPLATS; i++) {
|
|
989
|
+
const s = splat[i];
|
|
990
|
+
if (s.life <= 0) {
|
|
991
|
+
const a = rng.range(0, Math.PI * 2), d = rng.range(5, 35);
|
|
992
|
+
s.x = cx + Math.cos(a) * d; s.y = cy + Math.sin(a) * d; s.r = rng.range(2, 8); s.life = 1;
|
|
993
|
+
return;
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
return {
|
|
998
|
+
onClick(x, y) {
|
|
999
|
+
pressScale = 0.88;
|
|
1000
|
+
const cx = x || 80, cy = y || 24;
|
|
1001
|
+
for (let i = 0; i < 20; i++) spawn(cx, cy);
|
|
1002
|
+
},
|
|
1003
|
+
tick(ctx, dt, now, st) {
|
|
1004
|
+
pressScale = lerp(pressScale, 1, dt * 8);
|
|
1005
|
+
ctx.save(); ctx.translate(st.w / 2, st.h / 2); ctx.scale(pressScale, pressScale); ctx.translate(-st.w / 2, -st.h / 2);
|
|
1006
|
+
|
|
1007
|
+
ctx.fillStyle = 'rgba(255,255,255,.06)'; roundRect(ctx, 0, 0, st.w, st.h, 10); ctx.fill();
|
|
1008
|
+
ctx.fillStyle = '#e2e2f0'; ctx.font = "600 13px 'Space Grotesk',sans-serif"; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
|
1009
|
+
ctx.fillText('INK', st.w / 2, st.h / 2);
|
|
1010
|
+
ctx.restore();
|
|
1011
|
+
|
|
1012
|
+
// Splats -- fixed pool, const color, alpha via globalAlpha
|
|
1013
|
+
ctx.fillStyle = '#1e1e32';
|
|
1014
|
+
for (let i = 0; i < SPLATS; i++) {
|
|
1015
|
+
const s = splat[i];
|
|
1016
|
+
if (s.life <= 0) continue;
|
|
1017
|
+
s.life -= 1.5 * dt;
|
|
1018
|
+
if (s.life <= 0) continue;
|
|
1019
|
+
ctx.globalAlpha = s.life * 0.7;
|
|
1020
|
+
ctx.beginPath(); ctx.arc(s.x, s.y, s.r * easeOut(1 - s.life), 0, Math.PI * 2); ctx.fill();
|
|
1021
|
+
}
|
|
1022
|
+
ctx.globalAlpha = 1;
|
|
1023
|
+
if (st.focused) focusRing(ctx, st.w, st.h, 10);
|
|
1024
|
+
},
|
|
1025
|
+
};
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
/** 8. Pixel Dissolve Button -- Hover breaks into floating pixels, reforms on leave. */
|
|
1029
|
+
export function PixelDissolveButton({ seed = 42, cols = 16, rows = 5 } = {}) {
|
|
1030
|
+
const rng = new Random(seed);
|
|
1031
|
+
const N = cols * rows;
|
|
1032
|
+
const ox = new Float32Array(N), oy = new Float32Array(N);
|
|
1033
|
+
const px = new Float32Array(N), py = new Float32Array(N);
|
|
1034
|
+
const vx = new Float32Array(N), vy = new Float32Array(N);
|
|
1035
|
+
let dissolveT = 0;
|
|
1036
|
+
|
|
1037
|
+
return {
|
|
1038
|
+
init(ctx, w, h) {
|
|
1039
|
+
const pw = w / cols, ph = h / rows;
|
|
1040
|
+
for (let i = 0; i < N; i++) {
|
|
1041
|
+
ox[i] = (i % cols) * pw + pw / 2;
|
|
1042
|
+
oy[i] = ((i / cols) | 0) * ph + ph / 2;
|
|
1043
|
+
px[i] = ox[i]; py[i] = oy[i];
|
|
1044
|
+
}
|
|
1045
|
+
},
|
|
1046
|
+
onHover() { rng.reset(42); for (let i = 0; i < N; i++) { vx[i] = rng.range(-30, 30); vy[i] = rng.range(-30, 30); } },
|
|
1047
|
+
tick(ctx, dt, now, st) {
|
|
1048
|
+
dissolveT = lerp(dissolveT, st.hover ? 1 : 0, dt * 6);
|
|
1049
|
+
|
|
1050
|
+
ctx.fillStyle = st.hover ? 'rgba(167,139,250,.4)' : 'rgba(255,255,255,.12)';
|
|
1051
|
+
const pw = st.w / cols, ph = st.h / rows;
|
|
1052
|
+
|
|
1053
|
+
for (let i = 0; i < N; i++) {
|
|
1054
|
+
const tx = ox[i] + vx[i] * dissolveT;
|
|
1055
|
+
const ty = oy[i] + vy[i] * dissolveT;
|
|
1056
|
+
px[i] = lerp(px[i], tx, dt * 10);
|
|
1057
|
+
py[i] = lerp(py[i], ty, dt * 10);
|
|
1058
|
+
ctx.globalAlpha = 1 - dissolveT * 0.3;
|
|
1059
|
+
ctx.fillRect(px[i] - pw / 2 + 0.5, py[i] - ph / 2 + 0.5, pw - 1, ph - 1);
|
|
1060
|
+
}
|
|
1061
|
+
ctx.globalAlpha = 1;
|
|
1062
|
+
|
|
1063
|
+
if (dissolveT < 0.3) {
|
|
1064
|
+
ctx.fillStyle = '#e2e2f0'; ctx.font = "600 12px 'Space Grotesk',sans-serif";
|
|
1065
|
+
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
|
1066
|
+
ctx.globalAlpha = 1 - dissolveT / 0.3;
|
|
1067
|
+
ctx.fillText('DISSOLVE', st.w / 2, st.h / 2);
|
|
1068
|
+
ctx.globalAlpha = 1;
|
|
1069
|
+
}
|
|
1070
|
+
if (st.focused) focusRing(ctx, st.w, st.h, 0);
|
|
1071
|
+
},
|
|
1072
|
+
};
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
/** 9. Firework Button -- Shoots fireworks upward on click. */
|
|
1076
|
+
export function FireworkButton({ seed = 42 } = {}) {
|
|
1077
|
+
const rng = new Random(seed);
|
|
1078
|
+
const colors = ['#ff6b6b', '#ffd93d', '#6bcb77', '#4d96ff', '#ff6bcb'];
|
|
1079
|
+
// Fixed pools (life <= 0 == dead) -- no push/splice on the hot path.
|
|
1080
|
+
const ROCKETS = 16, SPARKS = 256;
|
|
1081
|
+
const rocket = [];
|
|
1082
|
+
for (let i = 0; i < ROCKETS; i++) rocket[i] = { x: 0, y: 0, vy: 0, life: 0, color: '#fff' };
|
|
1083
|
+
const spark = [];
|
|
1084
|
+
for (let i = 0; i < SPARKS; i++) spark[i] = { x: 0, y: 0, vx: 0, vy: 0, life: 0, color: '#fff' };
|
|
1085
|
+
let pressScale = 1;
|
|
1086
|
+
|
|
1087
|
+
function spawnRocket(x, y) {
|
|
1088
|
+
for (let i = 0; i < ROCKETS; i++) {
|
|
1089
|
+
const r = rocket[i];
|
|
1090
|
+
if (r.life <= 0) { r.x = x; r.y = y; r.vy = -rng.range(150, 250); r.life = 1; r.color = rng.pick(colors); return; }
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
function spawnSpark(x, y, color) {
|
|
1094
|
+
for (let i = 0; i < SPARKS; i++) {
|
|
1095
|
+
const s = spark[i];
|
|
1096
|
+
if (s.life <= 0) {
|
|
1097
|
+
const a = rng.range(0, Math.PI * 2), v = rng.range(30, 100);
|
|
1098
|
+
s.x = x; s.y = y; s.vx = Math.cos(a) * v; s.vy = Math.sin(a) * v; s.life = 1; s.color = color;
|
|
1099
|
+
return;
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
return {
|
|
1105
|
+
onClick(x, y, st) {
|
|
1106
|
+
pressScale = 0.88;
|
|
1107
|
+
spawnRocket(x || st.w / 2, st.h);
|
|
1108
|
+
},
|
|
1109
|
+
tick(ctx, dt, now, st) {
|
|
1110
|
+
pressScale = lerp(pressScale, 1, dt * 10);
|
|
1111
|
+
ctx.save(); ctx.translate(st.w / 2, st.h / 2); ctx.scale(pressScale, pressScale); ctx.translate(-st.w / 2, -st.h / 2);
|
|
1112
|
+
ctx.fillStyle = 'rgba(255,255,255,.06)'; roundRect(ctx, 0, 0, st.w, st.h, 10); ctx.fill();
|
|
1113
|
+
ctx.fillStyle = '#fbbf24'; ctx.font = "600 13px 'Space Grotesk',sans-serif"; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
|
1114
|
+
ctx.fillText('\u{1F386} FIRE', st.w / 2, st.h / 2);
|
|
1115
|
+
ctx.restore();
|
|
1116
|
+
|
|
1117
|
+
// Rockets -- fixed pool; on death, burst into sparks
|
|
1118
|
+
for (let i = 0; i < ROCKETS; i++) {
|
|
1119
|
+
const r = rocket[i];
|
|
1120
|
+
if (r.life <= 0) continue;
|
|
1121
|
+
r.y += r.vy * dt; r.vy += 80 * dt; r.life -= dt;
|
|
1122
|
+
ctx.fillStyle = r.color; ctx.beginPath(); ctx.arc(r.x, r.y, 3, 0, Math.PI * 2); ctx.fill();
|
|
1123
|
+
if (r.life <= 0 || r.y < -20) {
|
|
1124
|
+
for (let j = 0; j < 20; j++) spawnSpark(r.x, r.y, r.color);
|
|
1125
|
+
r.life = 0;
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
// Sparks -- fixed pool, const color per spark, alpha via globalAlpha
|
|
1130
|
+
ctx.globalCompositeOperation = 'screen';
|
|
1131
|
+
for (let i = 0; i < SPARKS; i++) {
|
|
1132
|
+
const s = spark[i];
|
|
1133
|
+
if (s.life <= 0) continue;
|
|
1134
|
+
s.x += s.vx * dt; s.y += s.vy * dt; s.vy += 60 * dt; s.life -= 1.5 * dt;
|
|
1135
|
+
if (s.life <= 0) continue;
|
|
1136
|
+
ctx.fillStyle = s.color; ctx.globalAlpha = s.life;
|
|
1137
|
+
ctx.beginPath(); ctx.arc(s.x, s.y, 1.5, 0, Math.PI * 2); ctx.fill();
|
|
1138
|
+
}
|
|
1139
|
+
ctx.globalCompositeOperation = 'source-over'; ctx.globalAlpha = 1;
|
|
1140
|
+
if (st.focused) focusRing(ctx, st.w, st.h, 10);
|
|
1141
|
+
},
|
|
1142
|
+
};
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
|
|
1146
|
+
// ===========================================================
|
|
1147
|
+
// SLIDERS
|
|
1148
|
+
// ===========================================================
|
|
1149
|
+
|
|
1150
|
+
/** 10. Aurora Slider -- Northern lights colors flow along the filled track. */
|
|
1151
|
+
export function AuroraSlider() {
|
|
1152
|
+
const REF = 100, PHASES = 12;
|
|
1153
|
+
const colors = ['#6ee7b6', '#38bdf8', '#a78bfa', '#c084fc'];
|
|
1154
|
+
let time = 0;
|
|
1155
|
+
// The shimmer animated the gradient stops per frame. Pre-build one gradient
|
|
1156
|
+
// per animation phase in init (all 0..REF) and cycle them by time; scale to
|
|
1157
|
+
// tx/REF at paint. Bounded, const set -> no per-frame createLinearGradient.
|
|
1158
|
+
const beams = [];
|
|
1159
|
+
return {
|
|
1160
|
+
init(ctx) {
|
|
1161
|
+
for (let p = 0; p < PHASES; p++) {
|
|
1162
|
+
const t = p / PHASES * PI2;
|
|
1163
|
+
const g = ctx.createLinearGradient(0, 0, REF, 0);
|
|
1164
|
+
for (let i = 0; i < 4; i++) {
|
|
1165
|
+
const pos = clamp(i / 3 + Math.sin(t + i) * 0.15, 0, 1);
|
|
1166
|
+
g.addColorStop(pos, colors[i]);
|
|
1167
|
+
}
|
|
1168
|
+
beams[p] = g;
|
|
1169
|
+
}
|
|
1170
|
+
},
|
|
1171
|
+
tick(ctx, dt, now, st) {
|
|
1172
|
+
time += dt;
|
|
1173
|
+
ctx.fillStyle = 'rgba(255,255,255,.06)'; roundRect(ctx, 0, 12, st.w, 4, 2); ctx.fill();
|
|
1174
|
+
const tx = st.val * st.w;
|
|
1175
|
+
if (tx > 2) {
|
|
1176
|
+
const beam = beams[((time * 2) | 0) % PHASES] || beams[0];
|
|
1177
|
+
ctx.save();
|
|
1178
|
+
ctx.scale(tx / REF, 1);
|
|
1179
|
+
ctx.fillStyle = beam; ctx.shadowBlur = 8; ctx.shadowColor = '#6ee7b6';
|
|
1180
|
+
roundRect(ctx, 0, 12, REF, 4, 2); ctx.fill();
|
|
1181
|
+
ctx.restore();
|
|
1182
|
+
ctx.shadowBlur = 0;
|
|
1183
|
+
}
|
|
1184
|
+
ctx.fillStyle = '#fff'; ctx.beginPath(); ctx.arc(tx, 14, 8, 0, Math.PI * 2); ctx.fill();
|
|
1185
|
+
label(ctx, PCT[Math.round(st.val * 100)], st.w / 2, st.h + 10);
|
|
1186
|
+
if (st.focused) focusRing(ctx, st.w, st.h, 4);
|
|
1187
|
+
},
|
|
1188
|
+
};
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
/** 11. Wave Slider -- Audio waveform visualization on the filled track. */
|
|
1192
|
+
export function WaveSlider({ seed = 42 } = {}) {
|
|
1193
|
+
const rng = new Random(seed);
|
|
1194
|
+
const bars = new Float32Array(40);
|
|
1195
|
+
return {
|
|
1196
|
+
init() { for (let i = 0; i < 40; i++) bars[i] = rng.range(0.2, 1); },
|
|
1197
|
+
tick(ctx, dt, now, st) {
|
|
1198
|
+
ctx.fillStyle = 'rgba(255,255,255,.04)'; roundRect(ctx, 0, 4, st.w, 20, 3); ctx.fill();
|
|
1199
|
+
const tx = st.val * st.w, bw = st.w / 40;
|
|
1200
|
+
for (let i = 0; i < 40; i++) {
|
|
1201
|
+
const bx = i * bw, active = bx < tx;
|
|
1202
|
+
const h = bars[i] * 16 * (0.5 + Math.sin(now / 300 + i * 0.3) * 0.3);
|
|
1203
|
+
ctx.fillStyle = active ? '#f472b6' : 'rgba(255,255,255,.06)';
|
|
1204
|
+
ctx.fillRect(bx + 1, 14 - h / 2, bw - 2, h);
|
|
1205
|
+
}
|
|
1206
|
+
ctx.fillStyle = '#f472b6'; ctx.beginPath(); ctx.arc(tx, 14, 8, 0, Math.PI * 2); ctx.fill();
|
|
1207
|
+
label(ctx, PCT[Math.round(st.val * 100)], st.w / 2, st.h + 10);
|
|
1208
|
+
if (st.focused) focusRing(ctx, st.w, st.h, 4);
|
|
1209
|
+
},
|
|
1210
|
+
};
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
/** 12. Elastic Band Slider -- Track rubber-bands ahead of thumb, snaps back. */
|
|
1214
|
+
export function ElasticBandSlider() {
|
|
1215
|
+
let leadX = 0;
|
|
1216
|
+
return {
|
|
1217
|
+
tick(ctx, dt, now, st) {
|
|
1218
|
+
const tx = st.val * st.w;
|
|
1219
|
+
leadX = lerp(leadX, tx, dt * 4); // Laggy -- creates stretch
|
|
1220
|
+
const stretch = (tx - leadX) * 0.3;
|
|
1221
|
+
|
|
1222
|
+
ctx.fillStyle = 'rgba(255,255,255,.06)'; roundRect(ctx, 0, 12, st.w, 4, 2); ctx.fill();
|
|
1223
|
+
|
|
1224
|
+
// Elastic band (curves toward target)
|
|
1225
|
+
ctx.strokeStyle = '#fb923c'; ctx.lineWidth = 3;
|
|
1226
|
+
ctx.beginPath(); ctx.moveTo(0, 14);
|
|
1227
|
+
ctx.quadraticCurveTo(leadX + stretch, 14 + stretch * 0.2, tx, 14);
|
|
1228
|
+
ctx.stroke();
|
|
1229
|
+
|
|
1230
|
+
ctx.fillStyle = '#fb923c'; ctx.beginPath(); ctx.arc(tx, 14, 9, 0, Math.PI * 2); ctx.fill();
|
|
1231
|
+
label(ctx, PCT[Math.round(st.val * 100)], st.w / 2, st.h + 10);
|
|
1232
|
+
if (st.focused) focusRing(ctx, st.w, st.h, 4);
|
|
1233
|
+
},
|
|
1234
|
+
};
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
/** 13. Gravity Slider -- Track sags under the weight of the thumb. */
|
|
1238
|
+
export function GravitySlider() {
|
|
1239
|
+
return {
|
|
1240
|
+
tick(ctx, dt, now, st) {
|
|
1241
|
+
const tx = st.val * st.w, sag = 8;
|
|
1242
|
+
|
|
1243
|
+
// Sagging track (quadratic curve through thumb position)
|
|
1244
|
+
ctx.strokeStyle = 'rgba(255,255,255,.1)'; ctx.lineWidth = 3;
|
|
1245
|
+
ctx.beginPath(); ctx.moveTo(0, 14);
|
|
1246
|
+
ctx.quadraticCurveTo(tx, 14 + sag, st.w, 14);
|
|
1247
|
+
ctx.stroke();
|
|
1248
|
+
|
|
1249
|
+
// Filled portion follows the sag
|
|
1250
|
+
ctx.strokeStyle = '#c084fc'; ctx.lineWidth = 3;
|
|
1251
|
+
ctx.beginPath(); ctx.moveTo(0, 14);
|
|
1252
|
+
const midSag = sag * (tx / st.w);
|
|
1253
|
+
ctx.quadraticCurveTo(tx / 2, 14 + midSag, tx, 14 + sag * Math.sin(Math.PI * st.val));
|
|
1254
|
+
ctx.stroke();
|
|
1255
|
+
|
|
1256
|
+
ctx.fillStyle = '#c084fc'; ctx.beginPath();
|
|
1257
|
+
ctx.arc(tx, 14 + sag * Math.sin(Math.PI * st.val), 10, 0, Math.PI * 2); ctx.fill();
|
|
1258
|
+
|
|
1259
|
+
label(ctx, PCT[Math.round(st.val * 100)], st.w / 2, st.h + 14);
|
|
1260
|
+
if (st.focused) focusRing(ctx, st.w, st.h + 8, 4);
|
|
1261
|
+
},
|
|
1262
|
+
};
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
|
|
1266
|
+
// ===========================================================
|
|
1267
|
+
// LOADERS (use toggle to start/stop)
|
|
1268
|
+
// ===========================================================
|
|
1269
|
+
|
|
1270
|
+
/** 14. Orbit Loader -- Planets orbit a sun. Toggle starts/stops. */
|
|
1271
|
+
export function OrbitLoader() {
|
|
1272
|
+
let phase = 0;
|
|
1273
|
+
const planets = [
|
|
1274
|
+
{ r: 14, speed: 2.0, size: 3, color: '#38bdf8' },
|
|
1275
|
+
{ r: 22, speed: 1.2, size: 2.5, color: '#6ee7b6' },
|
|
1276
|
+
{ r: 30, speed: 0.7, size: 2, color: '#fbbf24' },
|
|
1277
|
+
];
|
|
1278
|
+
return {
|
|
1279
|
+
tick(ctx, dt, now, st) {
|
|
1280
|
+
if (st.toggled) phase += dt;
|
|
1281
|
+
const cx = st.w / 2, cy = st.h / 2;
|
|
1282
|
+
|
|
1283
|
+
// Orbits (plain loop -- forEach's arrow was a per-frame closure alloc)
|
|
1284
|
+
for (let i = 0; i < planets.length; i++) {
|
|
1285
|
+
const p = planets[i];
|
|
1286
|
+
ctx.strokeStyle = 'rgba(255,255,255,.04)'; ctx.lineWidth = 0.5;
|
|
1287
|
+
ctx.beginPath(); ctx.arc(cx, cy, p.r, 0, Math.PI * 2); ctx.stroke();
|
|
1288
|
+
const a = phase * p.speed;
|
|
1289
|
+
ctx.fillStyle = p.color;
|
|
1290
|
+
ctx.beginPath(); ctx.arc(cx + Math.cos(a) * p.r, cy + Math.sin(a) * p.r, p.size, 0, Math.PI * 2); ctx.fill();
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
// Sun
|
|
1294
|
+
ctx.fillStyle = '#fbbf24'; ctx.beginPath(); ctx.arc(cx, cy, 5, 0, Math.PI * 2); ctx.fill();
|
|
1295
|
+
label(ctx, st.toggled ? 'LOADING...' : 'IDLE', st.w / 2, st.h + 14, st.toggled ? '#fbbf24' : '#8888aa');
|
|
1296
|
+
if (st.focused) focusRing(ctx, st.w, st.h, st.h / 2);
|
|
1297
|
+
},
|
|
1298
|
+
};
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
/** 15. Helix Loader -- DNA double helix spinning. */
|
|
1302
|
+
export function HelixLoader() {
|
|
1303
|
+
let phase = 0;
|
|
1304
|
+
return {
|
|
1305
|
+
tick(ctx, dt, now, st) {
|
|
1306
|
+
if (st.toggled) phase += dt * 4;
|
|
1307
|
+
const cx = st.w / 2, cy = st.h / 2;
|
|
1308
|
+
|
|
1309
|
+
for (let i = 0; i < 12; i++) {
|
|
1310
|
+
const t = i / 12, a = phase + t * Math.PI * 2;
|
|
1311
|
+
const x1 = cx + Math.cos(a) * 16, y1 = cy + Math.sin(a) * 6;
|
|
1312
|
+
const x2 = cx - Math.cos(a) * 16, y2 = cy - Math.sin(a) * 6;
|
|
1313
|
+
const depth = (Math.sin(a) + 1) / 2;
|
|
1314
|
+
|
|
1315
|
+
// Const strand colors, per-node alpha via globalAlpha (was three
|
|
1316
|
+
// `rgba(...,${...})` templates per node per frame).
|
|
1317
|
+
ctx.fillStyle = '#a78bfa'; ctx.globalAlpha = 0.2 + depth * 0.6;
|
|
1318
|
+
ctx.beginPath(); ctx.arc(x1, y1, 2 + depth, 0, Math.PI * 2); ctx.fill();
|
|
1319
|
+
ctx.fillStyle = '#f472b6'; ctx.globalAlpha = 0.2 + (1 - depth) * 0.6;
|
|
1320
|
+
ctx.beginPath(); ctx.arc(x2, y2, 2 + (1 - depth), 0, Math.PI * 2); ctx.fill();
|
|
1321
|
+
|
|
1322
|
+
ctx.strokeStyle = '#ffffff'; ctx.globalAlpha = 0.03 + depth * 0.04;
|
|
1323
|
+
ctx.lineWidth = 0.5; ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
|
|
1324
|
+
}
|
|
1325
|
+
ctx.globalAlpha = 1;
|
|
1326
|
+
label(ctx, st.toggled ? 'PROCESSING' : 'IDLE', st.w / 2, st.h + 14, st.toggled ? '#c084fc' : '#8888aa');
|
|
1327
|
+
if (st.focused) focusRing(ctx, st.w, st.h, st.h / 2);
|
|
1328
|
+
},
|
|
1329
|
+
};
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
|
|
1333
|
+
// ===========================================================
|
|
1334
|
+
// CHECKBOXES (use toggle events)
|
|
1335
|
+
// ===========================================================
|
|
1336
|
+
|
|
1337
|
+
/** 16. Ripple Checkbox -- Material-style ripple ring + morphing checkmark. */
|
|
1338
|
+
export function RippleCheck() {
|
|
1339
|
+
// Fixed ripple pool (life <= 0 == dead) -- no push/splice on the hot path.
|
|
1340
|
+
const RIPPLES = 8;
|
|
1341
|
+
const ripR = new Float64Array(RIPPLES);
|
|
1342
|
+
const ripLife = new Float64Array(RIPPLES);
|
|
1343
|
+
let checkT = 0;
|
|
1344
|
+
function spawn() {
|
|
1345
|
+
for (let i = 0; i < RIPPLES; i++) {
|
|
1346
|
+
if (ripLife[i] <= 0) { ripR[i] = 0; ripLife[i] = 1; return; }
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
return {
|
|
1350
|
+
onToggle(checked) { if (checked) spawn(); },
|
|
1351
|
+
tick(ctx, dt, now, st) {
|
|
1352
|
+
checkT = lerp(checkT, st.toggled ? 1 : 0, dt * 10);
|
|
1353
|
+
const sz = Math.min(st.w, st.h), cx = sz / 2, cy = sz / 2;
|
|
1354
|
+
|
|
1355
|
+
// Box
|
|
1356
|
+
ctx.fillStyle = st.toggled ? '#6ee7b6' : 'rgba(255,255,255,.06)';
|
|
1357
|
+
roundRect(ctx, 0, 0, sz, sz, 6); ctx.fill();
|
|
1358
|
+
|
|
1359
|
+
// Ripples -- fixed pool, const color, alpha via globalAlpha
|
|
1360
|
+
ctx.strokeStyle = '#6ee7b6'; ctx.lineWidth = 2;
|
|
1361
|
+
for (let i = 0; i < RIPPLES; i++) {
|
|
1362
|
+
if (ripLife[i] <= 0) continue;
|
|
1363
|
+
ripR[i] += 50 * dt; ripLife[i] -= 2 * dt;
|
|
1364
|
+
if (ripLife[i] <= 0) continue;
|
|
1365
|
+
ctx.globalAlpha = ripLife[i];
|
|
1366
|
+
ctx.beginPath(); ctx.arc(cx, cy, ripR[i], 0, Math.PI * 2); ctx.stroke();
|
|
1367
|
+
}
|
|
1368
|
+
ctx.globalAlpha = 1;
|
|
1369
|
+
|
|
1370
|
+
// Checkmark (animated draw)
|
|
1371
|
+
if (checkT > 0.01) {
|
|
1372
|
+
ctx.strokeStyle = '#000'; ctx.lineWidth = 3; ctx.lineCap = 'round';
|
|
1373
|
+
ctx.beginPath();
|
|
1374
|
+
const p1 = clamp(checkT * 2, 0, 1); // First stroke
|
|
1375
|
+
const p2 = clamp(checkT * 2 - 1, 0, 1); // Second stroke
|
|
1376
|
+
ctx.moveTo(cx - 7, cy);
|
|
1377
|
+
ctx.lineTo(cx - 7 + 7 * p1, cy + 7 * p1);
|
|
1378
|
+
if (p2 > 0) ctx.lineTo(cx + 14 * p2, cy + 7 - 14 * p2);
|
|
1379
|
+
ctx.stroke(); ctx.lineCap = 'butt';
|
|
1380
|
+
}
|
|
1381
|
+
label(ctx, st.toggled ? '\u2713' : '\u25CB', sz / 2, sz + 12, st.toggled ? '#6ee7b6' : '#8888aa');
|
|
1382
|
+
if (st.focused) focusRing(ctx, sz, sz, 6);
|
|
1383
|
+
},
|
|
1384
|
+
};
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
/** 17. Morph Checkbox -- X morphs into checkmark smoothly. */
|
|
1388
|
+
export function MorphCheck() {
|
|
1389
|
+
let t = 0;
|
|
1390
|
+
return {
|
|
1391
|
+
tick(ctx, dt, now, st) {
|
|
1392
|
+
t = lerp(t, st.toggled ? 1 : 0, dt * 8);
|
|
1393
|
+
const sz = Math.min(st.w, st.h), cx = sz / 2, cy = sz / 2;
|
|
1394
|
+
|
|
1395
|
+
// Box bg: faint white early, blue (alpha via globalAlpha) as t rises.
|
|
1396
|
+
if (lerp(0.06, 0.15, t) < 0.1) {
|
|
1397
|
+
ctx.fillStyle = 'rgba(255,255,255,.06)';
|
|
1398
|
+
roundRect(ctx, 0, 0, sz, sz, 6); ctx.fill();
|
|
1399
|
+
} else {
|
|
1400
|
+
ctx.fillStyle = '#38bdf8'; ctx.globalAlpha = lerp(0, 0.2, t);
|
|
1401
|
+
roundRect(ctx, 0, 0, sz, sz, 6); ctx.fill();
|
|
1402
|
+
ctx.globalAlpha = 1;
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
ctx.strokeStyle = t > 0.5 ? '#38bdf8' : '#9999b8'; ctx.lineWidth = 2.5; ctx.lineCap = 'round';
|
|
1406
|
+
|
|
1407
|
+
// Morph: X -> checkmark by interpolating endpoints
|
|
1408
|
+
const x1a = cx - 8, y1a = cy - 8; // X top-left
|
|
1409
|
+
const x1b = cx + 8, y1b = cy + 8; // X bottom-right
|
|
1410
|
+
const x2a = cx - 8, y2a = cy + 8; // X bottom-left
|
|
1411
|
+
const x2b = cx + 8, y2b = cy - 8; // X top-right
|
|
1412
|
+
|
|
1413
|
+
const c1a = cx - 7, c1b = cy; // Check start
|
|
1414
|
+
const c2a = cx - 2, c2b = cy + 7; // Check middle
|
|
1415
|
+
const c3a = cx + 8, c3b = cy - 6; // Check end
|
|
1416
|
+
|
|
1417
|
+
ctx.beginPath();
|
|
1418
|
+
ctx.moveTo(lerp(x1a, c1a, t), lerp(y1a, c1b, t));
|
|
1419
|
+
ctx.lineTo(lerp(x1b, c2a, t), lerp(y1b, c2b, t));
|
|
1420
|
+
ctx.stroke();
|
|
1421
|
+
|
|
1422
|
+
ctx.beginPath();
|
|
1423
|
+
ctx.moveTo(lerp(x2a, c2a, t), lerp(y2a, c2b, t));
|
|
1424
|
+
ctx.lineTo(lerp(x2b, c3a, t), lerp(y2b, c3b, t));
|
|
1425
|
+
ctx.stroke();
|
|
1426
|
+
ctx.lineCap = 'butt';
|
|
1427
|
+
|
|
1428
|
+
label(ctx, st.toggled ? '\u2713' : '\u2715', sz / 2, sz + 12, st.toggled ? '#38bdf8' : '#8888aa');
|
|
1429
|
+
if (st.focused) focusRing(ctx, sz, sz, 6);
|
|
1430
|
+
},
|
|
1431
|
+
};
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
|
|
1435
|
+
// ===========================================================
|
|
1436
|
+
// COUNTERS (use slider val as input)
|
|
1437
|
+
// ===========================================================
|
|
1438
|
+
|
|
1439
|
+
/** 18. Flame Counter -- Number with rising heat particles driven by slider value. */
|
|
1440
|
+
export function FlameCounter({ seed = 42 } = {}) {
|
|
1441
|
+
const rng = new Random(seed);
|
|
1442
|
+
// Fixed ember pool + heat-color LUT (rgb quantized by value), so neither the
|
|
1443
|
+
// pool nor the per-ember color allocates. The number string is rebuilt only
|
|
1444
|
+
// when the displayed integer changes.
|
|
1445
|
+
const EMBERS = 64;
|
|
1446
|
+
const ember = [];
|
|
1447
|
+
for (let i = 0; i < EMBERS; i++) ember[i] = { x: 0, y: 0, vy: 0, life: 0, size: 0 };
|
|
1448
|
+
const HEAT = [];
|
|
1449
|
+
for (let i = 0; i <= 32; i++) HEAT[i] = 'rgb(' + Math.round(200 + (i / 32) * 55) + ',' + Math.round(60 + (i / 32) * 80) + ',20)';
|
|
1450
|
+
let numStr = '0', lastNum = -1;
|
|
1451
|
+
function spawn(st) {
|
|
1452
|
+
for (let i = 0; i < EMBERS; i++) {
|
|
1453
|
+
const e = ember[i];
|
|
1454
|
+
if (e.life <= 0) { e.x = rng.range(20, st.w - 20); e.y = st.h - 5; e.vy = -rng.range(20, 60); e.life = 1; e.size = rng.range(1, 3); return; }
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
return {
|
|
1458
|
+
tick(ctx, dt, now, st) {
|
|
1459
|
+
const val = Math.round(st.val * 999);
|
|
1460
|
+
if (val !== lastNum) { lastNum = val; numStr = String(val); }
|
|
1461
|
+
|
|
1462
|
+
// Spawn embers proportional to value (pool self-caps at EMBERS)
|
|
1463
|
+
if (st.val > 0.1) spawn(st);
|
|
1464
|
+
|
|
1465
|
+
// Background
|
|
1466
|
+
ctx.fillStyle = 'rgba(255,255,255,.03)'; roundRect(ctx, 0, 0, st.w, st.h, 10); ctx.fill();
|
|
1467
|
+
|
|
1468
|
+
// Embers -- fixed pool, heat color via LUT, alpha via globalAlpha
|
|
1469
|
+
ctx.globalCompositeOperation = 'screen';
|
|
1470
|
+
ctx.fillStyle = HEAT[(st.val * 32) | 0] || HEAT[32];
|
|
1471
|
+
for (let i = 0; i < EMBERS; i++) {
|
|
1472
|
+
const e = ember[i];
|
|
1473
|
+
if (e.life <= 0) continue;
|
|
1474
|
+
e.y += e.vy * dt; e.life -= dt * 0.8;
|
|
1475
|
+
if (e.life <= 0) continue;
|
|
1476
|
+
ctx.globalAlpha = e.life;
|
|
1477
|
+
ctx.beginPath(); ctx.arc(e.x, e.y, e.size, 0, Math.PI * 2); ctx.fill();
|
|
1478
|
+
}
|
|
1479
|
+
ctx.globalAlpha = 1;
|
|
1480
|
+
ctx.globalCompositeOperation = 'source-over';
|
|
1481
|
+
|
|
1482
|
+
// Number
|
|
1483
|
+
ctx.fillStyle = st.val > 0.7 ? '#ff6b6b' : st.val > 0.3 ? '#fbbf24' : '#9999b8';
|
|
1484
|
+
ctx.font = "700 28px 'JetBrains Mono',monospace"; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
|
1485
|
+
ctx.fillText(numStr, st.w / 2, st.h / 2);
|
|
1486
|
+
|
|
1487
|
+
label(ctx, 'HEAT', st.w / 2, st.h + 12);
|
|
1488
|
+
if (st.focused) focusRing(ctx, st.w, st.h, 10);
|
|
1489
|
+
},
|
|
1490
|
+
};
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
/** 19. Glitch Counter -- Number glitches and jitters as slider value increases. */
|
|
1494
|
+
export function GlitchCounter({ seed = 42 } = {}) {
|
|
1495
|
+
const rng = new Random(seed);
|
|
1496
|
+
let numStr = '0', lastNum = -1;
|
|
1497
|
+
return {
|
|
1498
|
+
tick(ctx, dt, now, st) {
|
|
1499
|
+
const val = Math.round(st.val * 999);
|
|
1500
|
+
if (val !== lastNum) { lastNum = val; numStr = String(val); }
|
|
1501
|
+
const intensity = st.val;
|
|
1502
|
+
|
|
1503
|
+
ctx.fillStyle = 'rgba(255,255,255,.03)'; roundRect(ctx, 0, 0, st.w, st.h, 10); ctx.fill();
|
|
1504
|
+
|
|
1505
|
+
// Scanlines -- const white, alpha via globalAlpha
|
|
1506
|
+
if (intensity > 0.2) {
|
|
1507
|
+
ctx.fillStyle = '#ffffff'; ctx.globalAlpha = intensity * 0.03;
|
|
1508
|
+
for (let y = 0; y < st.h; y += 3) ctx.fillRect(0, y, st.w, 1);
|
|
1509
|
+
ctx.globalAlpha = 1;
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
// Glitched number
|
|
1513
|
+
const jx = intensity > 0.5 ? (rng.next() - 0.5) * intensity * 6 : 0;
|
|
1514
|
+
const jy = intensity > 0.5 ? (rng.next() - 0.5) * intensity * 4 : 0;
|
|
1515
|
+
|
|
1516
|
+
if (intensity > 0.3 && rng.next() > 0.6) {
|
|
1517
|
+
ctx.globalCompositeOperation = 'screen';
|
|
1518
|
+
ctx.font = "700 28px 'JetBrains Mono',monospace"; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
|
1519
|
+
// RGB split -- const channel colors, shared alpha via globalAlpha
|
|
1520
|
+
ctx.globalAlpha = intensity * 0.4;
|
|
1521
|
+
ctx.fillStyle = '#ff0055';
|
|
1522
|
+
ctx.fillText(numStr, st.w / 2 + jx - 2, st.h / 2 + jy);
|
|
1523
|
+
ctx.fillStyle = '#00ffcc';
|
|
1524
|
+
ctx.fillText(numStr, st.w / 2 + jx + 2, st.h / 2 + jy);
|
|
1525
|
+
ctx.globalAlpha = 1;
|
|
1526
|
+
ctx.globalCompositeOperation = 'source-over';
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
ctx.fillStyle = '#e2e2f0';
|
|
1530
|
+
ctx.font = "700 28px 'JetBrains Mono',monospace"; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
|
1531
|
+
ctx.fillText(numStr, st.w / 2 + jx, st.h / 2 + jy);
|
|
1532
|
+
|
|
1533
|
+
label(ctx, 'SIGNAL', st.w / 2, st.h + 12);
|
|
1534
|
+
if (st.focused) focusRing(ctx, st.w, st.h, 10);
|
|
1535
|
+
},
|
|
1536
|
+
};
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
|
|
1540
|
+
// ===========================================================
|
|
1541
|
+
// RATING (uses slider position mapped to 1--5 stars)
|
|
1542
|
+
// ===========================================================
|
|
1543
|
+
|
|
1544
|
+
/** 20. Bubble Rating -- 5 bubbles inflate based on slider position. Click pops them. */
|
|
1545
|
+
export function BubbleRating({ seed = 42 } = {}) {
|
|
1546
|
+
const rng = new Random(seed);
|
|
1547
|
+
const R5 = ['0 / 5', '1 / 5', '2 / 5', '3 / 5', '4 / 5', '5 / 5']; // const labels
|
|
1548
|
+
const POPS = 32;
|
|
1549
|
+
const pop = [];
|
|
1550
|
+
for (let i = 0; i < POPS; i++) pop[i] = { x: 0, y: 0, vx: 0, vy: 0, life: 0 };
|
|
1551
|
+
const sizes = new Float32Array(5);
|
|
1552
|
+
let lastW = 200;
|
|
1553
|
+
function spawnPops(cx) {
|
|
1554
|
+
for (let j = 0; j < 5; j++) {
|
|
1555
|
+
for (let i = 0; i < POPS; i++) {
|
|
1556
|
+
const p = pop[i];
|
|
1557
|
+
if (p.life <= 0) {
|
|
1558
|
+
const a = rng.range(0, Math.PI * 2), v = rng.range(20, 50);
|
|
1559
|
+
p.x = cx; p.y = 14; p.vx = Math.cos(a) * v; p.vy = Math.sin(a) * v; p.life = 1;
|
|
1560
|
+
break;
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
return {
|
|
1566
|
+
tick(ctx, dt, now, st) {
|
|
1567
|
+
lastW = st.w;
|
|
1568
|
+
const rating = Math.round(st.val * 5);
|
|
1569
|
+
const gap = st.w / 5;
|
|
1570
|
+
|
|
1571
|
+
for (let i = 0; i < 5; i++) {
|
|
1572
|
+
const active = i < rating;
|
|
1573
|
+
const targetSz = active ? 12 : 6;
|
|
1574
|
+
sizes[i] = lerp(sizes[i] || 6, targetSz, dt * 8);
|
|
1575
|
+
const cx = gap * i + gap / 2, cy = st.h / 2;
|
|
1576
|
+
|
|
1577
|
+
// Bubble -- const color, active alpha via globalAlpha
|
|
1578
|
+
if (active) { ctx.fillStyle = '#38bdf8'; ctx.globalAlpha = 0.3 + sizes[i] / 20; }
|
|
1579
|
+
else { ctx.fillStyle = 'rgba(255,255,255,.05)'; ctx.globalAlpha = 1; }
|
|
1580
|
+
ctx.beginPath(); ctx.arc(cx, cy, sizes[i], 0, Math.PI * 2); ctx.fill();
|
|
1581
|
+
ctx.globalAlpha = 1;
|
|
1582
|
+
ctx.strokeStyle = active ? '#38bdf8' : 'rgba(255,255,255,.08)';
|
|
1583
|
+
ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(cx, cy, sizes[i], 0, Math.PI * 2); ctx.stroke();
|
|
1584
|
+
|
|
1585
|
+
// Highlight
|
|
1586
|
+
if (active) {
|
|
1587
|
+
ctx.fillStyle = 'rgba(255,255,255,.15)';
|
|
1588
|
+
ctx.beginPath(); ctx.arc(cx - 3, cy - 3, 3, 0, Math.PI * 2); ctx.fill();
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
// Pop particles -- fixed pool, const color, alpha via globalAlpha
|
|
1593
|
+
ctx.fillStyle = '#38bdf8';
|
|
1594
|
+
for (let i = 0; i < POPS; i++) {
|
|
1595
|
+
const p = pop[i];
|
|
1596
|
+
if (p.life <= 0) continue;
|
|
1597
|
+
p.x += p.vx * dt; p.y += p.vy * dt; p.life -= 3 * dt;
|
|
1598
|
+
if (p.life <= 0) continue;
|
|
1599
|
+
ctx.globalAlpha = p.life;
|
|
1600
|
+
ctx.beginPath(); ctx.arc(p.x, p.y, 1.5, 0, Math.PI * 2); ctx.fill();
|
|
1601
|
+
}
|
|
1602
|
+
ctx.globalAlpha = 1;
|
|
1603
|
+
|
|
1604
|
+
label(ctx, R5[rating], st.w / 2, st.h + 12, rating >= 4 ? '#38bdf8' : '#9999b8');
|
|
1605
|
+
if (st.focused) focusRing(ctx, st.w, st.h, 4);
|
|
1606
|
+
},
|
|
1607
|
+
onDrag(val) {
|
|
1608
|
+
const rating = Math.round(val * 5);
|
|
1609
|
+
// Pop the newly activated bubble (position derived from st.w via lastW)
|
|
1610
|
+
const gap = lastW / 5;
|
|
1611
|
+
const cx = gap * (rating - 1) + gap / 2;
|
|
1612
|
+
spawnPops(cx);
|
|
1613
|
+
},
|
|
1614
|
+
};
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
|
|
1618
|
+
// ===========================================================
|
|
1619
|
+
// KNOBS
|
|
1620
|
+
// ===========================================================
|
|
1621
|
+
|
|
1622
|
+
/** 1. Volume Knob -- Rotary dial with tick marks and arc indicator. */
|
|
1623
|
+
export function VolumeKnob() {
|
|
1624
|
+
let displayVal = 0, grad = null;
|
|
1625
|
+
let numStr = '0', lastNum = -1;
|
|
1626
|
+
return {
|
|
1627
|
+
init(c, w, h) {
|
|
1628
|
+
// Value-arc gradient depends only on w/h (fixed per mount) -> build once.
|
|
1629
|
+
grad = c.createLinearGradient(0, h, w, 0);
|
|
1630
|
+
grad.addColorStop(0, '#6ee7b6'); grad.addColorStop(1, '#38bdf8');
|
|
1631
|
+
},
|
|
1632
|
+
tick(c,dt,now,st) {
|
|
1633
|
+
displayVal = lerp(displayVal, st.val, dt * 12);
|
|
1634
|
+
const cx=st.w/2, cy=st.h/2, R=Math.min(cx,cy)-4;
|
|
1635
|
+
const startA=-Math.PI*0.75, endA=Math.PI*0.75, range=endA-startA;
|
|
1636
|
+
|
|
1637
|
+
// Background ring
|
|
1638
|
+
c.strokeStyle='rgba(255,255,255,.06)'; c.lineWidth=4;
|
|
1639
|
+
c.beginPath(); c.arc(cx,cy,R,startA,endA); c.stroke();
|
|
1640
|
+
|
|
1641
|
+
// Value arc (gradient built once in init)
|
|
1642
|
+
const valA = startA + displayVal * range;
|
|
1643
|
+
c.strokeStyle = grad || '#38bdf8'; c.lineWidth=4;
|
|
1644
|
+
c.beginPath(); c.arc(cx,cy,R,startA,valA); c.stroke();
|
|
1645
|
+
|
|
1646
|
+
// Tick marks
|
|
1647
|
+
for(let i=0;i<=10;i++){
|
|
1648
|
+
const a=startA+i/10*range;
|
|
1649
|
+
const inner=R-6, outer=R+2;
|
|
1650
|
+
c.strokeStyle=i/10<=displayVal?'rgba(110,231,182,.5)':'rgba(255,255,255,.08)';
|
|
1651
|
+
c.lineWidth=1; c.beginPath();
|
|
1652
|
+
c.moveTo(cx+Math.cos(a)*inner,cy+Math.sin(a)*inner);
|
|
1653
|
+
c.lineTo(cx+Math.cos(a)*outer,cy+Math.sin(a)*outer);
|
|
1654
|
+
c.stroke();
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
// Needle dot
|
|
1658
|
+
c.fillStyle='#fff';
|
|
1659
|
+
c.beginPath(); c.arc(cx+Math.cos(valA)*(R-12),cy+Math.sin(valA)*(R-12),3,0,PI2); c.fill();
|
|
1660
|
+
|
|
1661
|
+
// Center
|
|
1662
|
+
c.fillStyle='rgba(255,255,255,.04)';
|
|
1663
|
+
c.beginPath(); c.arc(cx,cy,R*0.4,0,PI2); c.fill();
|
|
1664
|
+
|
|
1665
|
+
// Value text (rebuilt only when the integer changes)
|
|
1666
|
+
const v = Math.round(displayVal * 100);
|
|
1667
|
+
if (v !== lastNum) { lastNum = v; numStr = String(v); }
|
|
1668
|
+
c.fillStyle='#e2e2f0'; c.font="700 14px 'JetBrains Mono',monospace"; c.textAlign='center'; c.textBaseline='middle';
|
|
1669
|
+
c.fillText(numStr,cx,cy);
|
|
1670
|
+
lbl(c,'VOLUME',cx,st.h+10);
|
|
1671
|
+
if(st.focused)fr(c,st.w,st.h,st.h/2);
|
|
1672
|
+
},
|
|
1673
|
+
};
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
/** 2. Compass Knob -- Needle points based on slider value (0=N, 0.5=S, 1=N). */
|
|
1677
|
+
export function CompassKnob() {
|
|
1678
|
+
const dirs = ['N', 'E', 'S', 'W']; // hoisted out of tick
|
|
1679
|
+
const cols = ['#ff6b6b', '#9999b8', '#9999b8', '#9999b8']; // (were per-frame arrays)
|
|
1680
|
+
let needleA = 0;
|
|
1681
|
+
let degStr = '0\u00B0', lastDeg = -1;
|
|
1682
|
+
return {
|
|
1683
|
+
tick(c,dt,now,st) {
|
|
1684
|
+
const targetA = st.val * PI2;
|
|
1685
|
+
needleA = lerp(needleA, targetA, dt*8);
|
|
1686
|
+
const cx=st.w/2, cy=st.h/2, R=Math.min(cx,cy)-4;
|
|
1687
|
+
|
|
1688
|
+
// Ring
|
|
1689
|
+
c.strokeStyle='rgba(255,255,255,.06)'; c.lineWidth=2;
|
|
1690
|
+
c.beginPath(); c.arc(cx,cy,R,0,PI2); c.stroke();
|
|
1691
|
+
|
|
1692
|
+
// Cardinal marks
|
|
1693
|
+
for(let i=0;i<4;i++){
|
|
1694
|
+
const a=-Math.PI/2+i*Math.PI/2;
|
|
1695
|
+
c.fillStyle=cols[i]; c.font="600 9px 'JetBrains Mono',monospace"; c.textAlign='center'; c.textBaseline='middle';
|
|
1696
|
+
c.fillText(dirs[i],cx+Math.cos(a)*(R-10),cy+Math.sin(a)*(R-10));
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
// Needle
|
|
1700
|
+
c.save(); c.translate(cx,cy); c.rotate(needleA-Math.PI/2);
|
|
1701
|
+
c.fillStyle='#ff6b6b';
|
|
1702
|
+
c.beginPath(); c.moveTo(0,-R+18); c.lineTo(-4,4); c.lineTo(4,4); c.closePath(); c.fill();
|
|
1703
|
+
c.fillStyle='rgba(255,255,255,.15)';
|
|
1704
|
+
c.beginPath(); c.moveTo(0,R-18); c.lineTo(-4,-4); c.lineTo(4,-4); c.closePath(); c.fill();
|
|
1705
|
+
c.restore();
|
|
1706
|
+
|
|
1707
|
+
// Center pin
|
|
1708
|
+
c.fillStyle='#333'; c.beginPath(); c.arc(cx,cy,4,0,PI2); c.fill();
|
|
1709
|
+
c.strokeStyle='rgba(255,255,255,.1)'; c.lineWidth=1; c.beginPath(); c.arc(cx,cy,4,0,PI2); c.stroke();
|
|
1710
|
+
|
|
1711
|
+
const deg = Math.round(st.val * 360);
|
|
1712
|
+
if (deg !== lastDeg) { lastDeg = deg; degStr = deg + '\u00B0'; }
|
|
1713
|
+
lbl(c, degStr, cx, st.h + 10);
|
|
1714
|
+
if(st.focused)fr(c,st.w,st.h,st.h/2);
|
|
1715
|
+
},
|
|
1716
|
+
};
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
|
|
1720
|
+
// ===========================================================
|
|
1721
|
+
// PROGRESS
|
|
1722
|
+
// ===========================================================
|
|
1723
|
+
|
|
1724
|
+
/** 3. Ring Progress -- Circular progress with animated fill and particles at the tip. */
|
|
1725
|
+
export function RingProgress({seed=42}={}) {
|
|
1726
|
+
const rng=new Random(seed);
|
|
1727
|
+
// Fixed spark pool + a bounded set of conic gradients (one per progress
|
|
1728
|
+
// level) built in init, so the green stop still tracks the arc tip with zero
|
|
1729
|
+
// per-frame gradient construction.
|
|
1730
|
+
const SPARKS = 24;
|
|
1731
|
+
const spark = [];
|
|
1732
|
+
for (let i = 0; i < SPARKS; i++) spark[i] = { x: 0, y: 0, vx: 0, vy: 0, life: 0 };
|
|
1733
|
+
const GPHASES = 16;
|
|
1734
|
+
const grads = [];
|
|
1735
|
+
let displayVal = 0;
|
|
1736
|
+
function spawn(x, y) {
|
|
1737
|
+
for (let i = 0; i < SPARKS; i++) {
|
|
1738
|
+
const s = spark[i];
|
|
1739
|
+
if (s.life <= 0) { s.x = x; s.y = y; s.vx = rng.range(-20, 20); s.vy = rng.range(-20, 20); s.life = 1; return; }
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1742
|
+
return {
|
|
1743
|
+
init(c, w, h) {
|
|
1744
|
+
for (let p = 0; p < GPHASES; p++) {
|
|
1745
|
+
const g = c.createConicGradient(-Math.PI / 2, w / 2, h / 2);
|
|
1746
|
+
g.addColorStop(0, '#a78bfa');
|
|
1747
|
+
g.addColorStop((p / (GPHASES - 1)) * 0.95, '#6ee7b6');
|
|
1748
|
+
g.addColorStop(1, 'rgba(110,231,182,.2)');
|
|
1749
|
+
grads[p] = g;
|
|
1750
|
+
}
|
|
1751
|
+
},
|
|
1752
|
+
tick(c,dt,now,st) {
|
|
1753
|
+
displayVal=lerp(displayVal,st.val,dt*6);
|
|
1754
|
+
const cx=st.w/2,cy=st.h/2,R=Math.min(cx,cy)-6,lw=6;
|
|
1755
|
+
|
|
1756
|
+
// Background
|
|
1757
|
+
c.strokeStyle='rgba(255,255,255,.06)';c.lineWidth=lw;
|
|
1758
|
+
c.beginPath();c.arc(cx,cy,R,0,PI2);c.stroke();
|
|
1759
|
+
|
|
1760
|
+
// Fill arc (conic gradient from init; the green stop tracks the tip)
|
|
1761
|
+
const a=-Math.PI/2,ea=a+displayVal*PI2;
|
|
1762
|
+
c.strokeStyle = grads[(displayVal * (GPHASES - 1)) | 0] || '#6ee7b6';c.lineWidth=lw;
|
|
1763
|
+
c.beginPath();c.arc(cx,cy,R,a,ea);c.stroke();
|
|
1764
|
+
|
|
1765
|
+
// Tip sparks -- fixed pool, const color, alpha via globalAlpha
|
|
1766
|
+
if(displayVal>0.02){
|
|
1767
|
+
const tx=cx+Math.cos(ea)*R,ty=cy+Math.sin(ea)*R;
|
|
1768
|
+
spawn(tx, ty);
|
|
1769
|
+
}
|
|
1770
|
+
c.fillStyle = '#6ee7b6';
|
|
1771
|
+
for(let i=0;i<SPARKS;i++){
|
|
1772
|
+
const s=spark[i];
|
|
1773
|
+
if(s.life<=0)continue;
|
|
1774
|
+
s.x+=s.vx*dt;s.y+=s.vy*dt;s.life-=2*dt;
|
|
1775
|
+
if(s.life<=0)continue;
|
|
1776
|
+
c.globalAlpha=s.life;
|
|
1777
|
+
c.beginPath();c.arc(s.x,s.y,1.5,0,PI2);c.fill();
|
|
1778
|
+
}
|
|
1779
|
+
c.globalAlpha=1;
|
|
1780
|
+
|
|
1781
|
+
// Percentage
|
|
1782
|
+
c.fillStyle='#e2e2f0';c.font="700 16px 'JetBrains Mono',monospace";c.textAlign='center';c.textBaseline='middle';
|
|
1783
|
+
c.fillText(PCT[Math.round(displayVal*100)],cx,cy);
|
|
1784
|
+
if(st.focused)fr(c,st.w,st.h,st.h/2);
|
|
1785
|
+
},
|
|
1786
|
+
};
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
/** 4. Battery Gauge -- Battery icon that fills and changes color. */
|
|
1790
|
+
export function BatteryGauge() {
|
|
1791
|
+
let displayVal=0;
|
|
1792
|
+
return {
|
|
1793
|
+
tick(c,dt,now,st) {
|
|
1794
|
+
displayVal=lerp(displayVal,st.val,dt*8);
|
|
1795
|
+
const bw=st.w-16,bh=st.h-12,bx=8,by=6,r=4;
|
|
1796
|
+
|
|
1797
|
+
// Shell
|
|
1798
|
+
c.strokeStyle='rgba(255,255,255,.15)';c.lineWidth=2;
|
|
1799
|
+
rr(c,bx,by,bw,bh,r);c.stroke();
|
|
1800
|
+
// Cap
|
|
1801
|
+
c.fillStyle='rgba(255,255,255,.15)';
|
|
1802
|
+
c.fillRect(bx+bw+1,by+bh/2-4,4,8);
|
|
1803
|
+
|
|
1804
|
+
// Fill
|
|
1805
|
+
const fillW=Math.max(0,(bw-6)*displayVal);
|
|
1806
|
+
const col=displayVal<.2?'#ff6b6b':displayVal<.5?'#fbbf24':'#6ee7b6';
|
|
1807
|
+
c.fillStyle=col;
|
|
1808
|
+
rr(c,bx+3,by+3,fillW,bh-6,2);c.fill();
|
|
1809
|
+
|
|
1810
|
+
// Pulse glow when charging (high values)
|
|
1811
|
+
if(displayVal>.8){
|
|
1812
|
+
const pulse=Math.sin(now/200)*.3+.7;
|
|
1813
|
+
c.shadowBlur=8*pulse;c.shadowColor=col;
|
|
1814
|
+
rr(c,bx+3,by+3,fillW,bh-6,2);c.fill();
|
|
1815
|
+
c.shadowBlur=0;
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
// Low battery flash
|
|
1819
|
+
if(displayVal<.15&&((now/500|0)%2===0)){
|
|
1820
|
+
c.fillStyle='rgba(255,80,80,.15)';rr(c,bx,by,bw,bh,r);c.fill();
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
c.fillStyle='#e2e2f0';c.font="600 10px 'JetBrains Mono',monospace";c.textAlign='center';c.textBaseline='middle';
|
|
1824
|
+
c.fillText(PCT[Math.round(displayVal*100)],bx+bw/2,by+bh/2);
|
|
1825
|
+
if(st.focused)fr(c,st.w+4,st.h,r);
|
|
1826
|
+
},
|
|
1827
|
+
};
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
/** 5. Signal Meter -- WiFi-style signal bars. */
|
|
1831
|
+
export function SignalMeter() {
|
|
1832
|
+
let bars=[0,0,0,0,0];
|
|
1833
|
+
return {
|
|
1834
|
+
tick(c,dt,now,st) {
|
|
1835
|
+
const level=Math.ceil(st.val*5);
|
|
1836
|
+
for(let i=0;i<5;i++){
|
|
1837
|
+
const target=i<level?1:0;
|
|
1838
|
+
bars[i]=lerp(bars[i],target,dt*10);
|
|
1839
|
+
}
|
|
1840
|
+
const bw=8,gap=4,total=5*(bw+gap)-gap;
|
|
1841
|
+
const ox=(st.w-total)/2;
|
|
1842
|
+
|
|
1843
|
+
for(let i=0;i<5;i++){
|
|
1844
|
+
const maxH=8+i*6, h=maxH*bars[i]+2;
|
|
1845
|
+
const x=ox+i*(bw+gap), y=st.h-6-h;
|
|
1846
|
+
const active=bars[i]>.5;
|
|
1847
|
+
c.fillStyle=active?(st.val>.6?'#6ee7b6':st.val>.3?'#fbbf24':'#ff6b6b'):'rgba(255,255,255,.06)';
|
|
1848
|
+
rr(c,x,y,bw,h,2);c.fill();
|
|
1849
|
+
}
|
|
1850
|
+
lbl(c,level+'/5',st.w/2,st.h+10,st.val>.6?'#6ee7b6':'#9999b8');
|
|
1851
|
+
if(st.focused)fr(c,st.w,st.h,4);
|
|
1852
|
+
},
|
|
1853
|
+
};
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
|
|
1857
|
+
// ===========================================================
|
|
1858
|
+
// CONTROLS
|
|
1859
|
+
// ===========================================================
|
|
1860
|
+
|
|
1861
|
+
/** 6. Pill Tabs -- 3 segmented tabs with sliding indicator. */
|
|
1862
|
+
export function PillTabs() {
|
|
1863
|
+
let indicatorX=0, indicatorW=0, selected=0;
|
|
1864
|
+
const labels=['Alpha','Beta','Gamma'];
|
|
1865
|
+
return {
|
|
1866
|
+
onClick(x,y,st) {
|
|
1867
|
+
const idx=Math.floor(x/(st.w/3));
|
|
1868
|
+
selected=clamp(idx,0,2);
|
|
1869
|
+
},
|
|
1870
|
+
tick(c,dt,now,st) {
|
|
1871
|
+
const tw=st.w/3;
|
|
1872
|
+
const tx=selected*tw, targetW=tw;
|
|
1873
|
+
indicatorX=lerp(indicatorX,tx,dt*12);
|
|
1874
|
+
indicatorW=lerp(indicatorW,targetW,dt*12);
|
|
1875
|
+
|
|
1876
|
+
// Background
|
|
1877
|
+
c.fillStyle='rgba(255,255,255,.03)';rr(c,0,0,st.w,st.h,st.h/2);c.fill();
|
|
1878
|
+
|
|
1879
|
+
// Indicator
|
|
1880
|
+
c.fillStyle='rgba(167,139,250,.12)';c.strokeStyle='rgba(167,139,250,.25)';c.lineWidth=1;
|
|
1881
|
+
rr(c,indicatorX+2,2,indicatorW-4,st.h-4,st.h/2-2);c.fill();c.stroke();
|
|
1882
|
+
|
|
1883
|
+
// Labels
|
|
1884
|
+
c.font="600 11px 'Space Grotesk',sans-serif";c.textAlign='center';c.textBaseline='middle';
|
|
1885
|
+
for(let i=0;i<3;i++){
|
|
1886
|
+
c.fillStyle=i===selected?'#c4b5fd':'#9999b8';
|
|
1887
|
+
c.fillText(labels[i],tw*i+tw/2,st.h/2);
|
|
1888
|
+
}
|
|
1889
|
+
if(st.focused)fr(c,st.w,st.h,st.h/2);
|
|
1890
|
+
},
|
|
1891
|
+
};
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
/** 7. Stepper -- +/- buttons with spring counter. */
|
|
1895
|
+
export function Stepper() {
|
|
1896
|
+
let count=0,displayCount=0,flashDir=0,flashTimer=0;
|
|
1897
|
+
return {
|
|
1898
|
+
onClick(x,y,st) {
|
|
1899
|
+
if(x<st.w*0.33){count=Math.max(0,count-1);flashDir=-1;}
|
|
1900
|
+
else if(x>st.w*0.66){count++;flashDir=1;}
|
|
1901
|
+
flashTimer=0.3;
|
|
1902
|
+
},
|
|
1903
|
+
tick(c,dt,now,st) {
|
|
1904
|
+
displayCount=lerp(displayCount,count,dt*10);
|
|
1905
|
+
flashTimer=Math.max(0,flashTimer-dt);
|
|
1906
|
+
|
|
1907
|
+
// Background
|
|
1908
|
+
c.fillStyle='rgba(255,255,255,.04)';rr(c,0,0,st.w,st.h,10);c.fill();
|
|
1909
|
+
|
|
1910
|
+
// Minus zone
|
|
1911
|
+
const third=st.w/3;
|
|
1912
|
+
c.fillStyle=flashDir===-1&&flashTimer>0?'rgba(255,100,100,.15)':'rgba(255,255,255,.03)';
|
|
1913
|
+
rr(c,2,2,third-4,st.h-4,8);c.fill();
|
|
1914
|
+
c.fillStyle='#ff6b6b';c.font="700 18px 'JetBrains Mono',monospace";c.textAlign='center';c.textBaseline='middle';
|
|
1915
|
+
c.fillText('\u2212',third/2,st.h/2);
|
|
1916
|
+
|
|
1917
|
+
// Plus zone
|
|
1918
|
+
c.fillStyle=flashDir===1&&flashTimer>0?'rgba(110,231,182,.15)':'rgba(255,255,255,.03)';
|
|
1919
|
+
rr(c,third*2+2,2,third-4,st.h-4,8);c.fill();
|
|
1920
|
+
c.fillStyle='#6ee7b6';c.fillText('+',third*2+third/2,st.h/2);
|
|
1921
|
+
|
|
1922
|
+
// Counter
|
|
1923
|
+
c.fillStyle='#e2e2f0';c.font="700 20px 'JetBrains Mono',monospace";
|
|
1924
|
+
c.fillText(Math.round(displayCount),st.w/2,st.h/2);
|
|
1925
|
+
|
|
1926
|
+
if(st.focused)fr(c,st.w,st.h,10);
|
|
1927
|
+
},
|
|
1928
|
+
};
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
/** 8. Radio Orbit -- 4 options arranged in a circle. Slider picks one. */
|
|
1932
|
+
export function RadioOrbit() {
|
|
1933
|
+
let selectedGlow=new Float32Array(4);
|
|
1934
|
+
const names=['A','B','C','D'],colors=['#ff6b6b','#fbbf24','#6ee7b6','#38bdf8'];
|
|
1935
|
+
const OPTS=['OPTION A','OPTION B','OPTION C','OPTION D']; // const (was 'OPTION '+name concat)
|
|
1936
|
+
return {
|
|
1937
|
+
tick(c,dt,now,st) {
|
|
1938
|
+
const sel=Math.round(st.val*3);
|
|
1939
|
+
const cx=st.w/2,cy=st.h/2,R=Math.min(cx,cy)-12;
|
|
1940
|
+
|
|
1941
|
+
// Center
|
|
1942
|
+
c.fillStyle='rgba(255,255,255,.03)';c.beginPath();c.arc(cx,cy,8,0,PI2);c.fill();
|
|
1943
|
+
|
|
1944
|
+
for(let i=0;i<4;i++){
|
|
1945
|
+
const a=-Math.PI/2+i*Math.PI/2;
|
|
1946
|
+
const ox=cx+Math.cos(a)*R, oy=cy+Math.sin(a)*R;
|
|
1947
|
+
const active=i===sel;
|
|
1948
|
+
selectedGlow[i]=lerp(selectedGlow[i],active?1:0,dt*10);
|
|
1949
|
+
|
|
1950
|
+
// Orbit line -- const white, alpha via globalAlpha
|
|
1951
|
+
c.strokeStyle='#ffffff';c.globalAlpha=.03+selectedGlow[i]*.05;c.lineWidth=1;
|
|
1952
|
+
c.beginPath();c.moveTo(cx,cy);c.lineTo(ox,oy);c.stroke();c.globalAlpha=1;
|
|
1953
|
+
|
|
1954
|
+
// Node
|
|
1955
|
+
const sz=6+selectedGlow[i]*4;
|
|
1956
|
+
c.fillStyle=active?colors[i]:'rgba(255,255,255,.06)';
|
|
1957
|
+
c.beginPath();c.arc(ox,oy,sz,0,PI2);c.fill();
|
|
1958
|
+
if(active){c.strokeStyle=colors[i];c.lineWidth=1;c.beginPath();c.arc(ox,oy,sz+3,0,PI2);c.stroke()}
|
|
1959
|
+
|
|
1960
|
+
c.fillStyle=active?'#fff':'#9999b8';c.font="600 9px 'JetBrains Mono',monospace";c.textAlign='center';c.textBaseline='middle';
|
|
1961
|
+
c.fillText(names[i],ox,oy);
|
|
1962
|
+
}
|
|
1963
|
+
lbl(c,OPTS[sel],cx,st.h+10,colors[sel]);
|
|
1964
|
+
if(st.focused)fr(c,st.w,st.h,4);
|
|
1965
|
+
},
|
|
1966
|
+
};
|
|
1967
|
+
}
|
|
1968
|
+
|
|
1969
|
+
|
|
1970
|
+
// ===========================================================
|
|
1971
|
+
// INDICATORS
|
|
1972
|
+
// ===========================================================
|
|
1973
|
+
|
|
1974
|
+
/** 9. Password Strength -- Segmented bar with color progression and label. */
|
|
1975
|
+
export function PasswordStrength() {
|
|
1976
|
+
let segs=[0,0,0,0];
|
|
1977
|
+
const labels=['WEAK','FAIR','GOOD','STRONG'],colors=['#ff6b6b','#fbbf24','#38bdf8','#6ee7b6'];
|
|
1978
|
+
return {
|
|
1979
|
+
tick(c,dt,now,st) {
|
|
1980
|
+
const level=Math.ceil(st.val*4);
|
|
1981
|
+
for(let i=0;i<4;i++) segs[i]=lerp(segs[i],i<level?1:0,dt*10);
|
|
1982
|
+
|
|
1983
|
+
const segW=(st.w-12)/4,segH=8;
|
|
1984
|
+
for(let i=0;i<4;i++){
|
|
1985
|
+
const x=2+i*(segW+2);
|
|
1986
|
+
c.fillStyle=segs[i]>.5?colors[Math.min(level-1,3)]:'rgba(255,255,255,.06)';
|
|
1987
|
+
const w=segW*segs[i];
|
|
1988
|
+
rr(c,x,st.h/2-segH/2,Math.max(2,w),segH,3);c.fill();
|
|
1989
|
+
|
|
1990
|
+
// Empty track
|
|
1991
|
+
c.strokeStyle='rgba(255,255,255,.04)';c.lineWidth=1;
|
|
1992
|
+
rr(c,x,st.h/2-segH/2,segW,segH,3);c.stroke();
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1995
|
+
const idx=clamp(level-1,0,3);
|
|
1996
|
+
lbl(c,level>0?labels[idx]:'NONE',st.w/2,st.h/2+16,level>0?colors[idx]:'#666');
|
|
1997
|
+
if(st.focused)fr(c,st.w,st.h,4);
|
|
1998
|
+
},
|
|
1999
|
+
};
|
|
2000
|
+
}
|
|
2001
|
+
|
|
2002
|
+
/** 10. Water Level -- Animated wave surface inside a container. */
|
|
2003
|
+
export function WaterLevel() {
|
|
2004
|
+
let displayVal=0, wavePhase=0;
|
|
2005
|
+
return {
|
|
2006
|
+
tick(c,dt,now,st) {
|
|
2007
|
+
displayVal=lerp(displayVal,st.val,dt*4);
|
|
2008
|
+
wavePhase+=dt*3;
|
|
2009
|
+
const bx=4,by=4,bw=st.w-8,bh=st.h-8;
|
|
2010
|
+
|
|
2011
|
+
// Container
|
|
2012
|
+
c.strokeStyle='rgba(255,255,255,.1)';c.lineWidth=1.5;rr(c,bx,by,bw,bh,6);c.stroke();
|
|
2013
|
+
|
|
2014
|
+
// Water
|
|
2015
|
+
const waterY=by+bh-(bh*displayVal);
|
|
2016
|
+
c.save();c.beginPath();rr(c,bx+1,by+1,bw-2,bh-2,5);c.clip();
|
|
2017
|
+
|
|
2018
|
+
// Wave surface
|
|
2019
|
+
c.fillStyle=displayVal>.7?'rgba(56,189,248,.3)':displayVal>.3?'rgba(110,231,182,.25)':'rgba(251,191,36,.2)';
|
|
2020
|
+
c.beginPath();c.moveTo(bx,by+bh);
|
|
2021
|
+
for(let x=0;x<=bw;x++){
|
|
2022
|
+
const wave=Math.sin(wavePhase+x*0.08)*3+Math.sin(wavePhase*1.5+x*0.12)*2;
|
|
2023
|
+
c.lineTo(bx+x,waterY+wave);
|
|
2024
|
+
}
|
|
2025
|
+
c.lineTo(bx+bw,by+bh);c.closePath();c.fill();
|
|
2026
|
+
|
|
2027
|
+
// Deeper water
|
|
2028
|
+
c.fillStyle=displayVal>.7?'rgba(56,189,248,.15)':displayVal>.3?'rgba(110,231,182,.1)':'rgba(251,191,36,.08)';
|
|
2029
|
+
c.fillRect(bx,waterY+5,bw,bh);
|
|
2030
|
+
|
|
2031
|
+
c.restore();
|
|
2032
|
+
|
|
2033
|
+
c.fillStyle='#e2e2f0';c.font="700 12px 'JetBrains Mono',monospace";c.textAlign='center';c.textBaseline='middle';
|
|
2034
|
+
c.fillText(PCT[Math.round(displayVal*100)],st.w/2,st.h/2);
|
|
2035
|
+
if(st.focused)fr(c,st.w,st.h,6);
|
|
2036
|
+
},
|
|
2037
|
+
};
|
|
2038
|
+
}
|
|
2039
|
+
|
|
2040
|
+
/** 11. Heat Map -- 5×3 grid of cells that heat up based on slider. */
|
|
2041
|
+
export function HeatMap({seed=42}={}) {
|
|
2042
|
+
const rng=new Random(seed);
|
|
2043
|
+
const N=15,thresholds=new Float32Array(N);
|
|
2044
|
+
let vals=new Float32Array(N);
|
|
2045
|
+
// Heat-color LUT (rgb quantized by heat): a const lookup + globalAlpha
|
|
2046
|
+
// instead of a per-cell rgb() template every frame.
|
|
2047
|
+
const HEATC=[];
|
|
2048
|
+
for(let i=0;i<=32;i++)HEATC[i]='rgb('+Math.round(100+i/32*155)+','+Math.round(60+i/32*20)+','+Math.round(60-i/32*40)+')';
|
|
2049
|
+
return {
|
|
2050
|
+
init(){for(let i=0;i<N;i++)thresholds[i]=rng.range(0,1)},
|
|
2051
|
+
tick(c,dt,now,st) {
|
|
2052
|
+
const cols=5,rows=3,gap=3;
|
|
2053
|
+
const cw=(st.w-gap*(cols-1))/cols, ch=(st.h-gap*(rows-1))/rows;
|
|
2054
|
+
|
|
2055
|
+
for(let i=0;i<N;i++){
|
|
2056
|
+
const active=st.val>=thresholds[i];
|
|
2057
|
+
vals[i]=lerp(vals[i],active?1:0,dt*6);
|
|
2058
|
+
const col=i%cols,row=(i/cols)|0;
|
|
2059
|
+
const x=col*(cw+gap),y=row*(ch+gap);
|
|
2060
|
+
const heat=vals[i];
|
|
2061
|
+
if(heat>.1){c.fillStyle=HEATC[(heat*32)|0]||HEATC[32];c.globalAlpha=.15+heat*.4;}
|
|
2062
|
+
else{c.fillStyle='rgba(255,255,255,.04)';c.globalAlpha=1;}
|
|
2063
|
+
rr(c,x,y,cw,ch,3);c.fill();
|
|
2064
|
+
c.globalAlpha=1;
|
|
2065
|
+
}
|
|
2066
|
+
lbl(c,PCT[Math.round(st.val*100)],st.w/2,st.h+10);
|
|
2067
|
+
if(st.focused)fr(c,st.w,st.h,3);
|
|
2068
|
+
},
|
|
2069
|
+
};
|
|
2070
|
+
}
|
|
2071
|
+
|
|
2072
|
+
|
|
2073
|
+
// ===========================================================
|
|
2074
|
+
// MOOD
|
|
2075
|
+
// ===========================================================
|
|
2076
|
+
|
|
2077
|
+
/** 12. Day/Night Toggle -- Sun/moon transition with star particles. */
|
|
2078
|
+
export function DayNightToggle({seed=42}={}) {
|
|
2079
|
+
const rng=new Random(seed);
|
|
2080
|
+
// Sky-color LUT (day->night rgb quantized by t): a const lookup, not an
|
|
2081
|
+
// rgb() template per frame. Stars are a fixed pool filled in init.
|
|
2082
|
+
const SKY=[];
|
|
2083
|
+
for(let i=0;i<=32;i++){const u=i/32;SKY[i]='rgb('+Math.round(lerp(135,10,u))+','+Math.round(lerp(206,10,u))+','+Math.round(lerp(250,30,u))+')';}
|
|
2084
|
+
const STARS=30;
|
|
2085
|
+
const star=[];
|
|
2086
|
+
let t=0;
|
|
2087
|
+
return {
|
|
2088
|
+
init(ctx,w,h){for(let i=0;i<STARS;i++)star[i]={x:rng.range(4,w-4),y:rng.range(4,h-4),twinkle:rng.range(0,PI2)}},
|
|
2089
|
+
tick(c,dt,now,st) {
|
|
2090
|
+
t=lerp(t,st.toggled?1:0,dt*6);
|
|
2091
|
+
|
|
2092
|
+
// Sky background (LUT)
|
|
2093
|
+
c.fillStyle=SKY[(t*32)|0]||SKY[32];rr(c,0,0,st.w,st.h,st.h/2);c.fill();
|
|
2094
|
+
|
|
2095
|
+
// Stars (night only) -- const white, per-star alpha via globalAlpha
|
|
2096
|
+
if(t>.3){
|
|
2097
|
+
c.fillStyle='#ffffff';
|
|
2098
|
+
for(let i=0;i<STARS;i++){
|
|
2099
|
+
const s=star[i];
|
|
2100
|
+
const tw=Math.sin(now/400+s.twinkle)*.5+.5;
|
|
2101
|
+
c.globalAlpha=(t-.3)/.7*tw*.6;
|
|
2102
|
+
c.fillRect(s.x,s.y,1,1);
|
|
2103
|
+
}
|
|
2104
|
+
c.globalAlpha=1;
|
|
2105
|
+
}
|
|
2106
|
+
|
|
2107
|
+
// Sun/Moon
|
|
2108
|
+
const orbX=lerp(18,st.w-18,t);
|
|
2109
|
+
if(t<.5){
|
|
2110
|
+
// Sun
|
|
2111
|
+
c.fillStyle='#fbbf24';c.beginPath();c.arc(orbX,st.h/2,10,0,PI2);c.fill();
|
|
2112
|
+
// Rays -- const color, alpha via globalAlpha
|
|
2113
|
+
c.strokeStyle='#fbbf24';c.globalAlpha=.3*(1-t*2);c.lineWidth=1;
|
|
2114
|
+
for(let i=0;i<8;i++){const a=now/800+i*Math.PI/4;c.beginPath();c.moveTo(orbX+Math.cos(a)*12,st.h/2+Math.sin(a)*12);c.lineTo(orbX+Math.cos(a)*16,st.h/2+Math.sin(a)*16);c.stroke()}
|
|
2115
|
+
c.globalAlpha=1;
|
|
2116
|
+
} else {
|
|
2117
|
+
// Moon
|
|
2118
|
+
c.fillStyle='#d4d4e8';c.beginPath();c.arc(orbX,st.h/2,10,0,PI2);c.fill();
|
|
2119
|
+
// Crater shadows -- const black, alpha via globalAlpha
|
|
2120
|
+
c.fillStyle='#000000';c.globalAlpha=(t-.5)*2*.15;
|
|
2121
|
+
c.beginPath();c.arc(orbX-3,st.h/2-2,3,0,PI2);c.fill();
|
|
2122
|
+
c.beginPath();c.arc(orbX+4,st.h/2+3,2,0,PI2);c.fill();
|
|
2123
|
+
c.globalAlpha=1;
|
|
2124
|
+
}
|
|
2125
|
+
|
|
2126
|
+
lbl(c,st.toggled?'NIGHT':'DAY',st.w/2,st.h+14,st.toggled?'#9999b8':'#fbbf24');
|
|
2127
|
+
if(st.focused)fr(c,st.w,st.h,st.h/2);
|
|
2128
|
+
},
|
|
2129
|
+
};
|
|
2130
|
+
}
|
|
2131
|
+
|
|
2132
|
+
/** 13. Reaction Picker -- 5 emoji-style circles that inflate on hover region. */
|
|
2133
|
+
export function ReactionPicker() {
|
|
2134
|
+
let sizes=new Float32Array(5), selected=-1;
|
|
2135
|
+
const emojis=['\u{1F610}','\u{1F642}','\u{1F60A}','\u{1F604}','\u{1F929}'],colors=['#9999b8','#fbbf24','#fb923c','#f472b6','#ff6b6b'];
|
|
2136
|
+
const colors30=colors.map((col)=>col+'30'); // active fill (was `${color}30`)
|
|
2137
|
+
const REACT_LABELS=['MEH','OK','NICE','GREAT','LOVE']; // was a per-frame array literal
|
|
2138
|
+
const FONTS=[]; for(let i=0;i<=48;i++)FONTS[i]=i+'px sans-serif'; // emoji font by rounded size
|
|
2139
|
+
return {
|
|
2140
|
+
tick(c,dt,now,st,ptr) {
|
|
2141
|
+
const gap=st.w/5;
|
|
2142
|
+
const hoverIdx=st.hover?clamp(Math.floor(ptr.x/gap),0,4):-1;
|
|
2143
|
+
|
|
2144
|
+
for(let i=0;i<5;i++){
|
|
2145
|
+
const active=i===hoverIdx;
|
|
2146
|
+
sizes[i]=lerp(sizes[i],active?16:10,dt*12);
|
|
2147
|
+
const cx=gap*i+gap/2,cy=st.h/2;
|
|
2148
|
+
|
|
2149
|
+
// Circle
|
|
2150
|
+
c.fillStyle=active?colors30[i]:'rgba(255,255,255,.04)';
|
|
2151
|
+
c.beginPath();c.arc(cx,cy-sizes[i]+10,sizes[i],0,PI2);c.fill();
|
|
2152
|
+
|
|
2153
|
+
// Emoji face (simplified) -- font from a const-string LUT
|
|
2154
|
+
c.font=FONTS[Math.round(sizes[i]*1.2)]||FONTS[48];c.textAlign='center';c.textBaseline='middle';
|
|
2155
|
+
c.fillText(emojis[i],cx,cy-sizes[i]+10);
|
|
2156
|
+
}
|
|
2157
|
+
|
|
2158
|
+
// Selection line
|
|
2159
|
+
if(hoverIdx>=0){
|
|
2160
|
+
const sx=gap*hoverIdx+gap/2;
|
|
2161
|
+
c.fillStyle=colors[hoverIdx];
|
|
2162
|
+
c.fillRect(sx-10,st.h-4,20,2);
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2165
|
+
lbl(c,hoverIdx>=0?REACT_LABELS[hoverIdx]:'REACT',st.w/2,st.h+10,hoverIdx>=0?colors[hoverIdx]:'#9999b8');
|
|
2166
|
+
if(st.focused)fr(c,st.w,st.h,4);
|
|
2167
|
+
},
|
|
2168
|
+
};
|
|
2169
|
+
}
|
|
2170
|
+
|
|
2171
|
+
/** 14. Notification Bell -- Bell icon with bounce and count badge. */
|
|
2172
|
+
export function NotificationBell() {
|
|
2173
|
+
let count=0, bellAngle=0, bellVel=0, badgeScale=0;
|
|
2174
|
+
let badgeStr='0'; // rebuilt only on click (count change), not per frame
|
|
2175
|
+
return {
|
|
2176
|
+
onClick() {
|
|
2177
|
+
count++;bellVel=6;badgeScale=1.5;badgeStr=count>99?'99+':String(count);
|
|
2178
|
+
},
|
|
2179
|
+
tick(c,dt,now,st) {
|
|
2180
|
+
bellVel+=(0-bellAngle)*20*dt;bellVel*=0.9;bellAngle+=bellVel*dt;
|
|
2181
|
+
badgeScale=lerp(badgeScale,1,dt*8);
|
|
2182
|
+
const cx=st.w/2,cy=st.h/2;
|
|
2183
|
+
|
|
2184
|
+
// Bell body
|
|
2185
|
+
c.save();c.translate(cx,cy-4);c.rotate(bellAngle*0.3);
|
|
2186
|
+
c.fillStyle='#fbbf24';
|
|
2187
|
+
c.beginPath();c.moveTo(-10,0);c.quadraticCurveTo(-12,-14,0,-18);c.quadraticCurveTo(12,-14,10,0);c.lineTo(-10,0);c.fill();
|
|
2188
|
+
// Clapper
|
|
2189
|
+
c.fillStyle='#fbbf24';c.beginPath();c.arc(0,3,3,0,PI2);c.fill();
|
|
2190
|
+
c.restore();
|
|
2191
|
+
|
|
2192
|
+
// Badge
|
|
2193
|
+
if(count>0){
|
|
2194
|
+
const bx=cx+10,by=cy-16;
|
|
2195
|
+
c.save();c.translate(bx,by);c.scale(badgeScale,badgeScale);
|
|
2196
|
+
c.fillStyle='#ff6b6b';c.beginPath();c.arc(0,0,8,0,PI2);c.fill();
|
|
2197
|
+
c.fillStyle='#fff';c.font="700 8px 'JetBrains Mono',monospace";c.textAlign='center';c.textBaseline='middle';
|
|
2198
|
+
c.fillText(badgeStr,0,0);
|
|
2199
|
+
c.restore();
|
|
2200
|
+
}
|
|
2201
|
+
|
|
2202
|
+
lbl(c,'NOTIFY',cx,st.h+10);
|
|
2203
|
+
if(st.focused)fr(c,st.w,st.h,4);
|
|
2204
|
+
},
|
|
2205
|
+
};
|
|
2206
|
+
}
|
|
2207
|
+
|
|
2208
|
+
|
|
2209
|
+
// ===========================================================
|
|
2210
|
+
// FEEDBACK
|
|
2211
|
+
// ===========================================================
|
|
2212
|
+
|
|
2213
|
+
/** 15. Typewriter Field -- Characters appear one by one with cursor blink. */
|
|
2214
|
+
export function TypewriterField() {
|
|
2215
|
+
const text='Hello World';
|
|
2216
|
+
let charIdx=0, timer=0, cursorBlink=0, typing=false;
|
|
2217
|
+
let display='', dispW=0, lastIdx=-1; // substring rebuilt only when a char lands
|
|
2218
|
+
return {
|
|
2219
|
+
onToggle(checked){typing=checked;if(checked){charIdx=0;timer=0}},
|
|
2220
|
+
tick(c,dt,now,st) {
|
|
2221
|
+
cursorBlink=(cursorBlink+dt*3)%2;
|
|
2222
|
+
if(typing&&charIdx<text.length){timer+=dt;if(timer>.08){timer=0;charIdx++}}
|
|
2223
|
+
|
|
2224
|
+
c.fillStyle='rgba(255,255,255,.04)';rr(c,0,0,st.w,st.h,6);c.fill();
|
|
2225
|
+
c.strokeStyle='rgba(255,255,255,.06)';c.lineWidth=1;rr(c,0,0,st.w,st.h,6);c.stroke();
|
|
2226
|
+
|
|
2227
|
+
c.fillStyle='#6ee7b6';c.font="500 13px 'JetBrains Mono',monospace";c.textAlign='left';c.textBaseline='middle';
|
|
2228
|
+
// Rebuild the visible substring + its width only when a char is added.
|
|
2229
|
+
if(charIdx!==lastIdx){lastIdx=charIdx;display=text.substring(0,charIdx);dispW=c.measureText(display).width;}
|
|
2230
|
+
c.fillText(display,8,st.h/2);
|
|
2231
|
+
|
|
2232
|
+
// Cursor
|
|
2233
|
+
if(cursorBlink<1){
|
|
2234
|
+
c.fillStyle='#6ee7b6';c.fillRect(9+dispW,st.h/2-8,1.5,16);
|
|
2235
|
+
}
|
|
2236
|
+
|
|
2237
|
+
lbl(c,typing?'TYPING...':'TOGGLE TO TYPE',st.w/2,st.h+10,typing?'#6ee7b6':'#8888aa');
|
|
2238
|
+
if(st.focused)fr(c,st.w,st.h,6);
|
|
2239
|
+
},
|
|
2240
|
+
};
|
|
2241
|
+
}
|
|
2242
|
+
|
|
2243
|
+
/** 16. Sound Wave Button -- Oscillating waveform on press, static on idle. */
|
|
2244
|
+
export function SoundWaveBtn() {
|
|
2245
|
+
let intensity=0, phase=0;
|
|
2246
|
+
return {
|
|
2247
|
+
onClick(){intensity=1},
|
|
2248
|
+
tick(c,dt,now,st) {
|
|
2249
|
+
intensity=lerp(intensity,0,dt*2);
|
|
2250
|
+
phase+=dt*12;
|
|
2251
|
+
|
|
2252
|
+
c.fillStyle='rgba(255,255,255,.04)';rr(c,0,0,st.w,st.h,10);c.fill();
|
|
2253
|
+
|
|
2254
|
+
// Waveform
|
|
2255
|
+
const cy=st.h/2, amp=10*intensity;
|
|
2256
|
+
c.strokeStyle=intensity>.1?'#f472b6':'rgba(255,255,255,.08)';c.lineWidth=2;
|
|
2257
|
+
c.beginPath();
|
|
2258
|
+
for(let x=0;x<=st.w;x++){
|
|
2259
|
+
const freq=x*0.1;
|
|
2260
|
+
const y=cy+Math.sin(phase+freq)*amp*Math.sin(x/st.w*Math.PI);
|
|
2261
|
+
x===0?c.moveTo(x,y):c.lineTo(x,y);
|
|
2262
|
+
}
|
|
2263
|
+
c.stroke();
|
|
2264
|
+
|
|
2265
|
+
// Flatline when idle
|
|
2266
|
+
if(intensity<.05){
|
|
2267
|
+
c.strokeStyle='rgba(255,255,255,.06)';c.lineWidth=1;
|
|
2268
|
+
c.beginPath();c.moveTo(10,cy);c.lineTo(st.w-10,cy);c.stroke();
|
|
2269
|
+
}
|
|
2270
|
+
|
|
2271
|
+
c.fillStyle=intensity>.1?'#f472b6':'#9999b8';c.font="600 11px 'Space Grotesk',sans-serif";c.textAlign='center';c.textBaseline='middle';
|
|
2272
|
+
c.fillText(intensity>.1?'\u25CF REC':'RECORD',st.w/2,st.h/2);
|
|
2273
|
+
if(st.focused)fr(c,st.w,st.h,10);
|
|
2274
|
+
},
|
|
2275
|
+
};
|
|
2276
|
+
}
|
|
2277
|
+
|
|
2278
|
+
/** 17. Upload Progress -- File icon fills from bottom as slider increases. */
|
|
2279
|
+
export function UploadProgress() {
|
|
2280
|
+
let displayVal=0;
|
|
2281
|
+
return {
|
|
2282
|
+
tick(c,dt,now,st) {
|
|
2283
|
+
displayVal=lerp(displayVal,st.val,dt*6);
|
|
2284
|
+
const cx=st.w/2,fw=30,fh=38,fx=cx-fw/2,fy=(st.h-fh)/2;
|
|
2285
|
+
|
|
2286
|
+
// File icon shape
|
|
2287
|
+
const fold=8;
|
|
2288
|
+
c.strokeStyle='rgba(255,255,255,.15)';c.lineWidth=1.5;
|
|
2289
|
+
c.beginPath();c.moveTo(fx,fy);c.lineTo(fx+fw-fold,fy);c.lineTo(fx+fw,fy+fold);c.lineTo(fx+fw,fy+fh);c.lineTo(fx,fy+fh);c.closePath();c.stroke();
|
|
2290
|
+
// Fold line
|
|
2291
|
+
c.beginPath();c.moveTo(fx+fw-fold,fy);c.lineTo(fx+fw-fold,fy+fold);c.lineTo(fx+fw,fy+fold);c.stroke();
|
|
2292
|
+
|
|
2293
|
+
// Fill
|
|
2294
|
+
const fillH=fh*displayVal;
|
|
2295
|
+
const col=displayVal>=1?'#6ee7b6':'#38bdf8';
|
|
2296
|
+
c.fillStyle=displayVal>=1?'#6ee7b630':'#38bdf830'; // was `${col}30`
|
|
2297
|
+
c.save();c.beginPath();c.rect(fx+1,fy+fh-fillH,fw-1,fillH);c.clip();
|
|
2298
|
+
c.beginPath();c.moveTo(fx,fy);c.lineTo(fx+fw-fold,fy);c.lineTo(fx+fw,fy+fold);c.lineTo(fx+fw,fy+fh);c.lineTo(fx,fy+fh);c.closePath();c.fill();
|
|
2299
|
+
c.restore();
|
|
2300
|
+
|
|
2301
|
+
// Arrow or checkmark
|
|
2302
|
+
c.strokeStyle=col;c.lineWidth=2;c.lineCap='round';
|
|
2303
|
+
if(displayVal>=.99){
|
|
2304
|
+
c.beginPath();c.moveTo(cx-5,fy+fh/2);c.lineTo(cx-1,fy+fh/2+5);c.lineTo(cx+6,fy+fh/2-4);c.stroke();
|
|
2305
|
+
} else {
|
|
2306
|
+
c.beginPath();c.moveTo(cx,fy+fh/2+5);c.lineTo(cx,fy+fh/2-5);c.stroke();
|
|
2307
|
+
c.beginPath();c.moveTo(cx-4,fy+fh/2-1);c.lineTo(cx,fy+fh/2-5);c.lineTo(cx+4,fy+fh/2-1);c.stroke();
|
|
2308
|
+
}
|
|
2309
|
+
c.lineCap='butt';
|
|
2310
|
+
|
|
2311
|
+
lbl(c,displayVal>=.99?'DONE':PCT[Math.round(displayVal*100)],cx,st.h+10,displayVal>=.99?'#6ee7b6':'#9999b8');
|
|
2312
|
+
if(st.focused)fr(c,st.w,st.h,4);
|
|
2313
|
+
},
|
|
2314
|
+
};
|
|
2315
|
+
}
|
|
2316
|
+
|
|
2317
|
+
|
|
2318
|
+
// ===========================================================
|
|
2319
|
+
// FUN
|
|
2320
|
+
// ===========================================================
|
|
2321
|
+
|
|
2322
|
+
/** 18. Scratch Reveal -- Drag to erase a mask and reveal the prize beneath. */
|
|
2323
|
+
export function ScratchReveal({seed=42}={}) {
|
|
2324
|
+
const rng=new Random(seed);
|
|
2325
|
+
// Fixed scratch pool (holes persist). Once all SCR slots are used the scratch
|
|
2326
|
+
// stops adding holes -- existing marks are never overwritten, so earlier
|
|
2327
|
+
// scratches never "heal". Spawn position derives from st.w via lastW.
|
|
2328
|
+
const SCR=64;
|
|
2329
|
+
const scr=[];
|
|
2330
|
+
for(let i=0;i<SCR;i++)scr[i]={x:0,y:0,r:0,live:false};
|
|
2331
|
+
let revealed=0, lastW=200;
|
|
2332
|
+
function spawn(val){
|
|
2333
|
+
for(let i=0;i<SCR;i++){
|
|
2334
|
+
const s=scr[i];
|
|
2335
|
+
if(!s.live){ s.x=val*lastW; s.y=rng.range(2,26); s.r=rng.range(6,14); s.live=true; return; }
|
|
2336
|
+
}
|
|
2337
|
+
}
|
|
2338
|
+
return {
|
|
2339
|
+
onDrag(val,vx) {
|
|
2340
|
+
if(Math.abs(vx)>0.5){
|
|
2341
|
+
for(let i=0;i<3;i++)spawn(val);
|
|
2342
|
+
revealed=Math.min(1,revealed+0.015);
|
|
2343
|
+
}
|
|
2344
|
+
},
|
|
2345
|
+
tick(c,dt,now,st) {
|
|
2346
|
+
lastW=st.w;
|
|
2347
|
+
// Prize background
|
|
2348
|
+
c.fillStyle='rgba(110,231,182,.08)';rr(c,0,0,st.w,st.h,8);c.fill();
|
|
2349
|
+
|
|
2350
|
+
// Prize text -- const color, alpha via globalAlpha
|
|
2351
|
+
c.fillStyle='#6ee7b6';c.globalAlpha=.1+revealed*.6;c.font="700 14px 'JetBrains Mono',monospace";c.textAlign='center';c.textBaseline='middle';
|
|
2352
|
+
c.fillText(revealed>.6?'\u{1F389} WINNER!':'? ? ?',st.w/2,st.h/2);
|
|
2353
|
+
c.globalAlpha=1;
|
|
2354
|
+
|
|
2355
|
+
// Scratch mask (gets holes)
|
|
2356
|
+
if(revealed<.95){
|
|
2357
|
+
c.fillStyle='rgba(40,40,60,.85)';rr(c,0,0,st.w,st.h,8);c.fill();
|
|
2358
|
+
|
|
2359
|
+
// Cut holes -- fixed ring, indexed loop
|
|
2360
|
+
c.globalCompositeOperation='destination-out';
|
|
2361
|
+
for(let i=0;i<SCR;i++){
|
|
2362
|
+
const s=scr[i];
|
|
2363
|
+
if(!s.live)continue;
|
|
2364
|
+
c.beginPath();c.arc(s.x,s.y,s.r,0,PI2);c.fill();
|
|
2365
|
+
}
|
|
2366
|
+
c.globalCompositeOperation='source-over';
|
|
2367
|
+
|
|
2368
|
+
// Shimmer line
|
|
2369
|
+
c.fillStyle='rgba(255,255,255,.04)';
|
|
2370
|
+
const shimX=(now/20)%st.w;
|
|
2371
|
+
c.fillRect(shimX-2,0,4,st.h);
|
|
2372
|
+
}
|
|
2373
|
+
|
|
2374
|
+
lbl(c,revealed>.6?'REVEALED!':'DRAG TO SCRATCH',st.w/2,st.h+10,revealed>.6?'#6ee7b6':'#9999b8');
|
|
2375
|
+
if(st.focused)fr(c,st.w,st.h,8);
|
|
2376
|
+
},
|
|
2377
|
+
};
|
|
2378
|
+
}
|
|
2379
|
+
|
|
2380
|
+
/** 19. Timer Countdown -- Circular countdown timer. Toggle starts/stops. */
|
|
2381
|
+
export function TimerCountdown() {
|
|
2382
|
+
let timeLeft=10, running=false, flashAlpha=0;
|
|
2383
|
+
let timeStr='10.0s', lastTenths=-1; // rebuilt at ~10 Hz, not per frame
|
|
2384
|
+
return {
|
|
2385
|
+
onToggle(checked) { running=checked; if(checked)timeLeft=10; },
|
|
2386
|
+
tick(c,dt,now,st) {
|
|
2387
|
+
if(running&&timeLeft>0) timeLeft=Math.max(0,timeLeft-dt);
|
|
2388
|
+
if(timeLeft<=0&&running) flashAlpha=Math.sin(now/150)*.5+.5;
|
|
2389
|
+
else flashAlpha=0;
|
|
2390
|
+
|
|
2391
|
+
const cx=st.w/2,cy=st.h/2,R=Math.min(cx,cy)-4;
|
|
2392
|
+
|
|
2393
|
+
// Flash bg when done
|
|
2394
|
+
if(flashAlpha>0){c.fillStyle='#ff6464';c.globalAlpha=flashAlpha*.1;c.beginPath();c.arc(cx,cy,R+4,0,PI2);c.fill();c.globalAlpha=1;}
|
|
2395
|
+
|
|
2396
|
+
// Background ring
|
|
2397
|
+
c.strokeStyle='rgba(255,255,255,.06)';c.lineWidth=4;c.beginPath();c.arc(cx,cy,R,0,PI2);c.stroke();
|
|
2398
|
+
|
|
2399
|
+
// Progress arc
|
|
2400
|
+
const progress=timeLeft/10;
|
|
2401
|
+
const col=timeLeft>3?'#6ee7b6':timeLeft>1?'#fbbf24':'#ff6b6b';
|
|
2402
|
+
c.strokeStyle=col;c.lineWidth=4;
|
|
2403
|
+
c.beginPath();c.arc(cx,cy,R,-Math.PI/2,-Math.PI/2+progress*PI2,false);c.stroke();
|
|
2404
|
+
|
|
2405
|
+
// Time text
|
|
2406
|
+
const tenths=Math.round(timeLeft*10);
|
|
2407
|
+
if(tenths!==lastTenths){lastTenths=tenths;timeStr=(tenths/10).toFixed(1)+'s';}
|
|
2408
|
+
c.fillStyle='#e2e2f0';c.font="700 16px 'JetBrains Mono',monospace";c.textAlign='center';c.textBaseline='middle';
|
|
2409
|
+
c.fillText(timeStr,cx,cy);
|
|
2410
|
+
|
|
2411
|
+
lbl(c,running?(timeLeft>0?'RUNNING':'TIME UP!'):'TOGGLE TO START',cx,st.h+10,running?col:'#8888aa');
|
|
2412
|
+
if(st.focused)fr(c,st.w,st.h,R);
|
|
2413
|
+
},
|
|
2414
|
+
};
|
|
2415
|
+
}
|
|
2416
|
+
|
|
2417
|
+
/** 20. Pull Refresh -- Drag down to charge, release to spin. */
|
|
2418
|
+
export function PullRefresh() {
|
|
2419
|
+
let pullAmt=0, spinning=false, spinAngle=0, spinTimer=0;
|
|
2420
|
+
return {
|
|
2421
|
+
onDrag(val) { if(!spinning) pullAmt=val; },
|
|
2422
|
+
onToggle(checked) {
|
|
2423
|
+
if(checked&&pullAmt>.6){ spinning=true;spinTimer=2; }
|
|
2424
|
+
pullAmt=0;
|
|
2425
|
+
},
|
|
2426
|
+
tick(c,dt,now,st) {
|
|
2427
|
+
if(spinning){spinAngle+=dt*12;spinTimer-=dt;if(spinTimer<=0){spinning=false;spinAngle=0}}
|
|
2428
|
+
const cx=st.w/2,cy=st.h/2;
|
|
2429
|
+
|
|
2430
|
+
// Container
|
|
2431
|
+
c.fillStyle='rgba(255,255,255,.03)';c.beginPath();c.arc(cx,cy,24,0,PI2);c.fill();
|
|
2432
|
+
|
|
2433
|
+
// Pull indicator or spinner
|
|
2434
|
+
if(spinning){
|
|
2435
|
+
// Spinner arcs
|
|
2436
|
+
for(let i=0;i<3;i++){
|
|
2437
|
+
const a=spinAngle+i*PI2/3;
|
|
2438
|
+
c.strokeStyle='#38bdf8';c.globalAlpha=.3+i*.2;c.lineWidth=3;
|
|
2439
|
+
c.beginPath();c.arc(cx,cy,16,a,a+.8);c.stroke();
|
|
2440
|
+
}
|
|
2441
|
+
c.globalAlpha=1;
|
|
2442
|
+
lbl(c,'LOADING...',cx,st.h+10,'#38bdf8');
|
|
2443
|
+
} else {
|
|
2444
|
+
// Arrow that stretches with pull
|
|
2445
|
+
const stretch=pullAmt*14;
|
|
2446
|
+
c.strokeStyle=pullAmt>.6?'#6ee7b6':'rgba(255,255,255,.15)';c.lineWidth=2;c.lineCap='round';
|
|
2447
|
+
c.beginPath();c.moveTo(cx,cy-8-stretch);c.lineTo(cx,cy+4);c.stroke();
|
|
2448
|
+
c.beginPath();c.moveTo(cx-5,cy+0);c.lineTo(cx,cy+4);c.lineTo(cx+5,cy+0);c.stroke();
|
|
2449
|
+
c.lineCap='butt';
|
|
2450
|
+
|
|
2451
|
+
// Fill ring
|
|
2452
|
+
if(pullAmt>0){
|
|
2453
|
+
c.strokeStyle=pullAmt>.6?'#6ee7b6':'rgba(255,255,255,.1)';c.lineWidth=2;
|
|
2454
|
+
c.beginPath();c.arc(cx,cy,20,-Math.PI/2,-Math.PI/2+pullAmt*PI2);c.stroke();
|
|
2455
|
+
}
|
|
2456
|
+
lbl(c,pullAmt>.6?'RELEASE!':'DRAG SLIDER',cx,st.h+10,pullAmt>.6?'#6ee7b6':'#9999b8');
|
|
2457
|
+
}
|
|
2458
|
+
if(st.focused)fr(c,st.w,st.h,24);
|
|
2459
|
+
},
|
|
2460
|
+
};
|
|
2461
|
+
}
|
|
2462
|
+
|
|
2463
|
+
|
|
2464
|
+
// ===========================================================
|
|
2465
|
+
// BARREL OBJECTS (back-compat)
|
|
2466
|
+
// ===========================================================
|
|
2467
|
+
|
|
2468
|
+
export const UIFXRecipes = {
|
|
2469
|
+
SwarmToggle, LiquidToggle, NeonPulseToggle,
|
|
2470
|
+
MagneticButton, ShatterButton, ConfettiButton, GlitchButton,
|
|
2471
|
+
SparkSlider, CosmicSlider, LaserSlider,
|
|
2472
|
+
};
|
|
2473
|
+
|
|
2474
|
+
export const UIFXRecipes2 = {
|
|
2475
|
+
PendulumToggle, CircuitToggle, LightningToggle, DNAToggle,
|
|
2476
|
+
HeartbeatButton, BreathingButton, InkSplashButton, PixelDissolveButton, FireworkButton,
|
|
2477
|
+
AuroraSlider, WaveSlider, ElasticBandSlider, GravitySlider,
|
|
2478
|
+
OrbitLoader, HelixLoader,
|
|
2479
|
+
RippleCheck, MorphCheck,
|
|
2480
|
+
FlameCounter, GlitchCounter,
|
|
2481
|
+
BubbleRating,
|
|
2482
|
+
};
|
|
2483
|
+
|
|
2484
|
+
export const UIFXRecipes3 = {
|
|
2485
|
+
VolumeKnob, CompassKnob,
|
|
2486
|
+
RingProgress, BatteryGauge, SignalMeter,
|
|
2487
|
+
PillTabs, Stepper, RadioOrbit,
|
|
2488
|
+
PasswordStrength, WaterLevel, HeatMap,
|
|
2489
|
+
DayNightToggle, ReactionPicker, NotificationBell,
|
|
2490
|
+
TypewriterField, SoundWaveBtn, UploadProgress,
|
|
2491
|
+
ScratchReveal, TimerCountdown, PullRefresh,
|
|
2492
|
+
};
|
|
2493
|
+
|
|
2494
|
+
|
|
2495
|
+
// ===========================================================
|
|
2496
|
+
// DEFAULT EXPORT -- combined all-50 namespace
|
|
2497
|
+
// ===========================================================
|
|
2498
|
+
|
|
2499
|
+
export default {
|
|
2500
|
+
SwarmToggle,
|
|
2501
|
+
LiquidToggle,
|
|
2502
|
+
NeonPulseToggle,
|
|
2503
|
+
MagneticButton,
|
|
2504
|
+
ShatterButton,
|
|
2505
|
+
ConfettiButton,
|
|
2506
|
+
GlitchButton,
|
|
2507
|
+
SparkSlider,
|
|
2508
|
+
CosmicSlider,
|
|
2509
|
+
LaserSlider,
|
|
2510
|
+
PendulumToggle,
|
|
2511
|
+
CircuitToggle,
|
|
2512
|
+
LightningToggle,
|
|
2513
|
+
DNAToggle,
|
|
2514
|
+
HeartbeatButton,
|
|
2515
|
+
BreathingButton,
|
|
2516
|
+
InkSplashButton,
|
|
2517
|
+
PixelDissolveButton,
|
|
2518
|
+
FireworkButton,
|
|
2519
|
+
AuroraSlider,
|
|
2520
|
+
WaveSlider,
|
|
2521
|
+
ElasticBandSlider,
|
|
2522
|
+
GravitySlider,
|
|
2523
|
+
OrbitLoader,
|
|
2524
|
+
HelixLoader,
|
|
2525
|
+
RippleCheck,
|
|
2526
|
+
MorphCheck,
|
|
2527
|
+
FlameCounter,
|
|
2528
|
+
GlitchCounter,
|
|
2529
|
+
BubbleRating,
|
|
2530
|
+
VolumeKnob,
|
|
2531
|
+
CompassKnob,
|
|
2532
|
+
RingProgress,
|
|
2533
|
+
BatteryGauge,
|
|
2534
|
+
SignalMeter,
|
|
2535
|
+
PillTabs,
|
|
2536
|
+
Stepper,
|
|
2537
|
+
RadioOrbit,
|
|
2538
|
+
PasswordStrength,
|
|
2539
|
+
WaterLevel,
|
|
2540
|
+
HeatMap,
|
|
2541
|
+
DayNightToggle,
|
|
2542
|
+
ReactionPicker,
|
|
2543
|
+
NotificationBell,
|
|
2544
|
+
TypewriterField,
|
|
2545
|
+
SoundWaveBtn,
|
|
2546
|
+
UploadProgress,
|
|
2547
|
+
ScratchReveal,
|
|
2548
|
+
TimerCountdown,
|
|
2549
|
+
PullRefresh,
|
|
2550
|
+
};
|
|
2551
|
+
|
|
2552
|
+
|
|
2553
|
+
// ===========================================================
|
|
2554
|
+
// RECIPE REGISTRY
|
|
2555
|
+
// ===========================================================
|
|
2556
|
+
|
|
2557
|
+
/**
|
|
2558
|
+
* Every recipe factory keyed by id, for data-driven pickers (demo dropdowns,
|
|
2559
|
+
* random selection, config files). A null-prototype object so keys never
|
|
2560
|
+
* collide with Object.prototype.
|
|
2561
|
+
*/
|
|
2562
|
+
export const RECIPES = Object.assign(Object.create(null), {
|
|
2563
|
+
swarmToggle: SwarmToggle,
|
|
2564
|
+
liquidToggle: LiquidToggle,
|
|
2565
|
+
neonPulseToggle: NeonPulseToggle,
|
|
2566
|
+
magneticButton: MagneticButton,
|
|
2567
|
+
shatterButton: ShatterButton,
|
|
2568
|
+
confettiButton: ConfettiButton,
|
|
2569
|
+
glitchButton: GlitchButton,
|
|
2570
|
+
sparkSlider: SparkSlider,
|
|
2571
|
+
cosmicSlider: CosmicSlider,
|
|
2572
|
+
laserSlider: LaserSlider,
|
|
2573
|
+
pendulumToggle: PendulumToggle,
|
|
2574
|
+
circuitToggle: CircuitToggle,
|
|
2575
|
+
lightningToggle: LightningToggle,
|
|
2576
|
+
dnaToggle: DNAToggle,
|
|
2577
|
+
heartbeatButton: HeartbeatButton,
|
|
2578
|
+
breathingButton: BreathingButton,
|
|
2579
|
+
inkSplashButton: InkSplashButton,
|
|
2580
|
+
pixelDissolveButton: PixelDissolveButton,
|
|
2581
|
+
fireworkButton: FireworkButton,
|
|
2582
|
+
auroraSlider: AuroraSlider,
|
|
2583
|
+
waveSlider: WaveSlider,
|
|
2584
|
+
elasticBandSlider: ElasticBandSlider,
|
|
2585
|
+
gravitySlider: GravitySlider,
|
|
2586
|
+
orbitLoader: OrbitLoader,
|
|
2587
|
+
helixLoader: HelixLoader,
|
|
2588
|
+
rippleCheck: RippleCheck,
|
|
2589
|
+
morphCheck: MorphCheck,
|
|
2590
|
+
flameCounter: FlameCounter,
|
|
2591
|
+
glitchCounter: GlitchCounter,
|
|
2592
|
+
bubbleRating: BubbleRating,
|
|
2593
|
+
volumeKnob: VolumeKnob,
|
|
2594
|
+
compassKnob: CompassKnob,
|
|
2595
|
+
ringProgress: RingProgress,
|
|
2596
|
+
batteryGauge: BatteryGauge,
|
|
2597
|
+
signalMeter: SignalMeter,
|
|
2598
|
+
pillTabs: PillTabs,
|
|
2599
|
+
stepper: Stepper,
|
|
2600
|
+
radioOrbit: RadioOrbit,
|
|
2601
|
+
passwordStrength: PasswordStrength,
|
|
2602
|
+
waterLevel: WaterLevel,
|
|
2603
|
+
heatMap: HeatMap,
|
|
2604
|
+
dayNightToggle: DayNightToggle,
|
|
2605
|
+
reactionPicker: ReactionPicker,
|
|
2606
|
+
notificationBell: NotificationBell,
|
|
2607
|
+
typewriterField: TypewriterField,
|
|
2608
|
+
soundWaveBtn: SoundWaveBtn,
|
|
2609
|
+
uploadProgress: UploadProgress,
|
|
2610
|
+
scratchReveal: ScratchReveal,
|
|
2611
|
+
timerCountdown: TimerCountdown,
|
|
2612
|
+
pullRefresh: PullRefresh,
|
|
2613
|
+
});
|
|
2614
|
+
|
|
2615
|
+
/**
|
|
2616
|
+
* Display + capability metadata for every built-in recipe, so a host can build
|
|
2617
|
+
* a picker without hardcoding the list. A live array: registerRecipe() updates
|
|
2618
|
+
* it, so existing pickers keep working.
|
|
2619
|
+
*
|
|
2620
|
+
* type 'toggle' | 'button' | 'slider' -- the native element it mounts on
|
|
2621
|
+
* family display grouping (Toggles, Buttons, Sliders, Knobs, ...)
|
|
2622
|
+
* themeable accepts { colors, theme } (false for all until a later pass)
|
|
2623
|
+
* motionSafe inherently-calm under prefers-reduced-motion (false for all)
|
|
2624
|
+
*/
|
|
2625
|
+
export const RECIPE_META = [
|
|
2626
|
+
{ id: 'swarmToggle', name: 'Swarm Toggle', type: 'toggle', family: 'Toggles', themeable: false, motionSafe: false },
|
|
2627
|
+
{ id: 'liquidToggle', name: 'Liquid Toggle', type: 'toggle', family: 'Toggles', themeable: false, motionSafe: false },
|
|
2628
|
+
{ id: 'neonPulseToggle', name: 'Neon Pulse Toggle', type: 'toggle', family: 'Toggles', themeable: false, motionSafe: false },
|
|
2629
|
+
{ id: 'magneticButton', name: 'Magnetic Button', type: 'button', family: 'Buttons', themeable: false, motionSafe: false },
|
|
2630
|
+
{ id: 'shatterButton', name: 'Shatter Button', type: 'button', family: 'Buttons', themeable: false, motionSafe: false },
|
|
2631
|
+
{ id: 'confettiButton', name: 'Confetti Button', type: 'button', family: 'Buttons', themeable: false, motionSafe: false },
|
|
2632
|
+
{ id: 'glitchButton', name: 'Glitch Button', type: 'button', family: 'Buttons', themeable: false, motionSafe: false },
|
|
2633
|
+
{ id: 'sparkSlider', name: 'Spark Slider', type: 'slider', family: 'Sliders', themeable: false, motionSafe: false },
|
|
2634
|
+
{ id: 'cosmicSlider', name: 'Cosmic Slider', type: 'slider', family: 'Sliders', themeable: false, motionSafe: false },
|
|
2635
|
+
{ id: 'laserSlider', name: 'Laser Slider', type: 'slider', family: 'Sliders', themeable: false, motionSafe: false },
|
|
2636
|
+
{ id: 'pendulumToggle', name: 'Pendulum Toggle', type: 'toggle', family: 'Toggles', themeable: false, motionSafe: false },
|
|
2637
|
+
{ id: 'circuitToggle', name: 'Circuit Toggle', type: 'toggle', family: 'Toggles', themeable: false, motionSafe: false },
|
|
2638
|
+
{ id: 'lightningToggle', name: 'Lightning Toggle', type: 'toggle', family: 'Toggles', themeable: false, motionSafe: false },
|
|
2639
|
+
{ id: 'dnaToggle', name: 'Dna Toggle', type: 'toggle', family: 'Toggles', themeable: false, motionSafe: false },
|
|
2640
|
+
{ id: 'heartbeatButton', name: 'Heartbeat Button', type: 'button', family: 'Buttons', themeable: false, motionSafe: false },
|
|
2641
|
+
{ id: 'breathingButton', name: 'Breathing Button', type: 'button', family: 'Buttons', themeable: false, motionSafe: false },
|
|
2642
|
+
{ id: 'inkSplashButton', name: 'Ink Splash Button', type: 'button', family: 'Buttons', themeable: false, motionSafe: false },
|
|
2643
|
+
{ id: 'pixelDissolveButton', name: 'Pixel Dissolve Button', type: 'button', family: 'Buttons', themeable: false, motionSafe: false },
|
|
2644
|
+
{ id: 'fireworkButton', name: 'Firework Button', type: 'button', family: 'Buttons', themeable: false, motionSafe: false },
|
|
2645
|
+
{ id: 'auroraSlider', name: 'Aurora Slider', type: 'slider', family: 'Sliders', themeable: false, motionSafe: false },
|
|
2646
|
+
{ id: 'waveSlider', name: 'Wave Slider', type: 'slider', family: 'Sliders', themeable: false, motionSafe: false },
|
|
2647
|
+
{ id: 'elasticBandSlider', name: 'Elastic Band Slider', type: 'slider', family: 'Sliders', themeable: false, motionSafe: false },
|
|
2648
|
+
{ id: 'gravitySlider', name: 'Gravity Slider', type: 'slider', family: 'Sliders', themeable: false, motionSafe: false },
|
|
2649
|
+
{ id: 'orbitLoader', name: 'Orbit Loader', type: 'toggle', family: 'Loaders', themeable: false, motionSafe: false },
|
|
2650
|
+
{ id: 'helixLoader', name: 'Helix Loader', type: 'toggle', family: 'Loaders', themeable: false, motionSafe: false },
|
|
2651
|
+
{ id: 'rippleCheck', name: 'Ripple Check', type: 'toggle', family: 'Checkboxes', themeable: false, motionSafe: false },
|
|
2652
|
+
{ id: 'morphCheck', name: 'Morph Check', type: 'toggle', family: 'Checkboxes', themeable: false, motionSafe: false },
|
|
2653
|
+
{ id: 'flameCounter', name: 'Flame Counter', type: 'slider', family: 'Counters', themeable: false, motionSafe: false },
|
|
2654
|
+
{ id: 'glitchCounter', name: 'Glitch Counter', type: 'slider', family: 'Counters', themeable: false, motionSafe: false },
|
|
2655
|
+
{ id: 'bubbleRating', name: 'Bubble Rating', type: 'slider', family: 'Rating', themeable: false, motionSafe: false },
|
|
2656
|
+
{ id: 'volumeKnob', name: 'Volume Knob', type: 'slider', family: 'Knobs', themeable: false, motionSafe: false },
|
|
2657
|
+
{ id: 'compassKnob', name: 'Compass Knob', type: 'slider', family: 'Knobs', themeable: false, motionSafe: false },
|
|
2658
|
+
{ id: 'ringProgress', name: 'Ring Progress', type: 'slider', family: 'Progress', themeable: false, motionSafe: false },
|
|
2659
|
+
{ id: 'batteryGauge', name: 'Battery Gauge', type: 'slider', family: 'Progress', themeable: false, motionSafe: false },
|
|
2660
|
+
{ id: 'signalMeter', name: 'Signal Meter', type: 'slider', family: 'Progress', themeable: false, motionSafe: false },
|
|
2661
|
+
{ id: 'pillTabs', name: 'Pill Tabs', type: 'button', family: 'Controls', themeable: false, motionSafe: false },
|
|
2662
|
+
{ id: 'stepper', name: 'Stepper', type: 'button', family: 'Controls', themeable: false, motionSafe: false },
|
|
2663
|
+
{ id: 'radioOrbit', name: 'Radio Orbit', type: 'slider', family: 'Controls', themeable: false, motionSafe: false },
|
|
2664
|
+
{ id: 'passwordStrength', name: 'Password Strength', type: 'slider', family: 'Indicators', themeable: false, motionSafe: false },
|
|
2665
|
+
{ id: 'waterLevel', name: 'Water Level', type: 'slider', family: 'Indicators', themeable: false, motionSafe: false },
|
|
2666
|
+
{ id: 'heatMap', name: 'Heat Map', type: 'slider', family: 'Indicators', themeable: false, motionSafe: false },
|
|
2667
|
+
{ id: 'dayNightToggle', name: 'Day Night Toggle', type: 'toggle', family: 'Mood', themeable: false, motionSafe: false },
|
|
2668
|
+
{ id: 'reactionPicker', name: 'Reaction Picker', type: 'button', family: 'Mood', themeable: false, motionSafe: false },
|
|
2669
|
+
{ id: 'notificationBell', name: 'Notification Bell', type: 'button', family: 'Mood', themeable: false, motionSafe: false },
|
|
2670
|
+
{ id: 'typewriterField', name: 'Typewriter Field', type: 'toggle', family: 'Feedback', themeable: false, motionSafe: false },
|
|
2671
|
+
{ id: 'soundWaveBtn', name: 'Sound Wave Btn', type: 'button', family: 'Feedback', themeable: false, motionSafe: false },
|
|
2672
|
+
{ id: 'uploadProgress', name: 'Upload Progress', type: 'slider', family: 'Feedback', themeable: false, motionSafe: false },
|
|
2673
|
+
{ id: 'scratchReveal', name: 'Scratch Reveal', type: 'slider', family: 'Fun', themeable: false, motionSafe: false },
|
|
2674
|
+
{ id: 'timerCountdown', name: 'Timer Countdown', type: 'toggle', family: 'Fun', themeable: false, motionSafe: false },
|
|
2675
|
+
{ id: 'pullRefresh', name: 'Pull Refresh', type: 'slider', family: 'Fun', themeable: false, motionSafe: false },
|
|
2676
|
+
];
|
|
2677
|
+
|
|
2678
|
+
/** Names of every built-in recipe (the keys of RECIPES at load time). */
|
|
2679
|
+
export const RECIPE_NAMES = Object.freeze(Object.keys(RECIPES));
|
|
2680
|
+
|
|
2681
|
+
/**
|
|
2682
|
+
* Register a custom recipe, or override a built-in. Instantly usable via
|
|
2683
|
+
* RECIPES[id] and reflected in RECIPE_META so existing pickers keep working.
|
|
2684
|
+
*
|
|
2685
|
+
* @param {string} id the RECIPES key
|
|
2686
|
+
* @param {Function} factory a recipe factory: (opts) => Recipe
|
|
2687
|
+
* @param {{ name?: string, type?: string, family?: string, themeable?: boolean, motionSafe?: boolean }} [meta]
|
|
2688
|
+
* Omitted fields fall back to the existing entry (when overriding), then to a
|
|
2689
|
+
* de-camelCased name, family 'custom', and false flags.
|
|
2690
|
+
* @returns {Function} the registered factory
|
|
2691
|
+
*/
|
|
2692
|
+
export function registerRecipe(id, factory, meta) {
|
|
2693
|
+
if (typeof id !== 'string' || id.length === 0) {
|
|
2694
|
+
throw new TypeError('registerRecipe: id must be a non-empty string');
|
|
2695
|
+
}
|
|
2696
|
+
if (typeof factory !== 'function') {
|
|
2697
|
+
throw new TypeError('registerRecipe: factory must be a function');
|
|
2698
|
+
}
|
|
2699
|
+
|
|
2700
|
+
const idx = RECIPE_META.findIndex((m) => m.id === id);
|
|
2701
|
+
const prev = idx >= 0 ? RECIPE_META[idx] : null;
|
|
2702
|
+
const type = (meta && meta.type) || (prev && prev.type) || undefined;
|
|
2703
|
+
// A recipe's type selects its native element; a typeless recipe cannot be
|
|
2704
|
+
// mounted. Reject it at registration (fail closed) -- checked BEFORE any
|
|
2705
|
+
// mutation, so a rejected call leaves RECIPES/RECIPE_META untouched.
|
|
2706
|
+
if (type !== 'toggle' && type !== 'button' && type !== 'slider') {
|
|
2707
|
+
throw new TypeError('registerRecipe: type must be "toggle", "button", or "slider"');
|
|
2708
|
+
}
|
|
2709
|
+
|
|
2710
|
+
RECIPES[id] = factory;
|
|
2711
|
+
const entry = {
|
|
2712
|
+
id,
|
|
2713
|
+
name: (meta && meta.name) || (prev && prev.name)
|
|
2714
|
+
|| id.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/^[a-z]/, (c) => c.toUpperCase()),
|
|
2715
|
+
type,
|
|
2716
|
+
family: (meta && meta.family) || (prev && prev.family) || 'custom',
|
|
2717
|
+
themeable: meta && 'themeable' in meta ? !!meta.themeable : (prev ? prev.themeable : false),
|
|
2718
|
+
motionSafe: meta && 'motionSafe' in meta ? !!meta.motionSafe : (prev ? prev.motionSafe : false),
|
|
2719
|
+
};
|
|
2720
|
+
if (idx >= 0) RECIPE_META[idx] = entry; else RECIPE_META.push(entry);
|
|
2721
|
+
return factory;
|
|
2722
|
+
}
|
|
2723
|
+
|
|
2724
|
+
/**
|
|
2725
|
+
* Levenshtein edit distance (module-local, for the did-you-mean hint). Bounded
|
|
2726
|
+
* by the short recipe-id strings; never on a hot path.
|
|
2727
|
+
*/
|
|
2728
|
+
function editDistance(a, b) {
|
|
2729
|
+
const m = a.length, n = b.length;
|
|
2730
|
+
if (m === 0) return n;
|
|
2731
|
+
if (n === 0) return m;
|
|
2732
|
+
let prev = new Array(n + 1);
|
|
2733
|
+
let curr = new Array(n + 1);
|
|
2734
|
+
for (let j = 0; j <= n; j++) prev[j] = j;
|
|
2735
|
+
for (let i = 1; i <= m; i++) {
|
|
2736
|
+
curr[0] = i;
|
|
2737
|
+
for (let j = 1; j <= n; j++) {
|
|
2738
|
+
const cost = a.charCodeAt(i - 1) === b.charCodeAt(j - 1) ? 0 : 1;
|
|
2739
|
+
const del = prev[j] + 1;
|
|
2740
|
+
const ins = curr[j - 1] + 1;
|
|
2741
|
+
const sub = prev[j - 1] + cost;
|
|
2742
|
+
curr[j] = del < ins ? (del < sub ? del : sub) : (ins < sub ? ins : sub);
|
|
2743
|
+
}
|
|
2744
|
+
const tmp = prev; prev = curr; curr = tmp;
|
|
2745
|
+
}
|
|
2746
|
+
return prev[n];
|
|
2747
|
+
}
|
|
2748
|
+
|
|
2749
|
+
/** Nearest known recipe id to a mistyped one, for the did-you-mean hint. */
|
|
2750
|
+
function nearestRecipe(id) {
|
|
2751
|
+
let best = RECIPE_NAMES[0], bestD = Infinity;
|
|
2752
|
+
for (let i = 0; i < RECIPE_NAMES.length; i++) {
|
|
2753
|
+
const d = editDistance(id, RECIPE_NAMES[i]);
|
|
2754
|
+
if (d < bestD) { bestD = d; best = RECIPE_NAMES[i]; }
|
|
2755
|
+
}
|
|
2756
|
+
return best;
|
|
2757
|
+
}
|
|
2758
|
+
|
|
2759
|
+
/**
|
|
2760
|
+
* Resolve a recipe id to its factory + declared type and mount it via
|
|
2761
|
+
* mountUIFX. Fail closed:
|
|
2762
|
+
* - unknown id -> throw naming the nearest known id (did-you-mean).
|
|
2763
|
+
* - options.type present and != the recipe's declared type -> throw.
|
|
2764
|
+
* options.type is consumed here, never forwarded as a mount option.
|
|
2765
|
+
*
|
|
2766
|
+
* @param {HTMLElement} container
|
|
2767
|
+
* @param {string} id
|
|
2768
|
+
* @param {Object} [options]
|
|
2769
|
+
* @returns {{ el: HTMLElement, destroy: Function }}
|
|
2770
|
+
*/
|
|
2771
|
+
export function mountRecipe(container, id, options) {
|
|
2772
|
+
const factory = RECIPES[id];
|
|
2773
|
+
if (typeof factory !== 'function') {
|
|
2774
|
+
// did-you-mean only makes sense for a string typo; a non-string id
|
|
2775
|
+
// (null, a number) gets the clean message without entering the helper
|
|
2776
|
+
// (nearestRecipe -> editDistance reads .length, which would throw on null).
|
|
2777
|
+
const hint = typeof id === 'string'
|
|
2778
|
+
? ' Did you mean "' + nearestRecipe(id) + '"?'
|
|
2779
|
+
: '';
|
|
2780
|
+
throw new Error('mountRecipe: unknown recipe "' + id + '".' + hint);
|
|
2781
|
+
}
|
|
2782
|
+
const meta = RECIPE_META.find((m) => m.id === id) || null;
|
|
2783
|
+
const type = meta ? meta.type : undefined;
|
|
2784
|
+
if (options && 'type' in options && options.type !== type) {
|
|
2785
|
+
throw new Error(
|
|
2786
|
+
'mountRecipe: recipe "' + id + '" mounts as "' + type +
|
|
2787
|
+
'", not "' + options.type + '"'
|
|
2788
|
+
);
|
|
2789
|
+
}
|
|
2790
|
+
let mountOptions = options;
|
|
2791
|
+
if (options && 'type' in options) {
|
|
2792
|
+
mountOptions = {};
|
|
2793
|
+
for (const k in options) if (k !== 'type') mountOptions[k] = options[k];
|
|
2794
|
+
}
|
|
2795
|
+
return mountUIFX(container, type, factory, mountOptions);
|
|
2796
|
+
}
|
|
2797
|
+
|