feat: add Spin the Wheel generator

Canvas-based wheel with 8-color palette, ease-out cubic spin animation,
deterministic winner calculation, confetti burst on result, copy to clipboard.
Supports 2–24 custom items with live textarea update.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
emil
2026-05-09 16:03:30 +03:00
co-authored by Claude Sonnet 4.6
parent f0ed8304b6
commit 3863eb214e
4 changed files with 425 additions and 0 deletions
+1
View File
@@ -20,6 +20,7 @@ const icons: Record<string, string> = {
'circle-dollar-sign': `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M16 8h-6a2 2 0 1 0 0 4h4a2 2 0 1 1 0 4H8"/><path d="M12 18V6"/></svg>`,
'list': `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" x2="21" y1="6" y2="6"/><line x1="8" x2="21" y1="12" y2="12"/><line x1="8" x2="21" y1="18" y2="18"/><line x1="3" x2="3.01" y1="6" y2="6"/><line x1="3" x2="3.01" y1="12" y2="12"/><line x1="3" x2="3.01" y1="18" y2="18"/></svg>`,
'fingerprint': `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 10a2 2 0 0 0-2 2c0 1.02-.1 2.51-.26 4"/><path d="M14 13.12c0 2.38 0 6.38-1 8.88"/><path d="M17.29 21.02c.12-.6.43-2.3.5-3.02"/><path d="M2 12a10 10 0 0 1 18-6"/><path d="M2 17c1 .5 2.25 1 4 1 1.5 0 3-.5 4-1"/><path d="M20 12c0 2-.5 4.5-2 6"/><path d="M7 13.02c0 2.38 0 5.5 1 7.48"/><path d="M8.5 8.5A5 5 0 0 1 17 12"/></svg>`,
'pie-chart': `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.21 15.89A10 10 0 1 1 8 2.83"/><path d="M22 12A10 10 0 0 0 12 2v10z"/></svg>`,
};
---
@@ -0,0 +1,338 @@
---
// Wheel of Fortune spinner — canvas-based, vanilla JS, no libraries
---
<div id="wheel-spinner" class="mt-8">
<!-- Items input -->
<div class="flex flex-col gap-3">
<div>
<label for="ws-items" class="block text-sm font-medium text-zinc-400 mb-1">
Items <span class="text-zinc-600">(one per line, 2–24)</span>
</label>
<textarea
id="ws-items"
rows="6"
class="w-full bg-zinc-900 border border-zinc-700 rounded-lg px-3 py-2 text-zinc-100 text-sm font-mono focus:outline-none focus:border-[#534AB7] focus:ring-1 focus:ring-[#534AB7] transition-colors resize-y"
>Alice
Bob
Carol
Dave
Eve
Frank</textarea>
</div>
<div class="flex items-center gap-3">
<button
id="ws-update-btn"
type="button"
class="px-4 py-2 text-sm font-semibold border border-zinc-700 text-zinc-300 rounded-lg hover:border-[#534AB7] hover:text-zinc-100 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-[#534AB7] cursor-pointer"
>
Update wheel
</button>
<p id="ws-error" role="alert" aria-live="polite" class="text-sm text-red-500 hidden"></p>
</div>
</div>
<!-- Wheel area -->
<div class="flex flex-col items-center mt-8 gap-6">
<div class="relative w-full max-w-[480px]" id="ws-wheel-wrap">
<!-- Pointer: triangle pointing down at top-center of wheel -->
<div
id="ws-pointer"
class="absolute left-1/2 top-0 z-10 -translate-x-1/2 -translate-y-1/2"
style="width:0;height:0;border-left:14px solid transparent;border-right:14px solid transparent;border-top:24px solid #534AB7;filter:drop-shadow(0 2px 8px rgba(83,74,183,0.6));"
aria-hidden="true"
></div>
<canvas
id="ws-canvas"
class="block w-full"
aria-label="Wheel of fortune"
></canvas>
</div>
<button
id="ws-spin-btn"
type="button"
class="w-full sm:w-auto px-10 py-3 bg-[#534AB7] text-white font-semibold rounded-lg hover:bg-[#4740a0] active:bg-[#3d3990] focus:outline-none focus-visible:ring-2 focus-visible:ring-[#534AB7] focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 transition-colors duration-100 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
>
Spin
</button>
</div>
<!-- Result card (hidden until first spin) -->
<div id="ws-result-card" class="hidden mt-10 flex flex-col items-center gap-3" aria-live="polite">
<p class="text-xs font-semibold uppercase tracking-widest text-zinc-500">Winner</p>
<span id="ws-result-text" class="text-3xl sm:text-4xl font-bold text-zinc-100 text-center break-words max-w-full"></span>
<button
id="ws-copy-btn"
type="button"
class="inline-flex items-center gap-1.5 text-xs font-medium text-zinc-500 hover:text-zinc-200 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-[#534AB7] rounded"
>
<span id="ws-copy-label">Copy</span>
<span id="ws-copy-icon" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="14" height="14" x="8" y="8" rx="2" ry="2"/>
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>
</svg>
</span>
<span id="ws-check-icon" class="hidden text-[#534AB7]" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 6 9 17l-5-5"/>
</svg>
</span>
</button>
</div>
</div>
<style>
@keyframes ws-pop {
from { transform: scale(0.6); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
.ws-pop { animation: ws-pop 0.35s cubic-bezier(0.34, 1.56, 0.64, 1) both; }
</style>
<script>
const COLORS = ['#534AB7','#0F6E56','#993C1D','#185FA5','#854F0B','#993556','#3B6D11','#A32D2D'];
const TAU = 2 * Math.PI;
const textarea = document.getElementById('ws-items') as HTMLTextAreaElement;
const updateBtn = document.getElementById('ws-update-btn') as HTMLButtonElement;
const errorEl = document.getElementById('ws-error') as HTMLParagraphElement;
const canvas = document.getElementById('ws-canvas') as HTMLCanvasElement;
const spinBtn = document.getElementById('ws-spin-btn') as HTMLButtonElement;
const resultCard = document.getElementById('ws-result-card') as HTMLDivElement;
const resultText = document.getElementById('ws-result-text') as HTMLSpanElement;
const copyBtn = document.getElementById('ws-copy-btn') as HTMLButtonElement;
const copyLabel = document.getElementById('ws-copy-label') as HTMLSpanElement;
const copyIcon = document.getElementById('ws-copy-icon') as HTMLSpanElement;
const checkIcon = document.getElementById('ws-check-icon') as HTMLSpanElement;
const wheelWrap = document.getElementById('ws-wheel-wrap') as HTMLDivElement;
const ctx = canvas.getContext('2d')!;
let items: string[] = ['Alice', 'Bob', 'Carol', 'Dave', 'Eve', 'Frank'];
let currentRotation = 0;
let isSpinning = false;
let copyRevertTimer: ReturnType<typeof setTimeout> | null = null;
// ── Canvas sizing ────────────────────────────────────────────────
function resize() {
const size = Math.min(wheelWrap.offsetWidth, 480);
canvas.width = size;
canvas.height = size;
draw(currentRotation);
}
new ResizeObserver(resize).observe(wheelWrap);
resize();
// ── Drawing ──────────────────────────────────────────────────────
function clampText(text: string, maxWidth: number): string {
if (ctx.measureText(text).width <= maxWidth) return text;
let s = text;
while (s.length > 0 && ctx.measureText(s + '…').width > maxWidth) s = s.slice(0, -1);
return s + '…';
}
function draw(rotation: number) {
const size = canvas.width;
const r = size / 2 - 6;
const n = items.length;
const seg = TAU / n;
ctx.clearRect(0, 0, size, size);
ctx.save();
ctx.translate(size / 2, size / 2);
for (let i = 0; i < n; i++) {
const a0 = -Math.PI / 2 + i * seg + rotation;
const a1 = a0 + seg;
// fill
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.arc(0, 0, r, a0, a1);
ctx.closePath();
ctx.fillStyle = COLORS[i % COLORS.length];
ctx.fill();
// divider
ctx.strokeStyle = 'rgba(255,255,255,0.13)';
ctx.lineWidth = 1.5;
ctx.stroke();
// label
const mid = a0 + seg / 2;
const fontSize = Math.max(9, Math.min(14, Math.floor(r * seg * 0.4)));
ctx.save();
ctx.rotate(mid);
ctx.textAlign = 'right';
ctx.fillStyle = 'rgba(255,255,255,0.92)';
ctx.font = `bold ${fontSize}px 'Space Grotesk', sans-serif`;
const label = clampText(items[i], r * 0.54);
ctx.fillText(label, r - 10, fontSize * 0.38);
ctx.restore();
}
// outer ring
ctx.beginPath();
ctx.arc(0, 0, r, 0, TAU);
ctx.strokeStyle = 'rgba(255,255,255,0.2)';
ctx.lineWidth = 3;
ctx.stroke();
// center hub
ctx.beginPath();
ctx.arc(0, 0, 15, 0, TAU);
ctx.fillStyle = '#18181b';
ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.22)';
ctx.lineWidth = 2;
ctx.stroke();
ctx.restore();
}
// ── Items ────────────────────────────────────────────────────────
function parseItems(): string[] | null {
const lines = textarea.value.split('\n').map(l => l.trim()).filter(Boolean);
if (lines.length < 2) { showError('Add at least 2 items.'); return null; }
if (lines.length > 24) { showError('Maximum 24 items.'); return null; }
clearError();
return lines;
}
function showError(msg: string) { errorEl.textContent = msg; errorEl.classList.remove('hidden'); }
function clearError() { errorEl.textContent = ''; errorEl.classList.add('hidden'); }
updateBtn.addEventListener('click', () => {
const parsed = parseItems();
if (parsed) { items = parsed; draw(currentRotation); }
});
// ── Spin ─────────────────────────────────────────────────────────
spinBtn.addEventListener('click', () => {
if (isSpinning) return;
const parsed = parseItems();
if (!parsed) return;
items = parsed;
draw(currentRotation);
const n = items.length;
const seg = TAU / n;
const winnerIdx = Math.floor(Math.random() * n);
// Desired phase: rotation value (mod TAU) that puts center of winner at top (-π/2)
// midAngle of seg i = -π/2 + (i + 0.5)*seg + rotation → want ≡ -π/2 (mod TAU)
// → rotation ≡ -(i + 0.5)*seg (mod TAU)
const desiredPhase = (-(winnerIdx + 0.5) * seg % TAU + TAU) % TAU;
const currentPhase = ((currentRotation % TAU) + TAU) % TAU;
let delta = (desiredPhase - currentPhase + TAU) % TAU;
if (delta < 0.5) delta += TAU; // avoid near-zero spin
// Small jitter so it doesn't always stop dead-center
const jitter = (Math.random() - 0.5) * seg * 0.55;
const fullTurns = 5 + Math.floor(Math.random() * 3);
const totalDelta = fullTurns * TAU + delta + jitter;
const startRot = currentRotation;
const duration = 4000 + Math.random() * 2000;
const t0 = performance.now();
isSpinning = true;
spinBtn.disabled = true;
resultCard.classList.add('hidden');
(function animate(now: number) {
const p = Math.min((now - t0) / duration, 1);
const eased = 1 - Math.pow(1 - p, 3); // ease-out cubic
currentRotation = startRot + totalDelta * eased;
draw(currentRotation);
if (p < 1) {
requestAnimationFrame(animate);
} else {
currentRotation = startRot + totalDelta;
isSpinning = false;
spinBtn.disabled = false;
showResult(items[winnerIdx]);
spawnConfetti();
}
})(t0);
});
// ── Result ───────────────────────────────────────────────────────
function showResult(winner: string) {
resultText.textContent = winner;
resultCard.classList.remove('hidden');
resultText.classList.remove('ws-pop');
void resultText.offsetWidth; // reflow to restart animation
resultText.classList.add('ws-pop');
copyLabel.textContent = 'Copy';
copyIcon.classList.remove('hidden');
checkIcon.classList.add('hidden');
}
copyBtn.addEventListener('click', async () => {
const value = resultText.textContent?.trim();
if (!value) return;
await navigator.clipboard.writeText(value);
copyIcon.classList.add('hidden');
checkIcon.classList.remove('hidden');
copyLabel.textContent = 'Copied';
if (copyRevertTimer) clearTimeout(copyRevertTimer);
copyRevertTimer = setTimeout(() => {
checkIcon.classList.add('hidden');
copyIcon.classList.remove('hidden');
copyLabel.textContent = 'Copy';
}, 1500);
});
// ── Confetti ─────────────────────────────────────────────────────
function spawnConfetti() {
const cr = canvas.getBoundingClientRect();
const wr = wheelWrap.getBoundingClientRect();
const cx = cr.left - wr.left + cr.width / 2;
const cy = cr.top - wr.top + cr.height / 2;
type Particle = { el: HTMLDivElement; x: number; y: number; vx: number; vy: number };
const particles: Particle[] = [];
for (let i = 0; i < 60; i++) {
const el = document.createElement('div');
const w = 4 + Math.random() * 6;
const h = 4 + Math.random() * 6;
el.style.cssText = `position:absolute;width:${w}px;height:${h}px;` +
`background:${COLORS[i % COLORS.length]};border-radius:1px;` +
`pointer-events:none;left:${cx}px;top:${cy}px;will-change:transform,opacity;`;
wheelWrap.appendChild(el);
const angle = Math.random() * TAU;
const speed = 4 + Math.random() * 7;
particles.push({
el, x: cx, y: cy,
vx: Math.cos(angle) * speed,
vy: -(Math.abs(Math.sin(angle)) * speed + 2 + Math.random() * 4),
});
}
const dur = 2500;
const t0 = performance.now();
const gravity = 0.2;
(function tick(now: number) {
const elapsed = now - t0;
if (elapsed >= dur) { particles.forEach(p => p.el.remove()); return; }
const opacity = 1 - elapsed / dur;
particles.forEach(p => {
p.vy += gravity;
p.vx *= 0.99;
p.x += p.vx;
p.y += p.vy;
p.el.style.transform = `translate(${p.x - cx}px,${p.y - cy}px) rotate(${p.x * 2}deg)`;
p.el.style.opacity = String(Math.max(0, opacity));
});
requestAnimationFrame(tick);
})(t0);
}
</script>
+9
View File
@@ -90,4 +90,13 @@ export const generators: Generator[] = [
seoTitle: 'UUID Generator — UUID v4, Hex & Base64 Tokens | Randify',
seoDescription: 'Generate random UUID v4, hex tokens, or base64 strings online. Cryptographically secure, runs in your browser. Free UUID and token generator.',
},
{
slug: 'wheel',
title: 'Spin the Wheel',
description: 'Add your items, spin the wheel, and let chance decide.',
icon: 'pie-chart',
status: 'live',
seoTitle: 'Spin the Wheel — Random Picker | Randify',
seoDescription: 'Spin a customizable wheel of fortune to pick a random winner, choice, or option. Add your own items and let the wheel decide.',
},
];
+77
View File
@@ -0,0 +1,77 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import WheelSpinner from '../../components/generators/WheelSpinner.astro';
import AdBanner from '../../components/AdBanner.astro';
import SeoBlock from '../../components/SeoBlock.astro';
import { generators } from '../../data/generators';
const generator = generators.find((g) => g.slug === 'wheel')!;
---
<BaseLayout
title={generator.seoTitle}
description={generator.seoDescription}
>
<div class="max-w-2xl mx-auto px-4 py-12 sm:py-20">
<nav aria-label="Breadcrumb" class="mb-10">
<a
href="/"
class="inline-flex items-center gap-1.5 text-sm text-zinc-500 hover:text-zinc-200 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-[#534AB7] rounded"
aria-label="Back to all generators"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="m15 18-6-6 6-6"/>
</svg>
All generators
</a>
</nav>
<header class="mb-2">
<div class="flex items-center gap-2 mb-4">
<span
class="inline-block w-2.5 h-2.5 rounded-full bg-[#534AB7]"
aria-hidden="true"
></span>
<span class="text-sm font-medium text-zinc-400 uppercase tracking-widest">randify</span>
</div>
<h1 class="text-3xl sm:text-4xl font-bold text-zinc-100">
{generator.title}
</h1>
<p class="mt-2 text-base text-zinc-400">{generator.description}</p>
</header>
<main>
<WheelSpinner />
</main>
<div class="mt-12 flex justify-center">
<AdBanner size="tile" />
</div>
<SeoBlock
howTo={[
'Type your items into the text box — one per line.',
'Click "Update wheel" to apply changes.',
'Hit "Spin" and watch the wheel decide.',
'Copy the result or spin again.',
]}
whenTo={[
'Picking a winner for a giveaway or raffle.',
'Deciding whose turn it is in a game or meeting.',
'Choosing a random activity, movie, or restaurant.',
'Any decision you want to leave to chance.',
]}
/>
</div>
</BaseLayout>