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:
co-authored by
Claude Sonnet 4.6
parent
f0ed8304b6
commit
3863eb214e
@@ -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>
|
||||
@@ -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.',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user