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