| 1 |
(function () { |
| 2 |
'use strict'; |
| 3 |
|
| 4 |
var _typoKeys = [ |
| 5 |
['family','font-family'],['weight','font-weight'],['style','font-style'], |
| 6 |
['decoration','text-decoration'],['transform','text-transform'], |
| 7 |
['sizeDesktop','font-size-d'],['sizeTablet','font-size-t'],['sizeMobile','font-size-m'], |
| 8 |
['lineHeightDesktop','line-height-d'],['lineHeightTablet','line-height-t'],['lineHeightMobile','line-height-m'], |
| 9 |
['letterSpacingDesktop','letter-spacing-d'],['letterSpacingTablet','letter-spacing-t'],['letterSpacingMobile','letter-spacing-m'], |
| 10 |
['wordSpacingDesktop','word-spacing-d'],['wordSpacingTablet','word-spacing-t'],['wordSpacingMobile','word-spacing-m'] |
| 11 |
]; |
| 12 |
function typoCssVarsForEl(el, obj, prefix) { |
| 13 |
if (!obj || typeof obj !== 'object') return; |
| 14 |
_typoKeys.forEach(function (pair) { |
| 15 |
var val = obj[pair[0]]; |
| 16 |
if (val !== undefined && val !== '') el.style.setProperty(prefix + pair[1], String(val)); |
| 17 |
}); |
| 18 |
if (obj.sizeUnit && obj.sizeUnit !== 'px') { |
| 19 |
['sizeDesktop','sizeTablet','sizeMobile'].forEach(function (k, i) { |
| 20 |
var v = obj[k]; if (v !== undefined && v !== '') el.style.setProperty(prefix + ['font-size-d','font-size-t','font-size-m'][i], v + obj.sizeUnit); |
| 21 |
}); |
| 22 |
} |
| 23 |
} |
| 24 |
|
| 25 |
function parseItems(text) { |
| 26 |
return String(text || '').split('\n').map(function(s){ return s.trim(); }).filter(Boolean); |
| 27 |
} |
| 28 |
|
| 29 |
// Easing: ease-out cubic |
| 30 |
function easeOut(t) { |
| 31 |
return 1 - Math.pow(1 - t, 3); |
| 32 |
} |
| 33 |
|
| 34 |
function drawWheelFrame(ctx, items, colors, textColor, centerColor, size, rotation) { |
| 35 |
var cx = size / 2, cy = size / 2, rad = size / 2 - 4; |
| 36 |
ctx.clearRect(0, 0, size, size); |
| 37 |
if (!items.length) return; |
| 38 |
var slice = (2 * Math.PI) / items.length; |
| 39 |
items.forEach(function(item, i) { |
| 40 |
var start = rotation + i * slice; |
| 41 |
var end = start + slice; |
| 42 |
var color = colors[i % colors.length]; |
| 43 |
// Segment |
| 44 |
ctx.beginPath(); |
| 45 |
ctx.moveTo(cx, cy); |
| 46 |
ctx.arc(cx, cy, rad, start, end); |
| 47 |
ctx.closePath(); |
| 48 |
ctx.fillStyle = color; |
| 49 |
ctx.fill(); |
| 50 |
ctx.strokeStyle = 'rgba(255,255,255,0.3)'; |
| 51 |
ctx.lineWidth = 2; |
| 52 |
ctx.stroke(); |
| 53 |
// Label |
| 54 |
ctx.save(); |
| 55 |
ctx.translate(cx, cy); |
| 56 |
ctx.rotate(start + slice / 2); |
| 57 |
ctx.textAlign = 'right'; |
| 58 |
ctx.fillStyle = textColor || '#ffffff'; |
| 59 |
ctx.font = 'bold ' + Math.min(15, Math.max(9, Math.floor(rad * 0.13))) + 'px -apple-system,sans-serif'; |
| 60 |
var label = item.length > 14 ? item.slice(0,13) + '…' : item; |
| 61 |
ctx.fillText(label, rad - 12, 5); |
| 62 |
ctx.restore(); |
| 63 |
}); |
| 64 |
// Center circle |
| 65 |
ctx.beginPath(); |
| 66 |
ctx.arc(cx, cy, rad * 0.12, 0, 2 * Math.PI); |
| 67 |
ctx.fillStyle = centerColor || '#ffffff'; |
| 68 |
ctx.fill(); |
| 69 |
ctx.strokeStyle = 'rgba(0,0,0,0.1)'; |
| 70 |
ctx.lineWidth = 2; |
| 71 |
ctx.stroke(); |
| 72 |
} |
| 73 |
|
| 74 |
function initSpinWheel(app) { |
| 75 |
var opts = {}; |
| 76 |
try { opts = JSON.parse(app.getAttribute('data-opts') || '{}'); } catch(e) {} |
| 77 |
|
| 78 |
var cardBg = opts.cardBg || '#ffffff'; |
| 79 |
var winnerBg = opts.winnerBg || '#6c3fb5'; |
| 80 |
var winnerColor = opts.winnerColor || '#ffffff'; |
| 81 |
var historyBg = opts.historyBg || '#f9fafb'; |
| 82 |
var historyColor= opts.historyColor|| '#6b7280'; |
| 83 |
var btnBg = opts.btnBg || '#6c3fb5'; |
| 84 |
var btnColor = opts.btnColor || '#ffffff'; |
| 85 |
var labelColor = opts.labelColor || '#374151'; |
| 86 |
var titleColor = opts.titleColor || '#1f2937'; |
| 87 |
var subtitleColor = opts.subtitleColor || '#6b7280'; |
| 88 |
var sectionBg = opts.sectionBg || ''; |
| 89 |
var pointerColor= opts.pointerColor|| '#1f2937'; |
| 90 |
var textColor = opts.textColor || '#ffffff'; |
| 91 |
var centerColor = opts.centerColor || '#ffffff'; |
| 92 |
var maxWidth = opts.maxWidth || 520; |
| 93 |
var cardRadius = opts.cardRadius || 20; |
| 94 |
var btnRadius = opts.btnRadius || 100; |
| 95 |
var titleSize = opts.titleSize || 28; |
| 96 |
var winnerSize = opts.winnerSize || 28; |
| 97 |
var wheelSize = Math.min(opts.wheelSize || 340, 340); |
| 98 |
var padTop = opts.paddingTop || 60; |
| 99 |
var padBot = opts.paddingBottom || 60; |
| 100 |
var showTitle = opts.showTitle !== false; |
| 101 |
var showSub = opts.showSubtitle !== false; |
| 102 |
var showWinner = opts.showWinnerBox !== false; |
| 103 |
var showHistory = opts.showHistory !== false; |
| 104 |
var spinDuration= (opts.spinDuration || 4) * 1000; |
| 105 |
var buttonLabel = opts.buttonLabel || 'Spin!'; |
| 106 |
var colorList = (opts.colors || '#6c3fb5,#10b981,#f59e0b,#3b82f6,#ef4444,#8b5cf6,#06b6d4,#f97316').split(',').map(function(c){ return c.trim(); }); |
| 107 |
|
| 108 |
var items = parseItems(opts.defaultItems || 'Alice\nBob\nCarol\nDave\nEve\nFrank\nGrace\nHenry'); |
| 109 |
var history = []; |
| 110 |
var spinning = false; |
| 111 |
var currentRotation = 0; |
| 112 |
|
| 113 |
// Layout |
| 114 |
app.style.fontFamily = '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'; |
| 115 |
if (sectionBg) app.style.background = sectionBg; |
| 116 |
app.style.paddingTop = padTop + 'px'; |
| 117 |
app.style.paddingBottom = padBot + 'px'; |
| 118 |
|
| 119 |
typoCssVarsForEl(app, opts.titleTypo, '--bksw-tt-'); |
| 120 |
typoCssVarsForEl(app, opts.subtitleTypo, '--bksw-st-'); |
| 121 |
typoCssVarsForEl(app, opts.winnerTypo, '--bksw-wn-'); |
| 122 |
|
| 123 |
var card = document.createElement('div'); |
| 124 |
card.className = 'bkbg-sw-card'; |
| 125 |
card.style.cssText = 'background:' + cardBg + ';border-radius:' + cardRadius + 'px;padding:36px 28px;max-width:' + maxWidth + 'px;margin:0 auto;'; |
| 126 |
app.appendChild(card); |
| 127 |
|
| 128 |
// Title |
| 129 |
if (showTitle && opts.title) { |
| 130 |
var titleEl = document.createElement('div'); |
| 131 |
titleEl.className = 'bkbg-sw-title'; |
| 132 |
titleEl.style.color = titleColor; |
| 133 |
titleEl.style.marginBottom = '6px'; |
| 134 |
titleEl.textContent = opts.title; |
| 135 |
card.appendChild(titleEl); |
| 136 |
} |
| 137 |
if (showSub && opts.subtitle) { |
| 138 |
var subEl = document.createElement('div'); |
| 139 |
subEl.className = 'bkbg-sw-subtitle'; |
| 140 |
subEl.style.color = subtitleColor; |
| 141 |
subEl.style.marginBottom = '20px'; |
| 142 |
subEl.textContent = opts.subtitle; |
| 143 |
card.appendChild(subEl); |
| 144 |
} |
| 145 |
|
| 146 |
// Textarea |
| 147 |
var taWrap = document.createElement('div'); |
| 148 |
taWrap.style.cssText = 'margin-bottom:18px;text-align:left;'; |
| 149 |
var taLbl = document.createElement('label'); |
| 150 |
taLbl.className = 'bkbg-sw-items-label'; |
| 151 |
taLbl.style.color = labelColor; |
| 152 |
taLbl.textContent = 'Items (' + items.length + ')'; |
| 153 |
var textarea = document.createElement('textarea'); |
| 154 |
textarea.className = 'bkbg-sw-textarea'; |
| 155 |
textarea.rows = 5; |
| 156 |
textarea.value = opts.defaultItems || ''; |
| 157 |
textarea.style.borderRadius = '8px'; |
| 158 |
textarea.addEventListener('input', function() { |
| 159 |
items = parseItems(textarea.value); |
| 160 |
taLbl.textContent = 'Items (' + items.length + ')'; |
| 161 |
currentRotation = 0; |
| 162 |
drawWheelFrame(ctx, items, colorList, textColor, centerColor, wheelSize, 0); |
| 163 |
}); |
| 164 |
taWrap.appendChild(taLbl); |
| 165 |
taWrap.appendChild(textarea); |
| 166 |
card.appendChild(taWrap); |
| 167 |
|
| 168 |
// Wheel wrap |
| 169 |
var wheelWrap = document.createElement('div'); |
| 170 |
wheelWrap.className = 'bkbg-sw-wheel-wrap'; |
| 171 |
card.appendChild(wheelWrap); |
| 172 |
|
| 173 |
// Pointer |
| 174 |
var pointer = document.createElement('div'); |
| 175 |
pointer.className = 'bkbg-sw-pointer'; |
| 176 |
pointer.style.cssText = 'position:absolute;top:-10px;left:50%;transform:translateX(-50%);width:0;height:0;border-left:12px solid transparent;border-right:12px solid transparent;border-top:22px solid ' + pointerColor + ';z-index:10;'; |
| 177 |
wheelWrap.appendChild(pointer); |
| 178 |
|
| 179 |
// Canvas |
| 180 |
var canvas = document.createElement('canvas'); |
| 181 |
canvas.className = 'bkbg-sw-canvas'; |
| 182 |
canvas.width = wheelSize; |
| 183 |
canvas.height = wheelSize; |
| 184 |
wheelWrap.appendChild(canvas); |
| 185 |
var ctx = canvas.getContext('2d'); |
| 186 |
drawWheelFrame(ctx, items, colorList, textColor, centerColor, wheelSize, 0); |
| 187 |
|
| 188 |
// Spin button |
| 189 |
var spinBtn = document.createElement('button'); |
| 190 |
spinBtn.className = 'bkbg-sw-spin-btn'; |
| 191 |
spinBtn.style.cssText = 'background:' + btnBg + ';color:' + btnColor + ';border-radius:' + btnRadius + 'px;'; |
| 192 |
spinBtn.textContent = buttonLabel; |
| 193 |
card.appendChild(spinBtn); |
| 194 |
|
| 195 |
// Winner box |
| 196 |
var winnerBox; |
| 197 |
if (showWinner) { |
| 198 |
winnerBox = document.createElement('div'); |
| 199 |
winnerBox.style.display = 'none'; |
| 200 |
winnerBox.style.marginBottom = '14px'; |
| 201 |
var wInner = document.createElement('div'); |
| 202 |
wInner.className = 'bkbg-sw-winner-box'; |
| 203 |
wInner.style.cssText = 'background:' + winnerBg + ';color:' + winnerColor + ';border-radius:12px;padding:16px 28px;display:inline-block;'; |
| 204 |
var wSub = document.createElement('div'); |
| 205 |
wSub.className = 'bkbg-sw-winner-sub'; |
| 206 |
wSub.textContent = '🎉 Winner!'; |
| 207 |
var wText = document.createElement('div'); |
| 208 |
wText.className = 'bkbg-sw-winner-text'; |
| 209 |
wInner.appendChild(wSub); |
| 210 |
wInner.appendChild(wText); |
| 211 |
winnerBox.appendChild(wInner); |
| 212 |
card.appendChild(winnerBox); |
| 213 |
} |
| 214 |
|
| 215 |
// History |
| 216 |
var histSection, histChips; |
| 217 |
if (showHistory) { |
| 218 |
histSection = document.createElement('div'); |
| 219 |
histSection.className = 'bkbg-sw-history'; |
| 220 |
histSection.style.cssText = 'display:none;background:' + historyBg + ';'; |
| 221 |
var histLbl = document.createElement('div'); |
| 222 |
histLbl.className = 'bkbg-sw-history-label'; |
| 223 |
histLbl.style.color = labelColor; |
| 224 |
histLbl.textContent = 'Spin History'; |
| 225 |
histChips = document.createElement('div'); |
| 226 |
histChips.className = 'bkbg-sw-history-chips'; |
| 227 |
histSection.appendChild(histLbl); |
| 228 |
histSection.appendChild(histChips); |
| 229 |
card.appendChild(histSection); |
| 230 |
} |
| 231 |
|
| 232 |
function spin() { |
| 233 |
if (spinning || items.length === 0) return; |
| 234 |
spinning = true; |
| 235 |
spinBtn.disabled = true; |
| 236 |
if (winnerBox) winnerBox.style.display = 'none'; |
| 237 |
|
| 238 |
// Choose winner index, calculate target rotation |
| 239 |
var winnerIdx = Math.floor(Math.random() * items.length); |
| 240 |
var slice = (2 * Math.PI) / items.length; |
| 241 |
// We want winnerIdx to land at top (which is -Math.PI/2 after rotation) |
| 242 |
// Pointer at top = rotation offset where winnerIdx center aligns at top |
| 243 |
// Target angle for item i center = i * slice + slice/2 |
| 244 |
// We want that angle + rotation = -PI/2 → rotation = -PI/2 - (i*slice + slice/2) |
| 245 |
var targetNormalized = -Math.PI / 2 - (winnerIdx * slice + slice / 2); |
| 246 |
// Add extra full spins for drama |
| 247 |
var extraSpins = 5 + Math.floor(Math.random() * 3); |
| 248 |
var endRotation = targetNormalized + extraSpins * 2 * Math.PI; |
| 249 |
// Normalize to start |
| 250 |
var startRotation = currentRotation; |
| 251 |
var startTime = null; |
| 252 |
|
| 253 |
function frame(timestamp) { |
| 254 |
if (!startTime) startTime = timestamp; |
| 255 |
var elapsed = timestamp - startTime; |
| 256 |
var progress = Math.min(elapsed / spinDuration, 1); |
| 257 |
var eased = easeOut(progress); |
| 258 |
var rotation = startRotation + (endRotation - startRotation) * eased; |
| 259 |
currentRotation = rotation; |
| 260 |
drawWheelFrame(ctx, items, colorList, textColor, centerColor, wheelSize, rotation); |
| 261 |
if (progress < 1) { |
| 262 |
requestAnimationFrame(frame); |
| 263 |
} else { |
| 264 |
spinning = false; |
| 265 |
spinBtn.disabled = false; |
| 266 |
// Show winner |
| 267 |
var winner = items[winnerIdx]; |
| 268 |
if (showWinner && winnerBox) { |
| 269 |
winnerBox.style.display = 'block'; |
| 270 |
wText.textContent = winner; |
| 271 |
// Re-trigger animation |
| 272 |
wInner.style.animation = 'none'; |
| 273 |
void wInner.offsetWidth; |
| 274 |
wInner.style.animation = 'bkbg-sw-pop .35s ease'; |
| 275 |
} |
| 276 |
if (showHistory) { |
| 277 |
history.unshift(winner); |
| 278 |
if (history.length > 10) history.pop(); |
| 279 |
histChips.innerHTML = ''; |
| 280 |
history.forEach(function(h) { |
| 281 |
var chip = document.createElement('span'); |
| 282 |
chip.className = 'bkbg-sw-history-chip'; |
| 283 |
chip.style.color = historyColor; |
| 284 |
chip.textContent = h; |
| 285 |
histChips.appendChild(chip); |
| 286 |
}); |
| 287 |
histSection.style.display = 'block'; |
| 288 |
} |
| 289 |
} |
| 290 |
} |
| 291 |
requestAnimationFrame(frame); |
| 292 |
} |
| 293 |
|
| 294 |
spinBtn.addEventListener('click', spin); |
| 295 |
} |
| 296 |
|
| 297 |
document.querySelectorAll('.bkbg-sw-app').forEach(initSpinWheel); |
| 298 |
})(); |
| 299 |
|