ysharma HF Staff commited on
Commit
878a761
·
verified ·
1 Parent(s): b87816b

Create spin_wheel.py

Browse files
Files changed (1) hide show
  1. spin_wheel.py +671 -0
spin_wheel.py ADDED
@@ -0,0 +1,671 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 🎰 Spin-to-Win Prize Wheel Component (FIXED)
3
+ =============================================
4
+ A viral gamification component for giveaways, random selection, and decision making.
5
+ Features: smooth spinning animation, customizable segments, win detection, confetti celebration.
6
+
7
+ Fixed issues:
8
+ - Proper template syntax for dynamic updates
9
+ - Correct event handling
10
+ - Gradio 6 compatibility
11
+ - All props accepted in __init__ for re-instantiation
12
+ """
13
+
14
+ import gradio as gr
15
+ import json
16
+
17
+ # Default segments
18
+ DEFAULT_SEGMENTS = [
19
+ {"label": "🎁 Grand Prize", "color": "#FF6B6B"},
20
+ {"label": "💎 50 Gems", "color": "#4ECDC4"},
21
+ {"label": "⭐ 100 XP", "color": "#45B7D1"},
22
+ {"label": "🎫 Free Trial", "color": "#96CEB4"},
23
+ {"label": "🔥 Double Points", "color": "#FFEAA7"},
24
+ {"label": "💰 10% Off", "color": "#DDA0DD"},
25
+ {"label": "🎮 Bonus Round", "color": "#98D8C8"},
26
+ {"label": "🍀 Try Again", "color": "#F7DC6F"},
27
+ ]
28
+
29
+ # Presets for easy customization
30
+ PRESETS = {
31
+ "Default": DEFAULT_SEGMENTS,
32
+ "Yes/No": [
33
+ {"label": "✅ YES!", "color": "#4ECDC4"},
34
+ {"label": "❌ NO!", "color": "#FF6B6B"},
35
+ ],
36
+ "Restaurant Picker": [
37
+ {"label": "🍕 Pizza", "color": "#FF6B6B"},
38
+ {"label": "🍔 Burgers", "color": "#FFB347"},
39
+ {"label": "🍣 Sushi", "color": "#4ECDC4"},
40
+ {"label": "🌮 Tacos", "color": "#FFEAA7"},
41
+ {"label": "🍜 Ramen", "color": "#DDA0DD"},
42
+ {"label": "🥗 Salad", "color": "#96CEB4"},
43
+ ],
44
+ "Team Selector": [
45
+ {"label": "🔴 Red Team", "color": "#FF6B6B"},
46
+ {"label": "🔵 Blue Team", "color": "#45B7D1"},
47
+ {"label": "🟢 Green Team", "color": "#4ECDC4"},
48
+ {"label": "🟡 Yellow Team", "color": "#FFEAA7"},
49
+ ],
50
+ "Priority Picker": [
51
+ {"label": "🔥 Do First", "color": "#FF6B6B"},
52
+ {"label": "📌 Do Second", "color": "#FFB347"},
53
+ {"label": "📋 Do Third", "color": "#4ECDC4"},
54
+ {"label": "🗓️ Do Later", "color": "#96CEB4"},
55
+ {"label": "❄️ Icebox", "color": "#45B7D1"},
56
+ ],
57
+ }
58
+
59
+
60
+ def compute_segment_data(segments):
61
+ """Convert segment list to computed segment data with angles."""
62
+ total_weight = sum(s.get("weight", 1) for s in segments)
63
+ segment_data = []
64
+ current_angle = 0
65
+ for s in segments:
66
+ weight = s.get("weight", 1)
67
+ angle = (weight / total_weight) * 360
68
+ segment_data.append({
69
+ "label": s["label"],
70
+ "color": s["color"],
71
+ "startAngle": current_angle,
72
+ "endAngle": current_angle + angle,
73
+ "midAngle": current_angle + angle / 2
74
+ })
75
+ current_angle += angle
76
+ return segment_data
77
+
78
+
79
+ # HTML template - uses ${segments_json} prop for dynamic updates
80
+ # NOTE: ${rotation} prop persists wheel position across re-renders
81
+ HTML_TEMPLATE = """
82
+ <div class="wheel-container">
83
+ <div class="wheel-wrapper">
84
+ <!-- Pointer -->
85
+ <div class="wheel-pointer">▼</div>
86
+
87
+ <!-- Wheel - rotation prop preserves position across re-renders -->
88
+ <div class="wheel" id="prize-wheel" style="transform: rotate(${rotation || 0}deg);">
89
+ <svg viewBox="0 0 400 400" class="wheel-svg">
90
+ ${(() => {
91
+ const segments = JSON.parse(segments_json || '[]');
92
+ const cx = 200, cy = 200, r = 180;
93
+ let html = '';
94
+
95
+ segments.forEach((seg, i) => {
96
+ const startRad = (seg.startAngle - 90) * Math.PI / 180;
97
+ const endRad = (seg.endAngle - 90) * Math.PI / 180;
98
+ const x1 = cx + r * Math.cos(startRad);
99
+ const y1 = cy + r * Math.sin(startRad);
100
+ const x2 = cx + r * Math.cos(endRad);
101
+ const y2 = cy + r * Math.sin(endRad);
102
+ const largeArc = seg.endAngle - seg.startAngle > 180 ? 1 : 0;
103
+
104
+ const d = `M ${cx} ${cy} L ${x1} ${y1} A ${r} ${r} 0 ${largeArc} 1 ${x2} ${y2} Z`;
105
+ html += `<path d="${d}" fill="${seg.color}" stroke="#fff" stroke-width="2" class="segment" data-index="${i}"/>`;
106
+
107
+ // Text label
108
+ const midRad = (seg.midAngle - 90) * Math.PI / 180;
109
+ const textR = r * 0.65;
110
+ const tx = cx + textR * Math.cos(midRad);
111
+ const ty = cy + textR * Math.sin(midRad);
112
+ const rotation = seg.midAngle;
113
+ html += `<text x="${tx}" y="${ty}" fill="#fff" font-size="11" font-weight="bold"
114
+ text-anchor="middle" dominant-baseline="middle"
115
+ transform="rotate(${rotation}, ${tx}, ${ty})"
116
+ style="text-shadow: 1px 1px 2px rgba(0,0,0,0.5); pointer-events: none;">${seg.label}</text>`;
117
+ });
118
+
119
+ // Center circle
120
+ html += `<circle cx="200" cy="200" r="35" fill="#1a1a2e" stroke="#FFD700" stroke-width="4" class="center-btn"/>`;
121
+ html += `<text x="200" y="200" fill="#FFD700" font-size="14" font-weight="bold" text-anchor="middle" dominant-baseline="middle" style="pointer-events: none;">SPIN</text>`;
122
+
123
+ return html;
124
+ })()}
125
+ </svg>
126
+ </div>
127
+
128
+ <!-- Decorative lights -->
129
+ <div class="wheel-lights">
130
+ ${Array.from({length: 16}, (_, i) => `<div class="light" style="--i: ${i}"></div>`).join('')}
131
+ </div>
132
+ </div>
133
+
134
+ <!-- Result Display -->
135
+ <div class="result-display ${value ? 'show' : ''}" id="result-display">
136
+ ${value ? `<div class="result-text">🎉 You won: <strong>${value}</strong></div>` : '<div class="result-text">Spin to win!</div>'}
137
+ </div>
138
+
139
+ <!-- Spin Button -->
140
+ <button class="spin-button" id="spin-btn">
141
+ 🎰 SPIN TO WIN!
142
+ </button>
143
+
144
+ <!-- Confetti container -->
145
+ <div class="confetti-container" id="confetti"></div>
146
+ </div>
147
+ """
148
+
149
+ CSS_TEMPLATE = """
150
+ .wheel-container {
151
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
152
+ display: flex;
153
+ flex-direction: column;
154
+ align-items: center;
155
+ padding: 24px;
156
+ background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
157
+ border-radius: 24px;
158
+ position: relative;
159
+ overflow: hidden;
160
+ min-height: 500px;
161
+ }
162
+
163
+ .wheel-wrapper {
164
+ position: relative;
165
+ width: 320px;
166
+ height: 320px;
167
+ }
168
+
169
+ .wheel {
170
+ width: 100%;
171
+ height: 100%;
172
+ transform: rotate(0deg);
173
+ }
174
+
175
+ .wheel-svg {
176
+ width: 100%;
177
+ height: 100%;
178
+ filter: drop-shadow(0 10px 30px rgba(0,0,0,0.4));
179
+ }
180
+
181
+ .segment {
182
+ cursor: pointer;
183
+ transition: filter 0.2s;
184
+ }
185
+
186
+ .segment:hover {
187
+ filter: brightness(1.1);
188
+ }
189
+
190
+ .center-btn {
191
+ cursor: pointer;
192
+ transition: all 0.2s;
193
+ }
194
+
195
+ .center-btn:hover {
196
+ filter: brightness(1.2);
197
+ }
198
+
199
+ /* Pointer */
200
+ .wheel-pointer {
201
+ position: absolute;
202
+ top: -10px;
203
+ left: 50%;
204
+ transform: translateX(-50%);
205
+ font-size: 48px;
206
+ color: #FFD700;
207
+ z-index: 10;
208
+ filter: drop-shadow(0 4px 6px rgba(0,0,0,0.5));
209
+ animation: bounce 0.6s ease-in-out infinite;
210
+ }
211
+
212
+ @keyframes bounce {
213
+ 0%, 100% { transform: translateX(-50%) translateY(0); }
214
+ 50% { transform: translateX(-50%) translateY(8px); }
215
+ }
216
+
217
+ /* Decorative lights */
218
+ .wheel-lights {
219
+ position: absolute;
220
+ top: 50%;
221
+ left: 50%;
222
+ width: 360px;
223
+ height: 360px;
224
+ transform: translate(-50%, -50%);
225
+ pointer-events: none;
226
+ }
227
+
228
+ .light {
229
+ position: absolute;
230
+ width: 14px;
231
+ height: 14px;
232
+ background: #FFD700;
233
+ border-radius: 50%;
234
+ top: 50%;
235
+ left: 50%;
236
+ transform-origin: center;
237
+ transform: rotate(calc(var(--i) * 22.5deg)) translateY(-180px);
238
+ box-shadow: 0 0 10px #FFD700, 0 0 20px #FFD700;
239
+ animation: blink 0.5s ease-in-out infinite alternate;
240
+ animation-delay: calc(var(--i) * 0.08s);
241
+ }
242
+
243
+ @keyframes blink {
244
+ 0% { opacity: 0.4; transform: rotate(calc(var(--i) * 22.5deg)) translateY(-180px) scale(0.8); }
245
+ 100% { opacity: 1; transform: rotate(calc(var(--i) * 22.5deg)) translateY(-180px) scale(1); box-shadow: 0 0 15px #FFD700, 0 0 30px #FFD700; }
246
+ }
247
+
248
+ /* Spin Button */
249
+ .spin-button {
250
+ margin-top: 24px;
251
+ padding: 18px 56px;
252
+ font-size: 20px;
253
+ font-weight: 700;
254
+ color: white;
255
+ background: linear-gradient(135deg, #FF6B6B 0%, #FF8E53 100%);
256
+ border: none;
257
+ border-radius: 50px;
258
+ cursor: pointer;
259
+ box-shadow: 0 8px 25px rgba(255, 107, 107, 0.4);
260
+ transition: all 0.3s ease;
261
+ text-transform: uppercase;
262
+ letter-spacing: 2px;
263
+ }
264
+
265
+ .spin-button:hover:not(:disabled) {
266
+ transform: translateY(-3px);
267
+ box-shadow: 0 12px 35px rgba(255, 107, 107, 0.5);
268
+ }
269
+
270
+ .spin-button:active:not(:disabled) {
271
+ transform: translateY(0);
272
+ }
273
+
274
+ .spin-button:disabled {
275
+ background: linear-gradient(135deg, #666 0%, #888 100%);
276
+ cursor: not-allowed;
277
+ box-shadow: none;
278
+ transform: none;
279
+ }
280
+
281
+ /* Result Display */
282
+ .result-display {
283
+ margin-top: 20px;
284
+ padding: 16px 32px;
285
+ background: rgba(255,255,255,0.1);
286
+ border-radius: 12px;
287
+ backdrop-filter: blur(10px);
288
+ border: 1px solid rgba(255,255,255,0.1);
289
+ opacity: 0.7;
290
+ transform: scale(0.95);
291
+ transition: all 0.5s ease;
292
+ }
293
+
294
+ .result-display.show {
295
+ opacity: 1;
296
+ transform: scale(1);
297
+ background: rgba(255,215,0,0.15);
298
+ border-color: rgba(255,215,0,0.3);
299
+ }
300
+
301
+ .result-text {
302
+ font-size: 18px;
303
+ color: #fff;
304
+ text-align: center;
305
+ }
306
+
307
+ .result-text strong {
308
+ color: #FFD700;
309
+ font-size: 22px;
310
+ display: block;
311
+ margin-top: 4px;
312
+ }
313
+
314
+ /* Confetti */
315
+ .confetti-container {
316
+ position: absolute;
317
+ top: 0;
318
+ left: 0;
319
+ width: 100%;
320
+ height: 100%;
321
+ pointer-events: none;
322
+ overflow: hidden;
323
+ }
324
+
325
+ .confetti {
326
+ position: absolute;
327
+ width: 10px;
328
+ height: 10px;
329
+ animation: fall 3s ease-out forwards;
330
+ }
331
+
332
+ @keyframes fall {
333
+ 0% {
334
+ transform: translateY(-100px) rotate(0deg);
335
+ opacity: 1;
336
+ }
337
+ 100% {
338
+ transform: translateY(600px) rotate(720deg);
339
+ opacity: 0;
340
+ }
341
+ }
342
+
343
+ /* Spinning state indicator */
344
+ .wheel-container.spinning .wheel-pointer {
345
+ animation: none;
346
+ color: #fff;
347
+ }
348
+
349
+ .wheel-container.spinning .light {
350
+ animation: rapid-blink 0.1s ease-in-out infinite alternate;
351
+ }
352
+
353
+ @keyframes rapid-blink {
354
+ 0% { opacity: 0.3; }
355
+ 100% { opacity: 1; }
356
+ }
357
+ """
358
+
359
+ JS_ON_LOAD = """
360
+ const container = element.querySelector('.wheel-container');
361
+ const wheel = element.querySelector('#prize-wheel');
362
+ const spinBtn = element.querySelector('#spin-btn');
363
+ const confettiContainer = element.querySelector('#confetti');
364
+ const resultDisplay = element.querySelector('#result-display');
365
+
366
+ let isSpinning = false;
367
+
368
+ // Initialize totalRotation from the rotation prop (persists across re-renders)
369
+ let totalRotation = parseFloat(props.rotation) || 0;
370
+
371
+ function getSegments() {
372
+ try {
373
+ return JSON.parse(props.segments_json || '[]');
374
+ } catch (e) {
375
+ return [];
376
+ }
377
+ }
378
+
379
+ function createConfetti() {
380
+ const colors = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#FFEAA7', '#DDA0DD', '#FFD700', '#FF8E53', '#96CEB4'];
381
+ for (let i = 0; i < 150; i++) {
382
+ setTimeout(() => {
383
+ const confetti = document.createElement('div');
384
+ confetti.className = 'confetti';
385
+ confetti.style.left = Math.random() * 100 + '%';
386
+ confetti.style.background = colors[Math.floor(Math.random() * colors.length)];
387
+ confetti.style.animationDelay = Math.random() * 0.3 + 's';
388
+ confetti.style.animationDuration = (2 + Math.random() * 2) + 's';
389
+ const size = 6 + Math.random() * 10;
390
+ confetti.style.width = size + 'px';
391
+ confetti.style.height = size + 'px';
392
+ confetti.style.borderRadius = Math.random() > 0.5 ? '50%' : Math.random() > 0.5 ? '0' : '2px';
393
+ confettiContainer.appendChild(confetti);
394
+
395
+ setTimeout(() => confetti.remove(), 4000);
396
+ }, i * 15);
397
+ }
398
+ }
399
+
400
+ function spinWheel() {
401
+ if (isSpinning) return;
402
+
403
+ const segments = getSegments();
404
+ if (segments.length === 0) return;
405
+
406
+ isSpinning = true;
407
+ container.classList.add('spinning');
408
+ spinBtn.disabled = true;
409
+ spinBtn.textContent = '🎰 SPINNING...';
410
+
411
+ // Pick a random winning segment
412
+ const winningIndex = Math.floor(Math.random() * segments.length);
413
+ const winningSegment = segments[winningIndex];
414
+
415
+ // === ROTATION MATH ===
416
+ // The pointer is fixed at TOP (0°). Segments are drawn with 0° at top.
417
+ // For pointer to hit segment, we need rotation where segment's midAngle aligns with top.
418
+
419
+ // Add random offset within segment bounds
420
+ const segmentSize = winningSegment.endAngle - winningSegment.startAngle;
421
+ const randomOffset = (Math.random() - 0.5) * segmentSize * 0.7;
422
+
423
+ // Target position (mod 360) where wheel should stop
424
+ const targetMod = ((360 - winningSegment.midAngle + randomOffset) % 360 + 360) % 360;
425
+
426
+ // Current wheel position (mod 360)
427
+ const currentMod = ((totalRotation % 360) + 360) % 360;
428
+
429
+ // Calculate additional rotation needed (always spin forward)
430
+ let additionalRotation = targetMod - currentMod;
431
+ if (additionalRotation <= 0) additionalRotation += 360;
432
+
433
+ // Add extra full spins for dramatic effect (5-7 spins)
434
+ const extraSpins = 5 + Math.floor(Math.random() * 3);
435
+ const finalRotation = totalRotation + extraSpins * 360 + additionalRotation;
436
+
437
+ // Apply rotation with CSS transition
438
+ wheel.style.transition = 'transform 4s cubic-bezier(0.17, 0.67, 0.12, 0.99)';
439
+ wheel.style.transform = `rotate(${finalRotation}deg)`;
440
+
441
+ // Store FULL rotation for next spin
442
+ totalRotation = finalRotation;
443
+
444
+ // After spin completes, determine winner from ACTUAL landing position
445
+ setTimeout(() => {
446
+ isSpinning = false;
447
+ container.classList.remove('spinning');
448
+ spinBtn.disabled = false;
449
+ spinBtn.textContent = '🎰 SPIN AGAIN!';
450
+
451
+ // Verify which segment the pointer actually landed on
452
+ const landedRotation = ((totalRotation % 360) + 360) % 360;
453
+ const pointerAngle = ((360 - landedRotation) % 360 + 360) % 360;
454
+
455
+ // Find which segment contains this angle
456
+ let actualWinner = winningSegment;
457
+ for (const seg of segments) {
458
+ if (pointerAngle >= seg.startAngle && pointerAngle < seg.endAngle) {
459
+ actualWinner = seg;
460
+ break;
461
+ }
462
+ }
463
+ // Handle wrap-around edge case
464
+ if (pointerAngle >= segments[segments.length - 1].endAngle || pointerAngle < segments[0].startAngle) {
465
+ if (segments[0].startAngle === 0) {
466
+ actualWinner = segments[0];
467
+ }
468
+ }
469
+
470
+ // Persist rotation as prop so it survives re-renders
471
+ props.rotation = totalRotation;
472
+
473
+ // Update value - this automatically triggers 'change' event
474
+ // Do NOT call trigger('change') separately or you'll get duplicate events!
475
+ props.value = actualWinner.label;
476
+
477
+ createConfetti();
478
+
479
+ console.log('Spin complete:', {
480
+ finalRotation: totalRotation,
481
+ pointerAngle,
482
+ winner: actualWinner.label
483
+ });
484
+ }, 4100);
485
+ }
486
+
487
+ // Button click
488
+ spinBtn.addEventListener('click', spinWheel);
489
+
490
+ // Click on wheel center
491
+ element.addEventListener('click', (e) => {
492
+ if (e.target && e.target.classList.contains('center-btn')) {
493
+ spinWheel();
494
+ }
495
+ });
496
+
497
+ // Keyboard support
498
+ element.addEventListener('keydown', (e) => {
499
+ if (e.key === 'Enter' || e.key === ' ') {
500
+ e.preventDefault();
501
+ spinWheel();
502
+ }
503
+ });
504
+ """
505
+
506
+
507
+ class SpinWheel(gr.HTML):
508
+ """
509
+ An interactive prize wheel component with:
510
+ - Smooth CSS spinning animation
511
+ - Customizable segments with colors
512
+ - Win detection with callbacks
513
+ - Confetti celebration effect
514
+ """
515
+ def __init__(
516
+ self,
517
+ value=None, # Currently selected/won prize (string)
518
+ segments=None, # List of {"label": str, "color": str, "weight": int}
519
+ segments_json=None, # JSON string of computed segments (for updates)
520
+ rotation=0, # Current wheel rotation in degrees (persists position)
521
+ **kwargs
522
+ ):
523
+ # Use provided segments or default
524
+ if segments is None:
525
+ segments = DEFAULT_SEGMENTS
526
+
527
+ # Compute segment data if not provided as JSON
528
+ if segments_json is None:
529
+ segment_data = compute_segment_data(segments)
530
+ segments_json = json.dumps(segment_data)
531
+
532
+ super().__init__(
533
+ value=value,
534
+ segments_json=segments_json,
535
+ rotation=rotation,
536
+ html_template=HTML_TEMPLATE,
537
+ css_template=CSS_TEMPLATE,
538
+ js_on_load=JS_ON_LOAD,
539
+ **kwargs
540
+ )
541
+
542
+ def api_info(self):
543
+ return {"type": "string", "description": "The label of the winning segment"}
544
+
545
+
546
+ # Helper function for updating the wheel (per LESSONS.md pattern)
547
+ def update_wheel(segments=None, value=None, rotation=None):
548
+ """
549
+ Returns gr.HTML(...) for updating the wheel component.
550
+ Always use this instead of creating a new SpinWheel instance.
551
+
552
+ When changing segments (presets), rotation resets to 0.
553
+ """
554
+ if segments is not None:
555
+ segment_data = compute_segment_data(segments)
556
+ segments_json = json.dumps(segment_data)
557
+ # Reset rotation when segments change (wheel layout is different)
558
+ return gr.HTML(segments_json=segments_json, value=value, rotation=0)
559
+ elif value is not None or rotation is not None:
560
+ kwargs = {}
561
+ if value is not None:
562
+ kwargs['value'] = value
563
+ if rotation is not None:
564
+ kwargs['rotation'] = rotation
565
+ return gr.HTML(**kwargs)
566
+ return gr.HTML()
567
+
568
+
569
+ # Demo Application
570
+ with gr.Blocks(title="🎰 Spin Wheel Demo") as demo:
571
+ gr.Markdown("""
572
+ # 🎰 Spin-to-Win Prize Wheel
573
+ A viral gamification component for giveaways, random selection, and decision making!
574
+
575
+ **Click SPIN or the wheel center to play!**
576
+ """)
577
+
578
+ with gr.Row():
579
+ with gr.Column(scale=2):
580
+ wheel = SpinWheel()
581
+
582
+ with gr.Column(scale=1):
583
+ result_box = gr.Textbox(label="🏆 Last Win", interactive=False)
584
+ history_box = gr.Textbox(label="📜 Win History", lines=8, interactive=False)
585
+ spin_count = gr.Number(label="🔢 Total Spins", value=0, interactive=False)
586
+
587
+ gr.Markdown("### 🎨 Customize Wheel")
588
+ preset_dropdown = gr.Dropdown(
589
+ label="Choose Preset",
590
+ choices=list(PRESETS.keys()),
591
+ value="Default"
592
+ )
593
+
594
+ gr.Markdown("### ➕ Add Custom Entry")
595
+ with gr.Row():
596
+ custom_entry = gr.Textbox(label="Entry Name", placeholder="e.g. 🎁 My Prize")
597
+ custom_color = gr.ColorPicker(label="Color", value="#FF6B6B")
598
+ add_btn = gr.Button("Add Entry", variant="secondary")
599
+
600
+ reset_btn = gr.Button("🔄 Reset Wheel", variant="stop")
601
+
602
+ # State for tracking
603
+ win_history = gr.State([])
604
+ current_segments = gr.State(DEFAULT_SEGMENTS.copy())
605
+
606
+ def handle_spin_result(result, history, count):
607
+ """Handle when wheel stops spinning."""
608
+ if result:
609
+ history = history or []
610
+ history.append(result)
611
+ return (
612
+ result,
613
+ "\n".join(reversed(history[-10:])),
614
+ history,
615
+ count + 1
616
+ )
617
+ return "", "", history, count
618
+
619
+ def change_preset(preset, current_segs):
620
+ """Change to a preset wheel configuration."""
621
+ new_segments = PRESETS.get(preset, DEFAULT_SEGMENTS).copy()
622
+ return update_wheel(segments=new_segments), new_segments
623
+
624
+ def add_custom_entry(name, color, current_segs):
625
+ """Add a custom entry to the wheel."""
626
+ if not name.strip():
627
+ return gr.HTML(), current_segs # No change if empty
628
+
629
+ new_segments = current_segs.copy()
630
+ new_segments.append({"label": name.strip(), "color": color})
631
+ return update_wheel(segments=new_segments), new_segments
632
+
633
+ def reset_wheel():
634
+ """Reset to default state."""
635
+ segment_data = compute_segment_data(DEFAULT_SEGMENTS)
636
+ segments_json = json.dumps(segment_data)
637
+ return (
638
+ gr.HTML(segments_json=segments_json, value=None, rotation=0),
639
+ DEFAULT_SEGMENTS.copy(),
640
+ "",
641
+ "",
642
+ [],
643
+ 0
644
+ )
645
+
646
+ # Event handlers
647
+ wheel.change(
648
+ fn=handle_spin_result,
649
+ inputs=[wheel, win_history, spin_count],
650
+ outputs=[result_box, history_box, win_history, spin_count]
651
+ )
652
+
653
+ preset_dropdown.change(
654
+ fn=change_preset,
655
+ inputs=[preset_dropdown, current_segments],
656
+ outputs=[wheel, current_segments]
657
+ )
658
+
659
+ add_btn.click(
660
+ fn=add_custom_entry,
661
+ inputs=[custom_entry, custom_color, current_segments],
662
+ outputs=[wheel, current_segments]
663
+ )
664
+
665
+ reset_btn.click(
666
+ fn=reset_wheel,
667
+ outputs=[wheel, current_segments, result_box, history_box, win_history, spin_count]
668
+ )
669
+
670
+ if __name__ == "__main__":
671
+ demo.launch()