|
| 1 | +import React, { useState, useRef, useEffect } from 'react'; |
| 2 | +import '../styles/PickerWheel.css'; |
| 3 | +import colors from '../config/colors'; |
| 4 | +import { Trash } from '@phosphor-icons/react'; |
| 5 | +import ListInputModal from './ListInputModal'; |
| 6 | + |
| 7 | +const PickerWheel = () => { |
| 8 | + const [entries, setEntries] = useState([]); |
| 9 | + const [newEntry, setNewEntry] = useState(''); |
| 10 | + const [winner, setWinner] = useState(null); |
| 11 | + const [spinning, setSpinning] = useState(false); |
| 12 | + const canvasRef = useRef(null); |
| 13 | + const [rotation, setRotation] = useState(0); |
| 14 | + const animationFrameId = useRef(null); |
| 15 | + const newEntryInputRef = useRef(null); |
| 16 | + const [isModalOpen, setIsModalOpen] = useState(false); |
| 17 | + |
| 18 | + const colorPalette = [ |
| 19 | + "#FDE2E4", "#E2ECE9", "#BEE1E6", "#F0EFEB", "#DFE7FD", "#CDDAFD", "#EAD5E6", "#F4C7C3", "#D6E2E9", "#B9E2E6", |
| 20 | + "#F9D8D6", "#D4E9E6", "#A8DADC", "#E9E4F2", "#D0D9FB", "#C0CFFB", "#E3C8DE", "#F1BDBD", "#C9D5DE", "#A1D5DB", |
| 21 | + "#F6C4C1", "#C1E0DA", "#92D2D2", "#E2DDF0", "#C3CEFA", "#B3C4FA", "#DBBBD1", "#EDB3B0", "#BCC8D3", "#8DCED1" |
| 22 | + ]; |
| 23 | + |
| 24 | + const cardStyle = { |
| 25 | + backgroundColor: colors['app-alpha-10'], |
| 26 | + borderColor: colors['app-alpha-50'], |
| 27 | + color: colors.app, |
| 28 | + }; |
| 29 | + |
| 30 | + useEffect(() => { |
| 31 | + drawWheel(); |
| 32 | + }, [entries, rotation]); |
| 33 | + |
| 34 | + const getColorData = (color) => { |
| 35 | + const canvas = document.createElement('canvas'); |
| 36 | + canvas.width = 1; |
| 37 | + canvas.height = 1; |
| 38 | + const ctx = canvas.getContext('2d'); |
| 39 | + ctx.fillStyle = color; |
| 40 | + ctx.fillRect(0, 0, 1, 1); |
| 41 | + return ctx.getImageData(0, 0, 1, 1).data; |
| 42 | + } |
| 43 | + |
| 44 | + const drawWheel = () => { |
| 45 | + const canvas = canvasRef.current; |
| 46 | + if (!canvas) return; |
| 47 | + const ctx = canvas.getContext('2d'); |
| 48 | + const { width, height } = canvas; |
| 49 | + const arc = 2 * Math.PI / (entries.length || 1); |
| 50 | + |
| 51 | + ctx.clearRect(0, 0, width, height); |
| 52 | + ctx.save(); |
| 53 | + ctx.translate(width / 2, height / 2); |
| 54 | + ctx.rotate(rotation); |
| 55 | + ctx.translate(-width / 2, -height / 2); |
| 56 | + |
| 57 | + for (let i = 0; i < entries.length; i++) { |
| 58 | + const angle = i * arc; |
| 59 | + ctx.fillStyle = colorPalette[i % colorPalette.length]; |
| 60 | + ctx.beginPath(); |
| 61 | + ctx.arc(width / 2, height / 2, width / 2 - 10, angle, angle + arc); |
| 62 | + ctx.arc(width / 2, height / 2, 0, angle + arc, angle, true); |
| 63 | + ctx.fill(); |
| 64 | + |
| 65 | + ctx.save(); |
| 66 | + ctx.fillStyle = '#000'; |
| 67 | + ctx.font = '30px Arial'; |
| 68 | + ctx.translate(width / 2 + Math.cos(angle + arc / 2) * (width / 2 - 80), height / 2 + Math.sin(angle + arc / 2) * (height / 2 - 80)); |
| 69 | + ctx.rotate(angle + arc / 2 + Math.PI / 2); |
| 70 | + const text = entries[i]; |
| 71 | + ctx.fillText(text, -ctx.measureText(text).width / 2, 0); |
| 72 | + ctx.restore(); |
| 73 | + } |
| 74 | + ctx.restore(); |
| 75 | + }; |
| 76 | + |
| 77 | + const addEntry = () => { |
| 78 | + if (newEntry.trim() && entries.length < 30) { |
| 79 | + setEntries([...entries, newEntry.trim()]); |
| 80 | + setNewEntry(''); |
| 81 | + newEntryInputRef.current.focus(); |
| 82 | + } |
| 83 | + }; |
| 84 | + |
| 85 | + const handleKeyDown = (e) => { |
| 86 | + if (e.key === 'Enter') { |
| 87 | + addEntry(); |
| 88 | + } |
| 89 | + }; |
| 90 | + |
| 91 | + const deleteEntry = (index) => { |
| 92 | + const newEntries = [...entries]; |
| 93 | + newEntries.splice(index, 1); |
| 94 | + setEntries(newEntries); |
| 95 | + } |
| 96 | + |
| 97 | + const easeOut = (t) => 1 - Math.pow(1 - t, 3); |
| 98 | + |
| 99 | + const spin = () => { |
| 100 | + if (entries.length > 1 && !spinning) { |
| 101 | + setSpinning(true); |
| 102 | + setWinner(null); |
| 103 | + const duration = 7000; |
| 104 | + const startTime = performance.now(); |
| 105 | + const startRotation = rotation; |
| 106 | + const randomSpins = Math.random() * 5 + 5; |
| 107 | + const endRotation = startRotation + randomSpins * 2 * Math.PI; |
| 108 | + |
| 109 | + const animate = (currentTime) => { |
| 110 | + const elapsedTime = currentTime - startTime; |
| 111 | + const progress = Math.min(elapsedTime / duration, 1); |
| 112 | + const easedProgress = easeOut(progress); |
| 113 | + |
| 114 | + const newRotation = startRotation + (endRotation - startRotation) * easedProgress; |
| 115 | + setRotation(newRotation); |
| 116 | + |
| 117 | + if (progress < 1) { |
| 118 | + animationFrameId.current = requestAnimationFrame(animate); |
| 119 | + } else { |
| 120 | + const canvas = canvasRef.current; |
| 121 | + const ctx = canvas.getContext('2d'); |
| 122 | + const pinX = canvas.width / 2; |
| 123 | + const pinY = 30; // Position of the pin |
| 124 | + const pixel = ctx.getImageData(pinX, pinY, 1, 1).data; |
| 125 | + const pixelColor = `rgb(${pixel[0]}, ${pixel[1]}, ${pixel[2]})`; |
| 126 | + |
| 127 | + for (let i = 0; i < entries.length; i++) { |
| 128 | + const colorData = getColorData(colorPalette[i % colorPalette.length]); |
| 129 | + const color = `rgb(${colorData[0]}, ${colorData[1]}, ${colorData[2]})`; |
| 130 | + if (color === pixelColor) { |
| 131 | + setWinner(entries[i]); |
| 132 | + break; |
| 133 | + } |
| 134 | + } |
| 135 | + setSpinning(false); |
| 136 | + } |
| 137 | + }; |
| 138 | + |
| 139 | + animationFrameId.current = requestAnimationFrame(animate); |
| 140 | + } |
| 141 | + }; |
| 142 | + |
| 143 | + const reset = () => { |
| 144 | + setWinner(null); |
| 145 | + } |
| 146 | + |
| 147 | + const clearEntries = () => { |
| 148 | + setEntries([]); |
| 149 | + setWinner(null); |
| 150 | + setRotation(0); |
| 151 | + } |
| 152 | + |
| 153 | + const handleSaveList = (list) => { |
| 154 | + const newEntries = list.split('\n').map(entry => entry.trim()).filter(entry => entry); |
| 155 | + setEntries([...entries, ...newEntries].slice(0, 30)); |
| 156 | + }; |
| 157 | + |
| 158 | + return ( |
| 159 | + <div |
| 160 | + className="group bg-transparent border rounded-lg shadow-2xl p-6 flex flex-col justify-start relative w-full flex-grow" |
| 161 | + style={cardStyle} |
| 162 | + > |
| 163 | + <div |
| 164 | + className="absolute top-0 left-0 w-full h-full opacity-10" |
| 165 | + style={{ |
| 166 | + backgroundImage: |
| 167 | + 'radial-gradient(circle, white 1px, transparent 1px)', |
| 168 | + backgroundSize: '10px 10px', |
| 169 | + }} |
| 170 | + ></div> |
| 171 | + <div className="relative z-10"> |
| 172 | + <h1 className="text-3xl font-arvo font-normal mb-4 text-app"> Picker Wheel </h1> |
| 173 | + <hr className="border-gray-700 mb-4" /> |
| 174 | + <div className="flex gap-8"> |
| 175 | + <div className="flex flex-col items-center"> |
| 176 | + <div className="picker-wheel-container mt-8"> |
| 177 | + <div className="wheel-wrapper"> |
| 178 | + <div className="pin"></div> |
| 179 | + <canvas ref={canvasRef} width="600" height="600" className={`wheel ${entries.length > 1 && !spinning ? 'slow-spin' : ''}`}></canvas> |
| 180 | + <button onClick={spin} className="spin-button" disabled={spinning || entries.length < 2}> |
| 181 | + {spinning ? '...' : winner ? winner : 'Spin'} |
| 182 | + </button> |
| 183 | + </div> |
| 184 | + </div> |
| 185 | + <div className="winner mt-4"> |
| 186 | + {spinning ? 'Spinning...' : winner ? `The winner is: ${winner}`: ''} |
| 187 | + </div> |
| 188 | + </div> |
| 189 | + <div className="w-full max-w-lg ml-16"> |
| 190 | + <div className="controls"> |
| 191 | + <input |
| 192 | + ref={newEntryInputRef} |
| 193 | + type="text" |
| 194 | + value={newEntry} |
| 195 | + onChange={(e) => setNewEntry(e.target.value)} |
| 196 | + onKeyDown={handleKeyDown} |
| 197 | + placeholder="Add an option (max 30)" |
| 198 | + className="bg-gray-800 text-white p-2 rounded-lg flex-grow" |
| 199 | + disabled={entries.length >= 30} |
| 200 | + /> |
| 201 | + <button |
| 202 | + onClick={addEntry} |
| 203 | + className="flex items-center gap-2 text-lg font-arvo font-normal px-4 py-2 rounded-md border transition-colors duration-300 ease-in-out bg-app/50 text-white hover:bg-app/70" |
| 204 | + disabled={entries.length >= 30} |
| 205 | + > |
| 206 | + Add |
| 207 | + </button> |
| 208 | + </div> |
| 209 | + <button |
| 210 | + onClick={() => setIsModalOpen(true)} |
| 211 | + className="flex items-center justify-center w-full gap-2 text-lg font-arvo font-normal px-4 py-2 mt-4 rounded-md border transition-colors duration-300 ease-in-out bg-app/50 text-white hover:bg-app/70" |
| 212 | + > |
| 213 | + Load from List |
| 214 | + </button> |
| 215 | + <div className="w-full mt-4"> |
| 216 | + <h2 className="text-2xl font-arvo font-normal mb-4">Entries ({entries.length})</h2> |
| 217 | + <ul className="space-y-2"> |
| 218 | + {entries.map((entry, index) => ( |
| 219 | + <li key={index} className="flex items-center justify-between bg-gray-800/75 p-2 rounded-lg"> |
| 220 | + <span className="flex-grow text-center">{entry}</span> |
| 221 | + <button |
| 222 | + onClick={() => deleteEntry(index)} |
| 223 | + className="flex items-center gap-2 text-lg font-mono font-normal px-2 py-2 rounded-md border transition-colors duration-300 ease-in-out bg-app/50 text-white hover:bg-app/70" |
| 224 | + > |
| 225 | + <Trash size={20} /> |
| 226 | + </button> |
| 227 | + </li> |
| 228 | + ))} |
| 229 | + </ul> |
| 230 | + </div> |
| 231 | + </div> |
| 232 | + </div> |
| 233 | + </div> |
| 234 | + <div |
| 235 | + className="group bg-transparent border rounded-lg shadow-2xl p-6 flex flex-col justify-start relative w-full flex-grow mt-8" |
| 236 | + style={cardStyle} |
| 237 | + > |
| 238 | + <div |
| 239 | + className="absolute top-0 left-0 w-full h-full opacity-10" |
| 240 | + style={{ |
| 241 | + backgroundImage: |
| 242 | + 'radial-gradient(circle, white 1px, transparent 1px)', |
| 243 | + backgroundSize: '10px 10px', |
| 244 | + }} |
| 245 | + ></div> |
| 246 | + <div className="relative z-10"> |
| 247 | + <h2 className="text-2xl font-arvo font-normal mb-4">How it works</h2> |
| 248 | + <p className="text-gray-400"> |
| 249 | + ✦ Add entries one by one using the input field and "Add" button, or load a list of entries using the "Load from List" button. <br /> |
| 250 | + ✦ The wheel will display the entries as equal divisions. <br /> |
| 251 | + ✦ Click the "Spin" button to spin the wheel. It will spin fast and then slowly get slower, eventually stopping on a winner. <br /> |
| 252 | + ✦ The winner will be displayed below the wheel and in the center of the wheel. <br /> |
| 253 | + </p> |
| 254 | + </div> |
| 255 | + </div> |
| 256 | + <ListInputModal |
| 257 | + isOpen={isModalOpen} |
| 258 | + onClose={() => setIsModalOpen(false)} |
| 259 | + onSave={handleSaveList} |
| 260 | + /> |
| 261 | + </div> |
| 262 | + ); |
| 263 | +}; |
| 264 | + |
| 265 | +export default PickerWheel; |
0 commit comments