@zakkster/lite-ui-fx 1.2.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 +44 -1
- package/UIFXController.js +5 -2
- package/UIFXRecipes.js +583 -264
- package/llms.txt +4 -2
- package/package.json +1 -1
package/UIFXRecipes.js
CHANGED
|
@@ -34,6 +34,20 @@ import { Random } from '@zakkster/lite-random';
|
|
|
34
34
|
import { mountUIFX, UIType } from './UIFXController.js';
|
|
35
35
|
|
|
36
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
|
+
|
|
37
51
|
// ---------------------------------------------------------
|
|
38
52
|
// SHARED HELPERS
|
|
39
53
|
// ---------------------------------------------------------
|
|
@@ -57,31 +71,32 @@ function roundRect(ctx, x, y, w, h, r) {
|
|
|
57
71
|
function drawFocusRing(ctx, w, h, r) {
|
|
58
72
|
ctx.strokeStyle = 'rgba(110,231,182,.6)';
|
|
59
73
|
ctx.lineWidth = 2;
|
|
60
|
-
ctx.setLineDash(
|
|
74
|
+
ctx.setLineDash(DASH_FOCUS);
|
|
61
75
|
roundRect(ctx, -2, -2, w + 4, h + 4, r + 2);
|
|
62
76
|
ctx.stroke();
|
|
63
|
-
ctx.setLineDash(
|
|
77
|
+
ctx.setLineDash(DASH_NONE);
|
|
64
78
|
}
|
|
65
79
|
|
|
66
|
-
/** Draw a state label so the user knows the current state.
|
|
67
|
-
|
|
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) {
|
|
68
83
|
ctx.fillStyle = color;
|
|
69
|
-
ctx.font = "500 9px 'JetBrains Mono',monospace";
|
|
84
|
+
ctx.font = font || "500 9px 'JetBrains Mono',monospace";
|
|
70
85
|
ctx.textAlign = 'center';
|
|
71
86
|
ctx.fillText(text, x, y);
|
|
72
87
|
}
|
|
73
88
|
|
|
74
|
-
function label(ctx, text, x, y, color = '#9999b8') {
|
|
75
|
-
ctx.fillStyle = color; ctx.font = "500 9px 'JetBrains Mono',monospace"; ctx.textAlign = 'center'; ctx.fillText(text, x, y);
|
|
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);
|
|
76
91
|
}
|
|
77
92
|
function focusRing(ctx, w, h, r) {
|
|
78
|
-
ctx.strokeStyle = 'rgba(110,231,182,.5)'; ctx.lineWidth = 2; ctx.setLineDash(
|
|
79
|
-
roundRect(ctx, -2, -2, w + 4, h + 4, r + 2); ctx.stroke(); ctx.setLineDash(
|
|
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);
|
|
80
95
|
}
|
|
81
96
|
|
|
82
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()}
|
|
83
|
-
function lbl(c,t,x,y,col='#9999b8'){c.fillStyle=col;c.font="500 9px 'JetBrains Mono',monospace";c.textAlign='center';c.fillText(t,x,y)}
|
|
84
|
-
function fr(c,w,h,r){c.strokeStyle='rgba(110,231,182,.5)';c.lineWidth=2;c.setLineDash(
|
|
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)}
|
|
85
100
|
const PI2=Math.PI*2;
|
|
86
101
|
|
|
87
102
|
|
|
@@ -198,15 +213,28 @@ export function LiquidToggle() {
|
|
|
198
213
|
* Neon Pulse Toggle -- Expanding shockwave rings on toggle.
|
|
199
214
|
*/
|
|
200
215
|
export function NeonPulseToggle() {
|
|
201
|
-
|
|
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);
|
|
202
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
|
+
}
|
|
203
230
|
|
|
204
231
|
return {
|
|
205
232
|
onToggle(checked) {
|
|
206
|
-
|
|
233
|
+
spawnRing(checked ? lastW - 18 : 18);
|
|
207
234
|
},
|
|
208
235
|
|
|
209
236
|
tick(ctx, dt, now, st) {
|
|
237
|
+
lastW = st.w;
|
|
210
238
|
knobX = lerp(knobX, st.toggled ? st.w - 18 : 18, dt * 15);
|
|
211
239
|
|
|
212
240
|
// Track
|
|
@@ -214,18 +242,21 @@ export function NeonPulseToggle() {
|
|
|
214
242
|
roundRect(ctx, 0, 0, st.w, st.h, st.h / 2);
|
|
215
243
|
ctx.fill();
|
|
216
244
|
|
|
217
|
-
// Shockwave rings
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
if (
|
|
223
|
-
|
|
224
|
-
|
|
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];
|
|
225
255
|
ctx.beginPath();
|
|
226
|
-
ctx.arc(
|
|
256
|
+
ctx.arc(ringX[i], st.h / 2, ringR[i], 0, Math.PI * 2);
|
|
227
257
|
ctx.stroke();
|
|
228
258
|
}
|
|
259
|
+
ctx.globalAlpha = 1;
|
|
229
260
|
|
|
230
261
|
// Knob
|
|
231
262
|
ctx.fillStyle = st.toggled ? '#38bdf8' : '#9999b8';
|
|
@@ -295,7 +326,10 @@ export function MagneticButton({ maxPull = 15 } = {}) {
|
|
|
295
326
|
*/
|
|
296
327
|
export function ShatterButton({ seed = 42 } = {}) {
|
|
297
328
|
const rng = new Random(seed);
|
|
298
|
-
|
|
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 };
|
|
299
333
|
let visible = true;
|
|
300
334
|
let respawnTimer = 0;
|
|
301
335
|
|
|
@@ -304,13 +338,15 @@ export function ShatterButton({ seed = 42 } = {}) {
|
|
|
304
338
|
if (!visible) return;
|
|
305
339
|
visible = false;
|
|
306
340
|
respawnTimer = 1.5;
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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++;
|
|
314
350
|
}
|
|
315
351
|
},
|
|
316
352
|
|
|
@@ -332,10 +368,11 @@ export function ShatterButton({ seed = 42 } = {}) {
|
|
|
332
368
|
if (st.focused) drawFocusRing(ctx, st.w, st.h, 10);
|
|
333
369
|
}
|
|
334
370
|
|
|
335
|
-
// Falling shards
|
|
371
|
+
// Falling shards -- fixed pool
|
|
336
372
|
ctx.fillStyle = 'rgba(255,255,255,.5)';
|
|
337
|
-
for (let i =
|
|
338
|
-
const s =
|
|
373
|
+
for (let i = 0; i < SHARDS; i++) {
|
|
374
|
+
const s = shard[i];
|
|
375
|
+
if (!s.live) continue;
|
|
339
376
|
s.vy += 400 * dt;
|
|
340
377
|
s.x += s.vx * dt; s.y += s.vy * dt;
|
|
341
378
|
s.r += s.vr * dt;
|
|
@@ -343,7 +380,7 @@ export function ShatterButton({ seed = 42 } = {}) {
|
|
|
343
380
|
ctx.translate(s.x, s.y); ctx.rotate(s.r);
|
|
344
381
|
ctx.fillRect(-s.sz / 2, -s.sz / 2, s.sz, s.sz);
|
|
345
382
|
ctx.restore();
|
|
346
|
-
if (s.y > st.h + 100)
|
|
383
|
+
if (s.y > st.h + 100) s.live = false;
|
|
347
384
|
}
|
|
348
385
|
},
|
|
349
386
|
};
|
|
@@ -355,22 +392,28 @@ export function ShatterButton({ seed = 42 } = {}) {
|
|
|
355
392
|
*/
|
|
356
393
|
export function ConfettiButton({ seed = 42, colors = ['#6ee7b6', '#38bdf8', '#a78bfa', '#fbbf24', '#f43f5e'] } = {}) {
|
|
357
394
|
const rng = new Random(seed);
|
|
358
|
-
|
|
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 };
|
|
359
399
|
let pressScale = 1;
|
|
360
400
|
|
|
361
401
|
return {
|
|
362
402
|
onClick(x, y) {
|
|
363
403
|
pressScale = 0.85;
|
|
364
|
-
|
|
404
|
+
let n = 0;
|
|
405
|
+
for (let i = 0; i < CONF && n < 40; i++) {
|
|
406
|
+
const c = conf[i];
|
|
407
|
+
if (c.live) continue;
|
|
365
408
|
const a = rng.range(Math.PI, Math.PI * 2);
|
|
366
409
|
const v = rng.range(100, 300);
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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++;
|
|
374
417
|
}
|
|
375
418
|
},
|
|
376
419
|
|
|
@@ -390,9 +433,10 @@ export function ConfettiButton({ seed = 42, colors = ['#6ee7b6', '#38bdf8', '#a7
|
|
|
390
433
|
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
|
391
434
|
ctx.fillText('CONFETTI', st.w / 2, st.h / 2);
|
|
392
435
|
|
|
393
|
-
// Tumbling confetti
|
|
394
|
-
for (let i =
|
|
436
|
+
// Tumbling confetti -- fixed pool
|
|
437
|
+
for (let i = 0; i < CONF; i++) {
|
|
395
438
|
const c = conf[i];
|
|
439
|
+
if (!c.live) continue;
|
|
396
440
|
c.vy += 300 * dt;
|
|
397
441
|
c.x += c.vx * dt; c.y += c.vy * dt;
|
|
398
442
|
c.rx += c.vrx * dt; c.ry += c.vry * dt;
|
|
@@ -402,7 +446,7 @@ export function ConfettiButton({ seed = 42, colors = ['#6ee7b6', '#38bdf8', '#a7
|
|
|
402
446
|
ctx.fillStyle = c.c;
|
|
403
447
|
ctx.fillRect(-c.w / 2, -c.h / 2, c.w, c.h);
|
|
404
448
|
ctx.restore();
|
|
405
|
-
if (c.y > st.h + 100)
|
|
449
|
+
if (c.y > st.h + 100) c.live = false;
|
|
406
450
|
}
|
|
407
451
|
|
|
408
452
|
if (st.focused) {
|
|
@@ -422,33 +466,37 @@ export function GlitchButton({ seed = 42 } = {}) {
|
|
|
422
466
|
const rng = new Random(seed);
|
|
423
467
|
let glitchIntensity = 0;
|
|
424
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
|
+
|
|
425
483
|
return {
|
|
426
484
|
tick(ctx, dt, now, st) {
|
|
427
485
|
glitchIntensity = lerp(glitchIntensity, st.hover ? 1 : 0, dt * 10);
|
|
428
486
|
|
|
429
|
-
const drawBase = (ox, oy, color) => {
|
|
430
|
-
ctx.fillStyle = color;
|
|
431
|
-
roundRect(ctx, ox, oy, st.w, st.h, 10);
|
|
432
|
-
ctx.fill();
|
|
433
|
-
ctx.fillStyle = '#fff';
|
|
434
|
-
ctx.font = "600 13px 'Space Grotesk',sans-serif";
|
|
435
|
-
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
|
436
|
-
ctx.fillText('GLITCH', st.w / 2 + ox, st.h / 2 + oy);
|
|
437
|
-
};
|
|
438
|
-
|
|
439
487
|
if (glitchIntensity > 0.01) {
|
|
440
488
|
ctx.globalCompositeOperation = 'screen';
|
|
441
489
|
const off = rng.range(2, 6) * glitchIntensity;
|
|
442
490
|
// Alternate between split and normal for flicker
|
|
443
491
|
if (rng.next() > 0.4) {
|
|
444
|
-
drawBase(-off, 0,
|
|
445
|
-
drawBase(off, 0,
|
|
492
|
+
drawBase(ctx, st, -off, 0, '#ff0055', 0.6 * glitchIntensity);
|
|
493
|
+
drawBase(ctx, st, off, 0, '#00ffcc', 0.6 * glitchIntensity);
|
|
446
494
|
} else {
|
|
447
|
-
drawBase(0, 0, 'rgba(255,255,255,.1)');
|
|
495
|
+
drawBase(ctx, st, 0, 0, 'rgba(255,255,255,.1)', 1);
|
|
448
496
|
}
|
|
449
497
|
ctx.globalCompositeOperation = 'source-over';
|
|
450
498
|
} else {
|
|
451
|
-
drawBase(0, 0, 'rgba(255,255,255,.06)');
|
|
499
|
+
drawBase(ctx, st, 0, 0, 'rgba(255,255,255,.06)', 1);
|
|
452
500
|
}
|
|
453
501
|
|
|
454
502
|
if (st.focused) drawFocusRing(ctx, st.w, st.h, 10);
|
|
@@ -467,27 +515,36 @@ export function GlitchButton({ seed = 42 } = {}) {
|
|
|
467
515
|
*/
|
|
468
516
|
export function SparkSlider({ seed = 42, color = '#fbbf24' } = {}) {
|
|
469
517
|
const rng = new Random(seed);
|
|
470
|
-
|
|
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 };
|
|
471
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
|
+
}
|
|
472
535
|
|
|
473
536
|
return {
|
|
474
537
|
onDrag(val) {
|
|
475
538
|
const delta = val - lastVal;
|
|
476
539
|
if (Math.abs(delta) > 0.001) {
|
|
477
540
|
const dir = Math.sign(delta);
|
|
478
|
-
for (let i = 0; i < 4; i++)
|
|
479
|
-
sparks.push({
|
|
480
|
-
x: val * 200, y: 14,
|
|
481
|
-
vx: -dir * rng.range(100, 300),
|
|
482
|
-
vy: rng.range(-150, 50),
|
|
483
|
-
life: 1,
|
|
484
|
-
});
|
|
485
|
-
}
|
|
541
|
+
for (let i = 0; i < 4; i++) spawn(val, dir);
|
|
486
542
|
}
|
|
487
543
|
lastVal = val;
|
|
488
544
|
},
|
|
489
545
|
|
|
490
546
|
tick(ctx, dt, now, st) {
|
|
547
|
+
lastW = st.w;
|
|
491
548
|
// Track background
|
|
492
549
|
ctx.fillStyle = 'rgba(255,255,255,.08)';
|
|
493
550
|
roundRect(ctx, 0, 12, st.w, 4, 2);
|
|
@@ -505,26 +562,28 @@ export function SparkSlider({ seed = 42, color = '#fbbf24' } = {}) {
|
|
|
505
562
|
ctx.fill();
|
|
506
563
|
|
|
507
564
|
// Value label
|
|
508
|
-
drawStateLabel(ctx,
|
|
565
|
+
drawStateLabel(ctx, PCT[Math.round(st.val * 100)], st.w / 2, st.h + 10, '#9999b8');
|
|
509
566
|
|
|
510
|
-
// Sparks
|
|
567
|
+
// Sparks -- fixed pool, themed color, alpha via globalAlpha
|
|
568
|
+
// (was `rgba(251,191,36,${life})` built per spark per frame).
|
|
511
569
|
ctx.globalCompositeOperation = 'screen';
|
|
512
|
-
|
|
513
|
-
|
|
570
|
+
ctx.fillStyle = color;
|
|
571
|
+
for (let i = 0; i < SPARKS; i++) {
|
|
572
|
+
const s = spark[i];
|
|
573
|
+
if (s.life <= 0) continue;
|
|
514
574
|
s.vy += 400 * dt;
|
|
515
575
|
s.vx *= 0.95;
|
|
516
576
|
s.x += s.vx * dt; s.y += s.vy * dt;
|
|
517
577
|
s.life -= 2.0 * dt;
|
|
518
|
-
if (s.life <= 0)
|
|
519
|
-
|
|
520
|
-
// Motion-blur stretch
|
|
521
|
-
ctx.fillStyle = `rgba(251,191,36,${s.life})`;
|
|
578
|
+
if (s.life <= 0) continue;
|
|
579
|
+
ctx.globalAlpha = s.life;
|
|
522
580
|
ctx.save();
|
|
523
581
|
ctx.translate(s.x, s.y);
|
|
524
582
|
ctx.rotate(Math.atan2(s.vy, s.vx));
|
|
525
583
|
ctx.fillRect(0, -1, Math.max(3, Math.abs(s.vx) * 0.05), 2);
|
|
526
584
|
ctx.restore();
|
|
527
585
|
}
|
|
586
|
+
ctx.globalAlpha = 1;
|
|
528
587
|
ctx.globalCompositeOperation = 'source-over';
|
|
529
588
|
|
|
530
589
|
if (st.focused) drawFocusRing(ctx, st.w, st.h, 4);
|
|
@@ -556,9 +615,11 @@ export function CosmicSlider({ seed = 42, dustCount = 80 } = {}) {
|
|
|
556
615
|
|
|
557
616
|
const tx = st.val * st.w;
|
|
558
617
|
|
|
559
|
-
// Dust particles attracted to thumb
|
|
618
|
+
// Dust particles attracted to thumb (indexed loop -- for..of over an
|
|
619
|
+
// array can allocate an iterator on the hot path)
|
|
560
620
|
ctx.fillStyle = '#fff';
|
|
561
|
-
for (
|
|
621
|
+
for (let di = 0; di < dust.length; di++) {
|
|
622
|
+
const d = dust[di];
|
|
562
623
|
const dx = tx - d.x, dy = 14 - d.y;
|
|
563
624
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
564
625
|
if (dist < 40) {
|
|
@@ -582,7 +643,7 @@ export function CosmicSlider({ seed = 42, dustCount = 80 } = {}) {
|
|
|
582
643
|
ctx.fill(); ctx.stroke();
|
|
583
644
|
ctx.shadowBlur = 0;
|
|
584
645
|
|
|
585
|
-
drawStateLabel(ctx,
|
|
646
|
+
drawStateLabel(ctx, PCT[Math.round(st.val * 100)], st.w / 2, st.h + 10, '#9999b8');
|
|
586
647
|
|
|
587
648
|
if (st.focused) drawFocusRing(ctx, st.w, st.h, 4);
|
|
588
649
|
},
|
|
@@ -594,9 +655,19 @@ export function CosmicSlider({ seed = 42, dustCount = 80 } = {}) {
|
|
|
594
655
|
* Laser Slider -- Energy beam traces the filled track. Pulsing plasma thumb.
|
|
595
656
|
*/
|
|
596
657
|
export function LaserSlider() {
|
|
597
|
-
|
|
658
|
+
const REF = 100; // gradient reference width; scaled to tx at paint
|
|
659
|
+
let pulseTime = 0, beam = null;
|
|
598
660
|
|
|
599
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
|
+
},
|
|
600
671
|
tick(ctx, dt, now, st) {
|
|
601
672
|
pulseTime += dt * 10;
|
|
602
673
|
|
|
@@ -607,26 +678,26 @@ export function LaserSlider() {
|
|
|
607
678
|
|
|
608
679
|
const tx = st.val * st.w;
|
|
609
680
|
|
|
610
|
-
// Laser beam gradient
|
|
681
|
+
// Laser beam (gradient from init, scaled to 0..tx)
|
|
611
682
|
if (tx > 2) {
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
g.addColorStop(1, '#fff');
|
|
616
|
-
ctx.fillStyle = g;
|
|
683
|
+
ctx.save();
|
|
684
|
+
ctx.scale(tx / REF, 1);
|
|
685
|
+
ctx.fillStyle = beam || '#38bdf8';
|
|
617
686
|
ctx.shadowBlur = 10; ctx.shadowColor = '#38bdf8';
|
|
618
|
-
roundRect(ctx, 0, 12,
|
|
687
|
+
roundRect(ctx, 0, 12, REF, 4, 2);
|
|
619
688
|
ctx.fill();
|
|
689
|
+
ctx.restore();
|
|
620
690
|
}
|
|
621
691
|
|
|
622
|
-
// Plasma thumb (pulsing)
|
|
692
|
+
// Plasma thumb (pulsing) -- keep the glow the beam left on
|
|
693
|
+
ctx.shadowBlur = 10; ctx.shadowColor = '#38bdf8';
|
|
623
694
|
ctx.fillStyle = '#fff';
|
|
624
695
|
ctx.beginPath();
|
|
625
696
|
ctx.arc(tx, 14, 8 + Math.sin(pulseTime) * 2, 0, Math.PI * 2);
|
|
626
697
|
ctx.fill();
|
|
627
698
|
ctx.shadowBlur = 0;
|
|
628
699
|
|
|
629
|
-
drawStateLabel(ctx,
|
|
700
|
+
drawStateLabel(ctx, PCT[Math.round(st.val * 100)], st.w / 2, st.h + 10, '#9999b8');
|
|
630
701
|
|
|
631
702
|
if (st.focused) drawFocusRing(ctx, st.w, st.h, 4);
|
|
632
703
|
},
|
|
@@ -669,10 +740,23 @@ export function PendulumToggle() {
|
|
|
669
740
|
/** 2. Circuit Toggle -- Electricity flows through a circuit path when ON. */
|
|
670
741
|
export function CircuitToggle({ seed = 42 } = {}) {
|
|
671
742
|
const rng = new Random(seed);
|
|
672
|
-
|
|
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
|
+
|
|
673
757
|
return {
|
|
674
758
|
onToggle(checked) {
|
|
675
|
-
if (checked) for (let i = 0; i < 8; i++)
|
|
759
|
+
if (checked) for (let i = 0; i < 8; i++) spawn(rng.range(0, 1));
|
|
676
760
|
},
|
|
677
761
|
tick(ctx, dt, now, st) {
|
|
678
762
|
knobX = lerp(knobX, st.toggled ? st.w - 18 : 18, dt * 12);
|
|
@@ -683,24 +767,27 @@ export function CircuitToggle({ seed = 42 } = {}) {
|
|
|
683
767
|
|
|
684
768
|
// Circuit path
|
|
685
769
|
ctx.strokeStyle = st.toggled ? '#22d3ee' : 'rgba(255,255,255,.06)';
|
|
686
|
-
ctx.lineWidth = 2; ctx.setLineDash(
|
|
770
|
+
ctx.lineWidth = 2; ctx.setLineDash(DASH_FLOW); ctx.lineDashOffset = -flowT * 16;
|
|
687
771
|
ctx.beginPath(); ctx.moveTo(8, st.h / 2); ctx.lineTo(st.w - 8, st.h / 2); ctx.stroke();
|
|
688
|
-
ctx.setLineDash(
|
|
772
|
+
ctx.setLineDash(DASH_NONE); ctx.lineDashOffset = 0;
|
|
689
773
|
|
|
690
|
-
// Flowing sparks
|
|
774
|
+
// Flowing sparks -- fixed pool, per-spark alpha via globalAlpha
|
|
691
775
|
if (st.toggled) {
|
|
692
776
|
ctx.fillStyle = '#22d3ee';
|
|
693
|
-
|
|
694
|
-
|
|
777
|
+
let alive = 0;
|
|
778
|
+
for (let i = 0; i < SPARKS; i++) {
|
|
779
|
+
const s = spark[i];
|
|
780
|
+
if (s.life <= 0) continue;
|
|
695
781
|
s.t += s.speed * dt; s.life -= dt * 0.5;
|
|
696
|
-
if (s.life <= 0 || s.t > 1) {
|
|
782
|
+
if (s.life <= 0 || s.t > 1) { s.life = 0; continue; }
|
|
783
|
+
alive++;
|
|
697
784
|
const sx = 8 + s.t * (st.w - 16);
|
|
698
785
|
ctx.globalAlpha = s.life;
|
|
699
786
|
ctx.beginPath(); ctx.arc(sx, st.h / 2, 2, 0, Math.PI * 2); ctx.fill();
|
|
700
787
|
}
|
|
701
788
|
ctx.globalAlpha = 1;
|
|
702
789
|
// Continuous spawn
|
|
703
|
-
if (
|
|
790
|
+
if (alive < 12) spawn(0);
|
|
704
791
|
}
|
|
705
792
|
|
|
706
793
|
ctx.fillStyle = st.toggled ? '#22d3ee' : '#9999b8';
|
|
@@ -762,10 +849,20 @@ export function DNAToggle() {
|
|
|
762
849
|
const t = i / 20, x = 6 + t * (st.w - 12);
|
|
763
850
|
const y1 = cy + Math.sin(phase + t * Math.PI * 3) * 8;
|
|
764
851
|
const y2 = cy - Math.sin(phase + t * Math.PI * 3) * 8;
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
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
|
+
}
|
|
769
866
|
if (i % 3 === 0) {
|
|
770
867
|
ctx.strokeStyle = 'rgba(255,255,255,.04)'; ctx.lineWidth = 0.5;
|
|
771
868
|
ctx.beginPath(); ctx.moveTo(x, y1); ctx.lineTo(x, y2); ctx.stroke();
|
|
@@ -788,14 +885,25 @@ export function DNAToggle() {
|
|
|
788
885
|
/** 5. Heartbeat Button -- Heart icon pumps with particle burst on click. */
|
|
789
886
|
export function HeartbeatButton({ seed = 42 } = {}) {
|
|
790
887
|
const rng = new Random(seed);
|
|
791
|
-
|
|
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
|
+
}
|
|
792
903
|
return {
|
|
793
904
|
onClick(x, y) {
|
|
794
905
|
scale = 1.3;
|
|
795
|
-
for (let i = 0; i < 12; i++)
|
|
796
|
-
const a = rng.range(0, Math.PI * 2), v = rng.range(40, 120);
|
|
797
|
-
particles.push({ x: x || 80, y: y || 24, vx: Math.cos(a) * v, vy: Math.sin(a) * v, life: 1 });
|
|
798
|
-
}
|
|
906
|
+
for (let i = 0; i < 12; i++) spawn(x || 80, y || 24);
|
|
799
907
|
},
|
|
800
908
|
tick(ctx, dt, now, st) {
|
|
801
909
|
scale = lerp(scale, 1, dt * 8);
|
|
@@ -815,15 +923,18 @@ export function HeartbeatButton({ seed = 42 } = {}) {
|
|
|
815
923
|
ctx.bezierCurveTo(20, -2, 10, -6, 0, 4);
|
|
816
924
|
ctx.fill(); ctx.restore();
|
|
817
925
|
|
|
818
|
-
// Particles
|
|
926
|
+
// Particles -- fixed pool, const color, alpha via globalAlpha
|
|
819
927
|
ctx.globalCompositeOperation = 'screen';
|
|
820
|
-
|
|
821
|
-
|
|
928
|
+
ctx.fillStyle = '#ff3c64';
|
|
929
|
+
for (let i = 0; i < PARTS; i++) {
|
|
930
|
+
const p = part[i];
|
|
931
|
+
if (p.life <= 0) continue;
|
|
822
932
|
p.x += p.vx * dt; p.y += p.vy * dt; p.life -= 2 * dt;
|
|
823
|
-
if (p.life <= 0)
|
|
824
|
-
ctx.
|
|
933
|
+
if (p.life <= 0) continue;
|
|
934
|
+
ctx.globalAlpha = p.life;
|
|
825
935
|
ctx.beginPath(); ctx.arc(p.x, p.y, 2, 0, Math.PI * 2); ctx.fill();
|
|
826
936
|
}
|
|
937
|
+
ctx.globalAlpha = 1;
|
|
827
938
|
ctx.globalCompositeOperation = 'source-over';
|
|
828
939
|
if (st.focused) focusRing(ctx, st.w, st.h, 10);
|
|
829
940
|
},
|
|
@@ -832,6 +943,13 @@ export function HeartbeatButton({ seed = 42 } = {}) {
|
|
|
832
943
|
|
|
833
944
|
/** 6. Breathing Button -- Inhale/exhale pulse with particle halo. */
|
|
834
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]; }
|
|
835
953
|
let phase = 0;
|
|
836
954
|
return {
|
|
837
955
|
tick(ctx, dt, now, st) {
|
|
@@ -840,13 +958,13 @@ export function BreathingButton() {
|
|
|
840
958
|
const radius = 4 + breath * 6;
|
|
841
959
|
|
|
842
960
|
// Outer glow
|
|
843
|
-
ctx.shadowBlur = 10 + breath * 15; ctx.shadowColor =
|
|
844
|
-
ctx.fillStyle =
|
|
961
|
+
ctx.shadowBlur = 10 + breath * 15; ctx.shadowColor = greenA(0.2 + breath * 0.3);
|
|
962
|
+
ctx.fillStyle = greenA(0.05 + breath * 0.08);
|
|
845
963
|
roundRect(ctx, 0, 0, st.w, st.h, 10); ctx.fill();
|
|
846
964
|
ctx.shadowBlur = 0;
|
|
847
965
|
|
|
848
966
|
// Halo ring
|
|
849
|
-
ctx.strokeStyle =
|
|
967
|
+
ctx.strokeStyle = greenA(0.1 + breath * 0.2);
|
|
850
968
|
ctx.lineWidth = 1;
|
|
851
969
|
ctx.beginPath(); ctx.arc(st.w / 2, st.h / 2, st.w / 2 + radius, 0, Math.PI * 2); ctx.stroke();
|
|
852
970
|
|
|
@@ -861,15 +979,26 @@ export function BreathingButton() {
|
|
|
861
979
|
/** 7. Ink Splash Button -- Calligraphy ink splatter on click. */
|
|
862
980
|
export function InkSplashButton({ seed = 42 } = {}) {
|
|
863
981
|
const rng = new Random(seed);
|
|
864
|
-
|
|
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
|
+
}
|
|
865
997
|
return {
|
|
866
998
|
onClick(x, y) {
|
|
867
999
|
pressScale = 0.88;
|
|
868
1000
|
const cx = x || 80, cy = y || 24;
|
|
869
|
-
for (let i = 0; i < 20; i++)
|
|
870
|
-
const a = rng.range(0, Math.PI * 2), d = rng.range(5, 35);
|
|
871
|
-
splats.push({ x: cx + Math.cos(a) * d, y: cy + Math.sin(a) * d, r: rng.range(2, 8), life: 1 });
|
|
872
|
-
}
|
|
1001
|
+
for (let i = 0; i < 20; i++) spawn(cx, cy);
|
|
873
1002
|
},
|
|
874
1003
|
tick(ctx, dt, now, st) {
|
|
875
1004
|
pressScale = lerp(pressScale, 1, dt * 8);
|
|
@@ -880,12 +1009,17 @@ export function InkSplashButton({ seed = 42 } = {}) {
|
|
|
880
1009
|
ctx.fillText('INK', st.w / 2, st.h / 2);
|
|
881
1010
|
ctx.restore();
|
|
882
1011
|
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
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;
|
|
887
1020
|
ctx.beginPath(); ctx.arc(s.x, s.y, s.r * easeOut(1 - s.life), 0, Math.PI * 2); ctx.fill();
|
|
888
1021
|
}
|
|
1022
|
+
ctx.globalAlpha = 1;
|
|
889
1023
|
if (st.focused) focusRing(ctx, st.w, st.h, 10);
|
|
890
1024
|
},
|
|
891
1025
|
};
|
|
@@ -941,12 +1075,36 @@ export function PixelDissolveButton({ seed = 42, cols = 16, rows = 5 } = {}) {
|
|
|
941
1075
|
/** 9. Firework Button -- Shoots fireworks upward on click. */
|
|
942
1076
|
export function FireworkButton({ seed = 42 } = {}) {
|
|
943
1077
|
const rng = new Random(seed);
|
|
944
|
-
let rockets = [], sparks = [], pressScale = 1;
|
|
945
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
|
+
|
|
946
1104
|
return {
|
|
947
1105
|
onClick(x, y, st) {
|
|
948
1106
|
pressScale = 0.88;
|
|
949
|
-
|
|
1107
|
+
spawnRocket(x || st.w / 2, st.h);
|
|
950
1108
|
},
|
|
951
1109
|
tick(ctx, dt, now, st) {
|
|
952
1110
|
pressScale = lerp(pressScale, 1, dt * 10);
|
|
@@ -956,24 +1114,25 @@ export function FireworkButton({ seed = 42 } = {}) {
|
|
|
956
1114
|
ctx.fillText('\u{1F386} FIRE', st.w / 2, st.h / 2);
|
|
957
1115
|
ctx.restore();
|
|
958
1116
|
|
|
959
|
-
// Rockets
|
|
960
|
-
for (let i =
|
|
961
|
-
const r =
|
|
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;
|
|
962
1122
|
ctx.fillStyle = r.color; ctx.beginPath(); ctx.arc(r.x, r.y, 3, 0, Math.PI * 2); ctx.fill();
|
|
963
1123
|
if (r.life <= 0 || r.y < -20) {
|
|
964
|
-
for (let j = 0; j < 20; j++)
|
|
965
|
-
|
|
966
|
-
sparks.push({ x: r.x, y: r.y, vx: Math.cos(a) * v, vy: Math.sin(a) * v, life: 1, color: r.color });
|
|
967
|
-
}
|
|
968
|
-
rockets.splice(i, 1);
|
|
1124
|
+
for (let j = 0; j < 20; j++) spawnSpark(r.x, r.y, r.color);
|
|
1125
|
+
r.life = 0;
|
|
969
1126
|
}
|
|
970
1127
|
}
|
|
971
1128
|
|
|
972
|
-
// Sparks
|
|
1129
|
+
// Sparks -- fixed pool, const color per spark, alpha via globalAlpha
|
|
973
1130
|
ctx.globalCompositeOperation = 'screen';
|
|
974
|
-
for (let i =
|
|
975
|
-
const s =
|
|
976
|
-
if (s.life <= 0)
|
|
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;
|
|
977
1136
|
ctx.fillStyle = s.color; ctx.globalAlpha = s.life;
|
|
978
1137
|
ctx.beginPath(); ctx.arc(s.x, s.y, 1.5, 0, Math.PI * 2); ctx.fill();
|
|
979
1138
|
}
|
|
@@ -990,24 +1149,40 @@ export function FireworkButton({ seed = 42 } = {}) {
|
|
|
990
1149
|
|
|
991
1150
|
/** 10. Aurora Slider -- Northern lights colors flow along the filled track. */
|
|
992
1151
|
export function AuroraSlider() {
|
|
993
|
-
|
|
1152
|
+
const REF = 100, PHASES = 12;
|
|
994
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 = [];
|
|
995
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
|
+
},
|
|
996
1171
|
tick(ctx, dt, now, st) {
|
|
997
1172
|
time += dt;
|
|
998
1173
|
ctx.fillStyle = 'rgba(255,255,255,.06)'; roundRect(ctx, 0, 12, st.w, 4, 2); ctx.fill();
|
|
999
1174
|
const tx = st.val * st.w;
|
|
1000
1175
|
if (tx > 2) {
|
|
1001
|
-
const
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
ctx.
|
|
1007
|
-
|
|
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;
|
|
1008
1183
|
}
|
|
1009
1184
|
ctx.fillStyle = '#fff'; ctx.beginPath(); ctx.arc(tx, 14, 8, 0, Math.PI * 2); ctx.fill();
|
|
1010
|
-
label(ctx,
|
|
1185
|
+
label(ctx, PCT[Math.round(st.val * 100)], st.w / 2, st.h + 10);
|
|
1011
1186
|
if (st.focused) focusRing(ctx, st.w, st.h, 4);
|
|
1012
1187
|
},
|
|
1013
1188
|
};
|
|
@@ -1029,7 +1204,7 @@ export function WaveSlider({ seed = 42 } = {}) {
|
|
|
1029
1204
|
ctx.fillRect(bx + 1, 14 - h / 2, bw - 2, h);
|
|
1030
1205
|
}
|
|
1031
1206
|
ctx.fillStyle = '#f472b6'; ctx.beginPath(); ctx.arc(tx, 14, 8, 0, Math.PI * 2); ctx.fill();
|
|
1032
|
-
label(ctx,
|
|
1207
|
+
label(ctx, PCT[Math.round(st.val * 100)], st.w / 2, st.h + 10);
|
|
1033
1208
|
if (st.focused) focusRing(ctx, st.w, st.h, 4);
|
|
1034
1209
|
},
|
|
1035
1210
|
};
|
|
@@ -1053,7 +1228,7 @@ export function ElasticBandSlider() {
|
|
|
1053
1228
|
ctx.stroke();
|
|
1054
1229
|
|
|
1055
1230
|
ctx.fillStyle = '#fb923c'; ctx.beginPath(); ctx.arc(tx, 14, 9, 0, Math.PI * 2); ctx.fill();
|
|
1056
|
-
label(ctx,
|
|
1231
|
+
label(ctx, PCT[Math.round(st.val * 100)], st.w / 2, st.h + 10);
|
|
1057
1232
|
if (st.focused) focusRing(ctx, st.w, st.h, 4);
|
|
1058
1233
|
},
|
|
1059
1234
|
};
|
|
@@ -1081,7 +1256,7 @@ export function GravitySlider() {
|
|
|
1081
1256
|
ctx.fillStyle = '#c084fc'; ctx.beginPath();
|
|
1082
1257
|
ctx.arc(tx, 14 + sag * Math.sin(Math.PI * st.val), 10, 0, Math.PI * 2); ctx.fill();
|
|
1083
1258
|
|
|
1084
|
-
label(ctx,
|
|
1259
|
+
label(ctx, PCT[Math.round(st.val * 100)], st.w / 2, st.h + 14);
|
|
1085
1260
|
if (st.focused) focusRing(ctx, st.w, st.h + 8, 4);
|
|
1086
1261
|
},
|
|
1087
1262
|
};
|
|
@@ -1105,14 +1280,15 @@ export function OrbitLoader() {
|
|
|
1105
1280
|
if (st.toggled) phase += dt;
|
|
1106
1281
|
const cx = st.w / 2, cy = st.h / 2;
|
|
1107
1282
|
|
|
1108
|
-
// Orbits
|
|
1109
|
-
planets.
|
|
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];
|
|
1110
1286
|
ctx.strokeStyle = 'rgba(255,255,255,.04)'; ctx.lineWidth = 0.5;
|
|
1111
1287
|
ctx.beginPath(); ctx.arc(cx, cy, p.r, 0, Math.PI * 2); ctx.stroke();
|
|
1112
1288
|
const a = phase * p.speed;
|
|
1113
1289
|
ctx.fillStyle = p.color;
|
|
1114
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();
|
|
1115
|
-
}
|
|
1291
|
+
}
|
|
1116
1292
|
|
|
1117
1293
|
// Sun
|
|
1118
1294
|
ctx.fillStyle = '#fbbf24'; ctx.beginPath(); ctx.arc(cx, cy, 5, 0, Math.PI * 2); ctx.fill();
|
|
@@ -1136,14 +1312,17 @@ export function HelixLoader() {
|
|
|
1136
1312
|
const x2 = cx - Math.cos(a) * 16, y2 = cy - Math.sin(a) * 6;
|
|
1137
1313
|
const depth = (Math.sin(a) + 1) / 2;
|
|
1138
1314
|
|
|
1139
|
-
|
|
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;
|
|
1140
1318
|
ctx.beginPath(); ctx.arc(x1, y1, 2 + depth, 0, Math.PI * 2); ctx.fill();
|
|
1141
|
-
ctx.fillStyle =
|
|
1319
|
+
ctx.fillStyle = '#f472b6'; ctx.globalAlpha = 0.2 + (1 - depth) * 0.6;
|
|
1142
1320
|
ctx.beginPath(); ctx.arc(x2, y2, 2 + (1 - depth), 0, Math.PI * 2); ctx.fill();
|
|
1143
1321
|
|
|
1144
|
-
ctx.strokeStyle =
|
|
1322
|
+
ctx.strokeStyle = '#ffffff'; ctx.globalAlpha = 0.03 + depth * 0.04;
|
|
1145
1323
|
ctx.lineWidth = 0.5; ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
|
|
1146
1324
|
}
|
|
1325
|
+
ctx.globalAlpha = 1;
|
|
1147
1326
|
label(ctx, st.toggled ? 'PROCESSING' : 'IDLE', st.w / 2, st.h + 14, st.toggled ? '#c084fc' : '#8888aa');
|
|
1148
1327
|
if (st.focused) focusRing(ctx, st.w, st.h, st.h / 2);
|
|
1149
1328
|
},
|
|
@@ -1157,9 +1336,18 @@ export function HelixLoader() {
|
|
|
1157
1336
|
|
|
1158
1337
|
/** 16. Ripple Checkbox -- Material-style ripple ring + morphing checkmark. */
|
|
1159
1338
|
export function RippleCheck() {
|
|
1160
|
-
|
|
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
|
+
}
|
|
1161
1349
|
return {
|
|
1162
|
-
onToggle(checked) { if (checked)
|
|
1350
|
+
onToggle(checked) { if (checked) spawn(); },
|
|
1163
1351
|
tick(ctx, dt, now, st) {
|
|
1164
1352
|
checkT = lerp(checkT, st.toggled ? 1 : 0, dt * 10);
|
|
1165
1353
|
const sz = Math.min(st.w, st.h), cx = sz / 2, cy = sz / 2;
|
|
@@ -1168,13 +1356,16 @@ export function RippleCheck() {
|
|
|
1168
1356
|
ctx.fillStyle = st.toggled ? '#6ee7b6' : 'rgba(255,255,255,.06)';
|
|
1169
1357
|
roundRect(ctx, 0, 0, sz, sz, 6); ctx.fill();
|
|
1170
1358
|
|
|
1171
|
-
// Ripples
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
if (
|
|
1175
|
-
|
|
1176
|
-
|
|
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();
|
|
1177
1367
|
}
|
|
1368
|
+
ctx.globalAlpha = 1;
|
|
1178
1369
|
|
|
1179
1370
|
// Checkmark (animated draw)
|
|
1180
1371
|
if (checkT > 0.01) {
|
|
@@ -1201,8 +1392,15 @@ export function MorphCheck() {
|
|
|
1201
1392
|
t = lerp(t, st.toggled ? 1 : 0, dt * 8);
|
|
1202
1393
|
const sz = Math.min(st.w, st.h), cx = sz / 2, cy = sz / 2;
|
|
1203
1394
|
|
|
1204
|
-
|
|
1205
|
-
|
|
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
|
+
}
|
|
1206
1404
|
|
|
1207
1405
|
ctx.strokeStyle = t > 0.5 ? '#38bdf8' : '#9999b8'; ctx.lineWidth = 2.5; ctx.lineCap = 'round';
|
|
1208
1406
|
|
|
@@ -1241,34 +1439,50 @@ export function MorphCheck() {
|
|
|
1241
1439
|
/** 18. Flame Counter -- Number with rising heat particles driven by slider value. */
|
|
1242
1440
|
export function FlameCounter({ seed = 42 } = {}) {
|
|
1243
1441
|
const rng = new Random(seed);
|
|
1244
|
-
|
|
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
|
+
}
|
|
1245
1457
|
return {
|
|
1246
1458
|
tick(ctx, dt, now, st) {
|
|
1247
1459
|
const val = Math.round(st.val * 999);
|
|
1460
|
+
if (val !== lastNum) { lastNum = val; numStr = String(val); }
|
|
1248
1461
|
|
|
1249
|
-
// Spawn embers proportional to value
|
|
1250
|
-
if (st.val > 0.1
|
|
1251
|
-
embers.push({ x: rng.range(20, st.w - 20), y: st.h - 5, vy: -rng.range(20, 60), life: 1, size: rng.range(1, 3) });
|
|
1252
|
-
}
|
|
1462
|
+
// Spawn embers proportional to value (pool self-caps at EMBERS)
|
|
1463
|
+
if (st.val > 0.1) spawn(st);
|
|
1253
1464
|
|
|
1254
1465
|
// Background
|
|
1255
1466
|
ctx.fillStyle = 'rgba(255,255,255,.03)'; roundRect(ctx, 0, 0, st.w, st.h, 10); ctx.fill();
|
|
1256
1467
|
|
|
1257
|
-
// Embers
|
|
1468
|
+
// Embers -- fixed pool, heat color via LUT, alpha via globalAlpha
|
|
1258
1469
|
ctx.globalCompositeOperation = 'screen';
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
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;
|
|
1264
1477
|
ctx.beginPath(); ctx.arc(e.x, e.y, e.size, 0, Math.PI * 2); ctx.fill();
|
|
1265
1478
|
}
|
|
1479
|
+
ctx.globalAlpha = 1;
|
|
1266
1480
|
ctx.globalCompositeOperation = 'source-over';
|
|
1267
1481
|
|
|
1268
1482
|
// Number
|
|
1269
1483
|
ctx.fillStyle = st.val > 0.7 ? '#ff6b6b' : st.val > 0.3 ? '#fbbf24' : '#9999b8';
|
|
1270
1484
|
ctx.font = "700 28px 'JetBrains Mono',monospace"; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
|
1271
|
-
ctx.fillText(
|
|
1485
|
+
ctx.fillText(numStr, st.w / 2, st.h / 2);
|
|
1272
1486
|
|
|
1273
1487
|
label(ctx, 'HEAT', st.w / 2, st.h + 12);
|
|
1274
1488
|
if (st.focused) focusRing(ctx, st.w, st.h, 10);
|
|
@@ -1279,17 +1493,20 @@ export function FlameCounter({ seed = 42 } = {}) {
|
|
|
1279
1493
|
/** 19. Glitch Counter -- Number glitches and jitters as slider value increases. */
|
|
1280
1494
|
export function GlitchCounter({ seed = 42 } = {}) {
|
|
1281
1495
|
const rng = new Random(seed);
|
|
1496
|
+
let numStr = '0', lastNum = -1;
|
|
1282
1497
|
return {
|
|
1283
1498
|
tick(ctx, dt, now, st) {
|
|
1284
1499
|
const val = Math.round(st.val * 999);
|
|
1500
|
+
if (val !== lastNum) { lastNum = val; numStr = String(val); }
|
|
1285
1501
|
const intensity = st.val;
|
|
1286
1502
|
|
|
1287
1503
|
ctx.fillStyle = 'rgba(255,255,255,.03)'; roundRect(ctx, 0, 0, st.w, st.h, 10); ctx.fill();
|
|
1288
1504
|
|
|
1289
|
-
// Scanlines
|
|
1505
|
+
// Scanlines -- const white, alpha via globalAlpha
|
|
1290
1506
|
if (intensity > 0.2) {
|
|
1291
|
-
ctx.fillStyle =
|
|
1507
|
+
ctx.fillStyle = '#ffffff'; ctx.globalAlpha = intensity * 0.03;
|
|
1292
1508
|
for (let y = 0; y < st.h; y += 3) ctx.fillRect(0, y, st.w, 1);
|
|
1509
|
+
ctx.globalAlpha = 1;
|
|
1293
1510
|
}
|
|
1294
1511
|
|
|
1295
1512
|
// Glitched number
|
|
@@ -1298,17 +1515,20 @@ export function GlitchCounter({ seed = 42 } = {}) {
|
|
|
1298
1515
|
|
|
1299
1516
|
if (intensity > 0.3 && rng.next() > 0.6) {
|
|
1300
1517
|
ctx.globalCompositeOperation = 'screen';
|
|
1301
|
-
ctx.fillStyle = `rgba(255,0,85,${intensity * 0.4})`;
|
|
1302
1518
|
ctx.font = "700 28px 'JetBrains Mono',monospace"; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
|
1303
|
-
|
|
1304
|
-
ctx.
|
|
1305
|
-
ctx.
|
|
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;
|
|
1306
1526
|
ctx.globalCompositeOperation = 'source-over';
|
|
1307
1527
|
}
|
|
1308
1528
|
|
|
1309
1529
|
ctx.fillStyle = '#e2e2f0';
|
|
1310
1530
|
ctx.font = "700 28px 'JetBrains Mono',monospace"; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
|
1311
|
-
ctx.fillText(
|
|
1531
|
+
ctx.fillText(numStr, st.w / 2 + jx, st.h / 2 + jy);
|
|
1312
1532
|
|
|
1313
1533
|
label(ctx, 'SIGNAL', st.w / 2, st.h + 12);
|
|
1314
1534
|
if (st.focused) focusRing(ctx, st.w, st.h, 10);
|
|
@@ -1324,9 +1544,27 @@ export function GlitchCounter({ seed = 42 } = {}) {
|
|
|
1324
1544
|
/** 20. Bubble Rating -- 5 bubbles inflate based on slider position. Click pops them. */
|
|
1325
1545
|
export function BubbleRating({ seed = 42 } = {}) {
|
|
1326
1546
|
const rng = new Random(seed);
|
|
1327
|
-
|
|
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
|
+
}
|
|
1328
1565
|
return {
|
|
1329
1566
|
tick(ctx, dt, now, st) {
|
|
1567
|
+
lastW = st.w;
|
|
1330
1568
|
const rating = Math.round(st.val * 5);
|
|
1331
1569
|
const gap = st.w / 5;
|
|
1332
1570
|
|
|
@@ -1336,9 +1574,11 @@ export function BubbleRating({ seed = 42 } = {}) {
|
|
|
1336
1574
|
sizes[i] = lerp(sizes[i] || 6, targetSz, dt * 8);
|
|
1337
1575
|
const cx = gap * i + gap / 2, cy = st.h / 2;
|
|
1338
1576
|
|
|
1339
|
-
// Bubble
|
|
1340
|
-
ctx.fillStyle =
|
|
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; }
|
|
1341
1580
|
ctx.beginPath(); ctx.arc(cx, cy, sizes[i], 0, Math.PI * 2); ctx.fill();
|
|
1581
|
+
ctx.globalAlpha = 1;
|
|
1342
1582
|
ctx.strokeStyle = active ? '#38bdf8' : 'rgba(255,255,255,.08)';
|
|
1343
1583
|
ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(cx, cy, sizes[i], 0, Math.PI * 2); ctx.stroke();
|
|
1344
1584
|
|
|
@@ -1349,25 +1589,27 @@ export function BubbleRating({ seed = 42 } = {}) {
|
|
|
1349
1589
|
}
|
|
1350
1590
|
}
|
|
1351
1591
|
|
|
1352
|
-
// Pop particles
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
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();
|
|
1357
1601
|
}
|
|
1602
|
+
ctx.globalAlpha = 1;
|
|
1358
1603
|
|
|
1359
|
-
label(ctx,
|
|
1604
|
+
label(ctx, R5[rating], st.w / 2, st.h + 12, rating >= 4 ? '#38bdf8' : '#9999b8');
|
|
1360
1605
|
if (st.focused) focusRing(ctx, st.w, st.h, 4);
|
|
1361
1606
|
},
|
|
1362
1607
|
onDrag(val) {
|
|
1363
1608
|
const rating = Math.round(val * 5);
|
|
1364
|
-
// Pop the newly activated bubble
|
|
1365
|
-
const gap =
|
|
1609
|
+
// Pop the newly activated bubble (position derived from st.w via lastW)
|
|
1610
|
+
const gap = lastW / 5;
|
|
1366
1611
|
const cx = gap * (rating - 1) + gap / 2;
|
|
1367
|
-
|
|
1368
|
-
const a = rng.range(0, Math.PI * 2), v = rng.range(20, 50);
|
|
1369
|
-
pops.push({ x: cx, y: 14, vx: Math.cos(a) * v, vy: Math.sin(a) * v, life: 1 });
|
|
1370
|
-
}
|
|
1612
|
+
spawnPops(cx);
|
|
1371
1613
|
},
|
|
1372
1614
|
};
|
|
1373
1615
|
}
|
|
@@ -1379,8 +1621,14 @@ export function BubbleRating({ seed = 42 } = {}) {
|
|
|
1379
1621
|
|
|
1380
1622
|
/** 1. Volume Knob -- Rotary dial with tick marks and arc indicator. */
|
|
1381
1623
|
export function VolumeKnob() {
|
|
1382
|
-
let displayVal = 0;
|
|
1624
|
+
let displayVal = 0, grad = null;
|
|
1625
|
+
let numStr = '0', lastNum = -1;
|
|
1383
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
|
+
},
|
|
1384
1632
|
tick(c,dt,now,st) {
|
|
1385
1633
|
displayVal = lerp(displayVal, st.val, dt * 12);
|
|
1386
1634
|
const cx=st.w/2, cy=st.h/2, R=Math.min(cx,cy)-4;
|
|
@@ -1390,11 +1638,9 @@ export function VolumeKnob() {
|
|
|
1390
1638
|
c.strokeStyle='rgba(255,255,255,.06)'; c.lineWidth=4;
|
|
1391
1639
|
c.beginPath(); c.arc(cx,cy,R,startA,endA); c.stroke();
|
|
1392
1640
|
|
|
1393
|
-
// Value arc
|
|
1641
|
+
// Value arc (gradient built once in init)
|
|
1394
1642
|
const valA = startA + displayVal * range;
|
|
1395
|
-
|
|
1396
|
-
grad.addColorStop(0,'#6ee7b6'); grad.addColorStop(1,'#38bdf8');
|
|
1397
|
-
c.strokeStyle=grad; c.lineWidth=4;
|
|
1643
|
+
c.strokeStyle = grad || '#38bdf8'; c.lineWidth=4;
|
|
1398
1644
|
c.beginPath(); c.arc(cx,cy,R,startA,valA); c.stroke();
|
|
1399
1645
|
|
|
1400
1646
|
// Tick marks
|
|
@@ -1416,9 +1662,11 @@ export function VolumeKnob() {
|
|
|
1416
1662
|
c.fillStyle='rgba(255,255,255,.04)';
|
|
1417
1663
|
c.beginPath(); c.arc(cx,cy,R*0.4,0,PI2); c.fill();
|
|
1418
1664
|
|
|
1419
|
-
// Value text
|
|
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); }
|
|
1420
1668
|
c.fillStyle='#e2e2f0'; c.font="700 14px 'JetBrains Mono',monospace"; c.textAlign='center'; c.textBaseline='middle';
|
|
1421
|
-
c.fillText(
|
|
1669
|
+
c.fillText(numStr,cx,cy);
|
|
1422
1670
|
lbl(c,'VOLUME',cx,st.h+10);
|
|
1423
1671
|
if(st.focused)fr(c,st.w,st.h,st.h/2);
|
|
1424
1672
|
},
|
|
@@ -1427,7 +1675,10 @@ export function VolumeKnob() {
|
|
|
1427
1675
|
|
|
1428
1676
|
/** 2. Compass Knob -- Needle points based on slider value (0=N, 0.5=S, 1=N). */
|
|
1429
1677
|
export function CompassKnob() {
|
|
1430
|
-
|
|
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;
|
|
1431
1682
|
return {
|
|
1432
1683
|
tick(c,dt,now,st) {
|
|
1433
1684
|
const targetA = st.val * PI2;
|
|
@@ -1439,7 +1690,6 @@ export function CompassKnob() {
|
|
|
1439
1690
|
c.beginPath(); c.arc(cx,cy,R,0,PI2); c.stroke();
|
|
1440
1691
|
|
|
1441
1692
|
// Cardinal marks
|
|
1442
|
-
const dirs=['N','E','S','W'], cols=['#ff6b6b','#9999b8','#9999b8','#9999b8'];
|
|
1443
1693
|
for(let i=0;i<4;i++){
|
|
1444
1694
|
const a=-Math.PI/2+i*Math.PI/2;
|
|
1445
1695
|
c.fillStyle=cols[i]; c.font="600 9px 'JetBrains Mono',monospace"; c.textAlign='center'; c.textBaseline='middle';
|
|
@@ -1458,7 +1708,9 @@ export function CompassKnob() {
|
|
|
1458
1708
|
c.fillStyle='#333'; c.beginPath(); c.arc(cx,cy,4,0,PI2); c.fill();
|
|
1459
1709
|
c.strokeStyle='rgba(255,255,255,.1)'; c.lineWidth=1; c.beginPath(); c.arc(cx,cy,4,0,PI2); c.stroke();
|
|
1460
1710
|
|
|
1461
|
-
|
|
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);
|
|
1462
1714
|
if(st.focused)fr(c,st.w,st.h,st.h/2);
|
|
1463
1715
|
},
|
|
1464
1716
|
};
|
|
@@ -1472,8 +1724,31 @@ export function CompassKnob() {
|
|
|
1472
1724
|
/** 3. Ring Progress -- Circular progress with animated fill and particles at the tip. */
|
|
1473
1725
|
export function RingProgress({seed=42}={}) {
|
|
1474
1726
|
const rng=new Random(seed);
|
|
1475
|
-
|
|
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
|
+
}
|
|
1476
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
|
+
},
|
|
1477
1752
|
tick(c,dt,now,st) {
|
|
1478
1753
|
displayVal=lerp(displayVal,st.val,dt*6);
|
|
1479
1754
|
const cx=st.w/2,cy=st.h/2,R=Math.min(cx,cy)-6,lw=6;
|
|
@@ -1482,27 +1757,30 @@ export function RingProgress({seed=42}={}) {
|
|
|
1482
1757
|
c.strokeStyle='rgba(255,255,255,.06)';c.lineWidth=lw;
|
|
1483
1758
|
c.beginPath();c.arc(cx,cy,R,0,PI2);c.stroke();
|
|
1484
1759
|
|
|
1485
|
-
// Fill arc
|
|
1760
|
+
// Fill arc (conic gradient from init; the green stop tracks the tip)
|
|
1486
1761
|
const a=-Math.PI/2,ea=a+displayVal*PI2;
|
|
1487
|
-
|
|
1488
|
-
g.addColorStop(0,'#a78bfa');g.addColorStop(displayVal*0.95,'#6ee7b6');g.addColorStop(1,'rgba(110,231,182,.2)');
|
|
1489
|
-
c.strokeStyle=g;c.lineWidth=lw;
|
|
1762
|
+
c.strokeStyle = grads[(displayVal * (GPHASES - 1)) | 0] || '#6ee7b6';c.lineWidth=lw;
|
|
1490
1763
|
c.beginPath();c.arc(cx,cy,R,a,ea);c.stroke();
|
|
1491
1764
|
|
|
1492
|
-
// Tip sparks
|
|
1765
|
+
// Tip sparks -- fixed pool, const color, alpha via globalAlpha
|
|
1493
1766
|
if(displayVal>0.02){
|
|
1494
1767
|
const tx=cx+Math.cos(ea)*R,ty=cy+Math.sin(ea)*R;
|
|
1495
|
-
|
|
1768
|
+
spawn(tx, ty);
|
|
1496
1769
|
}
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
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();
|
|
1501
1778
|
}
|
|
1779
|
+
c.globalAlpha=1;
|
|
1502
1780
|
|
|
1503
1781
|
// Percentage
|
|
1504
1782
|
c.fillStyle='#e2e2f0';c.font="700 16px 'JetBrains Mono',monospace";c.textAlign='center';c.textBaseline='middle';
|
|
1505
|
-
c.fillText(Math.round(displayVal*100)
|
|
1783
|
+
c.fillText(PCT[Math.round(displayVal*100)],cx,cy);
|
|
1506
1784
|
if(st.focused)fr(c,st.w,st.h,st.h/2);
|
|
1507
1785
|
},
|
|
1508
1786
|
};
|
|
@@ -1543,7 +1821,7 @@ export function BatteryGauge() {
|
|
|
1543
1821
|
}
|
|
1544
1822
|
|
|
1545
1823
|
c.fillStyle='#e2e2f0';c.font="600 10px 'JetBrains Mono',monospace";c.textAlign='center';c.textBaseline='middle';
|
|
1546
|
-
c.fillText(Math.round(displayVal*100)
|
|
1824
|
+
c.fillText(PCT[Math.round(displayVal*100)],bx+bw/2,by+bh/2);
|
|
1547
1825
|
if(st.focused)fr(c,st.w+4,st.h,r);
|
|
1548
1826
|
},
|
|
1549
1827
|
};
|
|
@@ -1654,6 +1932,7 @@ export function Stepper() {
|
|
|
1654
1932
|
export function RadioOrbit() {
|
|
1655
1933
|
let selectedGlow=new Float32Array(4);
|
|
1656
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)
|
|
1657
1936
|
return {
|
|
1658
1937
|
tick(c,dt,now,st) {
|
|
1659
1938
|
const sel=Math.round(st.val*3);
|
|
@@ -1668,9 +1947,9 @@ export function RadioOrbit() {
|
|
|
1668
1947
|
const active=i===sel;
|
|
1669
1948
|
selectedGlow[i]=lerp(selectedGlow[i],active?1:0,dt*10);
|
|
1670
1949
|
|
|
1671
|
-
// Orbit line
|
|
1672
|
-
c.strokeStyle
|
|
1673
|
-
c.beginPath();c.moveTo(cx,cy);c.lineTo(ox,oy);c.stroke();
|
|
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;
|
|
1674
1953
|
|
|
1675
1954
|
// Node
|
|
1676
1955
|
const sz=6+selectedGlow[i]*4;
|
|
@@ -1681,7 +1960,7 @@ export function RadioOrbit() {
|
|
|
1681
1960
|
c.fillStyle=active?'#fff':'#9999b8';c.font="600 9px 'JetBrains Mono',monospace";c.textAlign='center';c.textBaseline='middle';
|
|
1682
1961
|
c.fillText(names[i],ox,oy);
|
|
1683
1962
|
}
|
|
1684
|
-
lbl(c,
|
|
1963
|
+
lbl(c,OPTS[sel],cx,st.h+10,colors[sel]);
|
|
1685
1964
|
if(st.focused)fr(c,st.w,st.h,4);
|
|
1686
1965
|
},
|
|
1687
1966
|
};
|
|
@@ -1752,7 +2031,7 @@ export function WaterLevel() {
|
|
|
1752
2031
|
c.restore();
|
|
1753
2032
|
|
|
1754
2033
|
c.fillStyle='#e2e2f0';c.font="700 12px 'JetBrains Mono',monospace";c.textAlign='center';c.textBaseline='middle';
|
|
1755
|
-
c.fillText(Math.round(displayVal*100)
|
|
2034
|
+
c.fillText(PCT[Math.round(displayVal*100)],st.w/2,st.h/2);
|
|
1756
2035
|
if(st.focused)fr(c,st.w,st.h,6);
|
|
1757
2036
|
},
|
|
1758
2037
|
};
|
|
@@ -1763,6 +2042,10 @@ export function HeatMap({seed=42}={}) {
|
|
|
1763
2042
|
const rng=new Random(seed);
|
|
1764
2043
|
const N=15,thresholds=new Float32Array(N);
|
|
1765
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)+')';
|
|
1766
2049
|
return {
|
|
1767
2050
|
init(){for(let i=0;i<N;i++)thresholds[i]=rng.range(0,1)},
|
|
1768
2051
|
tick(c,dt,now,st) {
|
|
@@ -1775,11 +2058,12 @@ export function HeatMap({seed=42}={}) {
|
|
|
1775
2058
|
const col=i%cols,row=(i/cols)|0;
|
|
1776
2059
|
const x=col*(cw+gap),y=row*(ch+gap);
|
|
1777
2060
|
const heat=vals[i];
|
|
1778
|
-
|
|
1779
|
-
c.fillStyle=
|
|
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;}
|
|
1780
2063
|
rr(c,x,y,cw,ch,3);c.fill();
|
|
2064
|
+
c.globalAlpha=1;
|
|
1781
2065
|
}
|
|
1782
|
-
lbl(c,Math.round(st.val*100)
|
|
2066
|
+
lbl(c,PCT[Math.round(st.val*100)],st.w/2,st.h+10);
|
|
1783
2067
|
if(st.focused)fr(c,st.w,st.h,3);
|
|
1784
2068
|
},
|
|
1785
2069
|
};
|
|
@@ -1793,23 +2077,31 @@ export function HeatMap({seed=42}={}) {
|
|
|
1793
2077
|
/** 12. Day/Night Toggle -- Sun/moon transition with star particles. */
|
|
1794
2078
|
export function DayNightToggle({seed=42}={}) {
|
|
1795
2079
|
const rng=new Random(seed);
|
|
1796
|
-
|
|
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;
|
|
1797
2087
|
return {
|
|
1798
|
-
init(ctx,w,h){for(let i=0;i<
|
|
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)}},
|
|
1799
2089
|
tick(c,dt,now,st) {
|
|
1800
2090
|
t=lerp(t,st.toggled?1:0,dt*6);
|
|
1801
|
-
const skyR=Math.round(lerp(135,10,t)),skyG=Math.round(lerp(206,10,t)),skyB=Math.round(lerp(250,30,t));
|
|
1802
2091
|
|
|
1803
|
-
// Sky background
|
|
1804
|
-
c.fillStyle
|
|
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();
|
|
1805
2094
|
|
|
1806
|
-
// Stars (night only)
|
|
2095
|
+
// Stars (night only) -- const white, per-star alpha via globalAlpha
|
|
1807
2096
|
if(t>.3){
|
|
1808
|
-
|
|
2097
|
+
c.fillStyle='#ffffff';
|
|
2098
|
+
for(let i=0;i<STARS;i++){
|
|
2099
|
+
const s=star[i];
|
|
1809
2100
|
const tw=Math.sin(now/400+s.twinkle)*.5+.5;
|
|
1810
|
-
c.
|
|
2101
|
+
c.globalAlpha=(t-.3)/.7*tw*.6;
|
|
1811
2102
|
c.fillRect(s.x,s.y,1,1);
|
|
1812
2103
|
}
|
|
2104
|
+
c.globalAlpha=1;
|
|
1813
2105
|
}
|
|
1814
2106
|
|
|
1815
2107
|
// Sun/Moon
|
|
@@ -1817,16 +2109,18 @@ export function DayNightToggle({seed=42}={}) {
|
|
|
1817
2109
|
if(t<.5){
|
|
1818
2110
|
// Sun
|
|
1819
2111
|
c.fillStyle='#fbbf24';c.beginPath();c.arc(orbX,st.h/2,10,0,PI2);c.fill();
|
|
1820
|
-
// Rays
|
|
1821
|
-
c.strokeStyle
|
|
2112
|
+
// Rays -- const color, alpha via globalAlpha
|
|
2113
|
+
c.strokeStyle='#fbbf24';c.globalAlpha=.3*(1-t*2);c.lineWidth=1;
|
|
1822
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;
|
|
1823
2116
|
} else {
|
|
1824
2117
|
// Moon
|
|
1825
2118
|
c.fillStyle='#d4d4e8';c.beginPath();c.arc(orbX,st.h/2,10,0,PI2);c.fill();
|
|
1826
|
-
// Crater shadows
|
|
1827
|
-
c.fillStyle
|
|
2119
|
+
// Crater shadows -- const black, alpha via globalAlpha
|
|
2120
|
+
c.fillStyle='#000000';c.globalAlpha=(t-.5)*2*.15;
|
|
1828
2121
|
c.beginPath();c.arc(orbX-3,st.h/2-2,3,0,PI2);c.fill();
|
|
1829
2122
|
c.beginPath();c.arc(orbX+4,st.h/2+3,2,0,PI2);c.fill();
|
|
2123
|
+
c.globalAlpha=1;
|
|
1830
2124
|
}
|
|
1831
2125
|
|
|
1832
2126
|
lbl(c,st.toggled?'NIGHT':'DAY',st.w/2,st.h+14,st.toggled?'#9999b8':'#fbbf24');
|
|
@@ -1839,6 +2133,9 @@ export function DayNightToggle({seed=42}={}) {
|
|
|
1839
2133
|
export function ReactionPicker() {
|
|
1840
2134
|
let sizes=new Float32Array(5), selected=-1;
|
|
1841
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
|
|
1842
2139
|
return {
|
|
1843
2140
|
tick(c,dt,now,st,ptr) {
|
|
1844
2141
|
const gap=st.w/5;
|
|
@@ -1850,11 +2147,11 @@ export function ReactionPicker() {
|
|
|
1850
2147
|
const cx=gap*i+gap/2,cy=st.h/2;
|
|
1851
2148
|
|
|
1852
2149
|
// Circle
|
|
1853
|
-
c.fillStyle=active
|
|
2150
|
+
c.fillStyle=active?colors30[i]:'rgba(255,255,255,.04)';
|
|
1854
2151
|
c.beginPath();c.arc(cx,cy-sizes[i]+10,sizes[i],0,PI2);c.fill();
|
|
1855
2152
|
|
|
1856
|
-
// Emoji face (simplified)
|
|
1857
|
-
c.font
|
|
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';
|
|
1858
2155
|
c.fillText(emojis[i],cx,cy-sizes[i]+10);
|
|
1859
2156
|
}
|
|
1860
2157
|
|
|
@@ -1865,7 +2162,7 @@ export function ReactionPicker() {
|
|
|
1865
2162
|
c.fillRect(sx-10,st.h-4,20,2);
|
|
1866
2163
|
}
|
|
1867
2164
|
|
|
1868
|
-
lbl(c,hoverIdx>=0?[
|
|
2165
|
+
lbl(c,hoverIdx>=0?REACT_LABELS[hoverIdx]:'REACT',st.w/2,st.h+10,hoverIdx>=0?colors[hoverIdx]:'#9999b8');
|
|
1869
2166
|
if(st.focused)fr(c,st.w,st.h,4);
|
|
1870
2167
|
},
|
|
1871
2168
|
};
|
|
@@ -1874,9 +2171,10 @@ export function ReactionPicker() {
|
|
|
1874
2171
|
/** 14. Notification Bell -- Bell icon with bounce and count badge. */
|
|
1875
2172
|
export function NotificationBell() {
|
|
1876
2173
|
let count=0, bellAngle=0, bellVel=0, badgeScale=0;
|
|
2174
|
+
let badgeStr='0'; // rebuilt only on click (count change), not per frame
|
|
1877
2175
|
return {
|
|
1878
2176
|
onClick() {
|
|
1879
|
-
count++;bellVel=6;badgeScale=1.5;
|
|
2177
|
+
count++;bellVel=6;badgeScale=1.5;badgeStr=count>99?'99+':String(count);
|
|
1880
2178
|
},
|
|
1881
2179
|
tick(c,dt,now,st) {
|
|
1882
2180
|
bellVel+=(0-bellAngle)*20*dt;bellVel*=0.9;bellAngle+=bellVel*dt;
|
|
@@ -1897,7 +2195,7 @@ export function NotificationBell() {
|
|
|
1897
2195
|
c.save();c.translate(bx,by);c.scale(badgeScale,badgeScale);
|
|
1898
2196
|
c.fillStyle='#ff6b6b';c.beginPath();c.arc(0,0,8,0,PI2);c.fill();
|
|
1899
2197
|
c.fillStyle='#fff';c.font="700 8px 'JetBrains Mono',monospace";c.textAlign='center';c.textBaseline='middle';
|
|
1900
|
-
c.fillText(
|
|
2198
|
+
c.fillText(badgeStr,0,0);
|
|
1901
2199
|
c.restore();
|
|
1902
2200
|
}
|
|
1903
2201
|
|
|
@@ -1916,6 +2214,7 @@ export function NotificationBell() {
|
|
|
1916
2214
|
export function TypewriterField() {
|
|
1917
2215
|
const text='Hello World';
|
|
1918
2216
|
let charIdx=0, timer=0, cursorBlink=0, typing=false;
|
|
2217
|
+
let display='', dispW=0, lastIdx=-1; // substring rebuilt only when a char lands
|
|
1919
2218
|
return {
|
|
1920
2219
|
onToggle(checked){typing=checked;if(checked){charIdx=0;timer=0}},
|
|
1921
2220
|
tick(c,dt,now,st) {
|
|
@@ -1925,14 +2224,14 @@ export function TypewriterField() {
|
|
|
1925
2224
|
c.fillStyle='rgba(255,255,255,.04)';rr(c,0,0,st.w,st.h,6);c.fill();
|
|
1926
2225
|
c.strokeStyle='rgba(255,255,255,.06)';c.lineWidth=1;rr(c,0,0,st.w,st.h,6);c.stroke();
|
|
1927
2226
|
|
|
1928
|
-
const display=text.substring(0,charIdx);
|
|
1929
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;}
|
|
1930
2230
|
c.fillText(display,8,st.h/2);
|
|
1931
2231
|
|
|
1932
2232
|
// Cursor
|
|
1933
2233
|
if(cursorBlink<1){
|
|
1934
|
-
|
|
1935
|
-
c.fillStyle='#6ee7b6';c.fillRect(9+tw,st.h/2-8,1.5,16);
|
|
2234
|
+
c.fillStyle='#6ee7b6';c.fillRect(9+dispW,st.h/2-8,1.5,16);
|
|
1936
2235
|
}
|
|
1937
2236
|
|
|
1938
2237
|
lbl(c,typing?'TYPING...':'TOGGLE TO TYPE',st.w/2,st.h+10,typing?'#6ee7b6':'#8888aa');
|
|
@@ -1994,7 +2293,7 @@ export function UploadProgress() {
|
|
|
1994
2293
|
// Fill
|
|
1995
2294
|
const fillH=fh*displayVal;
|
|
1996
2295
|
const col=displayVal>=1?'#6ee7b6':'#38bdf8';
|
|
1997
|
-
c.fillStyle
|
|
2296
|
+
c.fillStyle=displayVal>=1?'#6ee7b630':'#38bdf830'; // was `${col}30`
|
|
1998
2297
|
c.save();c.beginPath();c.rect(fx+1,fy+fh-fillH,fw-1,fillH);c.clip();
|
|
1999
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();
|
|
2000
2299
|
c.restore();
|
|
@@ -2009,7 +2308,7 @@ export function UploadProgress() {
|
|
|
2009
2308
|
}
|
|
2010
2309
|
c.lineCap='butt';
|
|
2011
2310
|
|
|
2012
|
-
lbl(c,displayVal>=.99?'DONE':Math.round(displayVal*100)
|
|
2311
|
+
lbl(c,displayVal>=.99?'DONE':PCT[Math.round(displayVal*100)],cx,st.h+10,displayVal>=.99?'#6ee7b6':'#9999b8');
|
|
2013
2312
|
if(st.focused)fr(c,st.w,st.h,4);
|
|
2014
2313
|
},
|
|
2015
2314
|
};
|
|
@@ -2023,29 +2322,45 @@ export function UploadProgress() {
|
|
|
2023
2322
|
/** 18. Scratch Reveal -- Drag to erase a mask and reveal the prize beneath. */
|
|
2024
2323
|
export function ScratchReveal({seed=42}={}) {
|
|
2025
2324
|
const rng=new Random(seed);
|
|
2026
|
-
|
|
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
|
+
}
|
|
2027
2338
|
return {
|
|
2028
2339
|
onDrag(val,vx) {
|
|
2029
2340
|
if(Math.abs(vx)>0.5){
|
|
2030
|
-
for(let i=0;i<3;i++)
|
|
2341
|
+
for(let i=0;i<3;i++)spawn(val);
|
|
2031
2342
|
revealed=Math.min(1,revealed+0.015);
|
|
2032
2343
|
}
|
|
2033
2344
|
},
|
|
2034
2345
|
tick(c,dt,now,st) {
|
|
2346
|
+
lastW=st.w;
|
|
2035
2347
|
// Prize background
|
|
2036
2348
|
c.fillStyle='rgba(110,231,182,.08)';rr(c,0,0,st.w,st.h,8);c.fill();
|
|
2037
2349
|
|
|
2038
|
-
// Prize text
|
|
2039
|
-
c.fillStyle
|
|
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';
|
|
2040
2352
|
c.fillText(revealed>.6?'\u{1F389} WINNER!':'? ? ?',st.w/2,st.h/2);
|
|
2353
|
+
c.globalAlpha=1;
|
|
2041
2354
|
|
|
2042
2355
|
// Scratch mask (gets holes)
|
|
2043
2356
|
if(revealed<.95){
|
|
2044
2357
|
c.fillStyle='rgba(40,40,60,.85)';rr(c,0,0,st.w,st.h,8);c.fill();
|
|
2045
2358
|
|
|
2046
|
-
// Cut holes
|
|
2359
|
+
// Cut holes -- fixed ring, indexed loop
|
|
2047
2360
|
c.globalCompositeOperation='destination-out';
|
|
2048
|
-
for(
|
|
2361
|
+
for(let i=0;i<SCR;i++){
|
|
2362
|
+
const s=scr[i];
|
|
2363
|
+
if(!s.live)continue;
|
|
2049
2364
|
c.beginPath();c.arc(s.x,s.y,s.r,0,PI2);c.fill();
|
|
2050
2365
|
}
|
|
2051
2366
|
c.globalCompositeOperation='source-over';
|
|
@@ -2065,6 +2380,7 @@ export function ScratchReveal({seed=42}={}) {
|
|
|
2065
2380
|
/** 19. Timer Countdown -- Circular countdown timer. Toggle starts/stops. */
|
|
2066
2381
|
export function TimerCountdown() {
|
|
2067
2382
|
let timeLeft=10, running=false, flashAlpha=0;
|
|
2383
|
+
let timeStr='10.0s', lastTenths=-1; // rebuilt at ~10 Hz, not per frame
|
|
2068
2384
|
return {
|
|
2069
2385
|
onToggle(checked) { running=checked; if(checked)timeLeft=10; },
|
|
2070
2386
|
tick(c,dt,now,st) {
|
|
@@ -2075,7 +2391,7 @@ export function TimerCountdown() {
|
|
|
2075
2391
|
const cx=st.w/2,cy=st.h/2,R=Math.min(cx,cy)-4;
|
|
2076
2392
|
|
|
2077
2393
|
// Flash bg when done
|
|
2078
|
-
if(flashAlpha>0){c.fillStyle
|
|
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;}
|
|
2079
2395
|
|
|
2080
2396
|
// Background ring
|
|
2081
2397
|
c.strokeStyle='rgba(255,255,255,.06)';c.lineWidth=4;c.beginPath();c.arc(cx,cy,R,0,PI2);c.stroke();
|
|
@@ -2087,8 +2403,10 @@ export function TimerCountdown() {
|
|
|
2087
2403
|
c.beginPath();c.arc(cx,cy,R,-Math.PI/2,-Math.PI/2+progress*PI2,false);c.stroke();
|
|
2088
2404
|
|
|
2089
2405
|
// Time text
|
|
2406
|
+
const tenths=Math.round(timeLeft*10);
|
|
2407
|
+
if(tenths!==lastTenths){lastTenths=tenths;timeStr=(tenths/10).toFixed(1)+'s';}
|
|
2090
2408
|
c.fillStyle='#e2e2f0';c.font="700 16px 'JetBrains Mono',monospace";c.textAlign='center';c.textBaseline='middle';
|
|
2091
|
-
c.fillText(
|
|
2409
|
+
c.fillText(timeStr,cx,cy);
|
|
2092
2410
|
|
|
2093
2411
|
lbl(c,running?(timeLeft>0?'RUNNING':'TIME UP!'):'TOGGLE TO START',cx,st.h+10,running?col:'#8888aa');
|
|
2094
2412
|
if(st.focused)fr(c,st.w,st.h,R);
|
|
@@ -2117,9 +2435,10 @@ export function PullRefresh() {
|
|
|
2117
2435
|
// Spinner arcs
|
|
2118
2436
|
for(let i=0;i<3;i++){
|
|
2119
2437
|
const a=spinAngle+i*PI2/3;
|
|
2120
|
-
c.strokeStyle
|
|
2438
|
+
c.strokeStyle='#38bdf8';c.globalAlpha=.3+i*.2;c.lineWidth=3;
|
|
2121
2439
|
c.beginPath();c.arc(cx,cy,16,a,a+.8);c.stroke();
|
|
2122
2440
|
}
|
|
2441
|
+
c.globalAlpha=1;
|
|
2123
2442
|
lbl(c,'LOADING...',cx,st.h+10,'#38bdf8');
|
|
2124
2443
|
} else {
|
|
2125
2444
|
// Arrow that stretches with pull
|