Last active
February 26, 2025 20:45
-
-
Save aloverso/c7ed8e2ba822af935f8dd23fa807468d to your computer and use it in GitHub Desktop.
Spinner.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import {ReactElement, useEffect, useLayoutEffect, useMemo, useRef, useState} from "react"; | |
| interface SpinnerProps { | |
| names: string[]; | |
| onSpinFinished: (name: string) => void; | |
| } | |
| interface PieProps { | |
| names: string[]; | |
| r: number; | |
| } | |
| interface PointerProps { | |
| r: number | |
| } | |
| const colorsDefault = [ | |
| '#ff0000', | |
| '#ffa500', | |
| '#ffff00', | |
| '#8acd32', | |
| '#008000', | |
| '#7fffd4', | |
| '#00ffff', | |
| '#00bfff', | |
| '#0000ff', | |
| '#800080', | |
| '#ff00ff', | |
| '#ff1493' | |
| ] | |
| const buttonStyles = { | |
| color: "#fff", | |
| "backgroundColor": "#0076d6", | |
| "borderRadius": ".25rem", | |
| cursor: "pointer", | |
| display: "inline-block", | |
| fontWeight: 700, | |
| "marginRight": ".5rem", | |
| padding: ".75rem 1.25rem", | |
| "textDecoration": "none", | |
| border: 0, | |
| marginBottom: "3rem", | |
| width: "100%", | |
| textAlign: "center" | |
| } | |
| function useRefWidth() { | |
| const [width, setWidth] = useState(0); | |
| const ref = useRef(null) | |
| useLayoutEffect(() => { | |
| function updateWdith() { | |
| setWidth(ref.current.offsetWidth); | |
| } | |
| window.addEventListener('resize', updateWdith); | |
| updateWdith(); | |
| return () => window.removeEventListener('resize', updateWdith); | |
| }, []); | |
| return {width, ref}; | |
| } | |
| export const Spinner = (props: SpinnerProps): ReactElement => { | |
| const { width, ref } = useRefWidth() | |
| const [spinDeg, setSpinDeg] = useState(0) | |
| const [isSpinning, setIsSpinning] = useState(false) | |
| const [spinFinished, setSpinFinished] = useState(false) | |
| const padding = 10; | |
| const r = (width - padding*2) / 2 | |
| const namesInWheelOrder = useMemo(() => { | |
| const newList = [...props.names] | |
| const first = newList.shift() | |
| newList.push(first) | |
| return newList | |
| }, [props.names]) | |
| const determineNameSpun = (): string => { | |
| const finalRotation = spinDeg % 360; | |
| const theta = 360/(props.names.length); | |
| const nameIndex = Math.floor(finalRotation / theta) | |
| return namesInWheelOrder[nameIndex] | |
| } | |
| useEffect(() => { | |
| if (spinFinished) { | |
| const name = determineNameSpun(); | |
| props.onSpinFinished(name); | |
| setSpinFinished(false) | |
| } | |
| }, [spinFinished]) | |
| const doSpin = () => { | |
| const createInterval = (deg: number): number => { | |
| return setInterval(() => { | |
| setSpinDeg((prev) => prev+deg) | |
| }, 1) | |
| } | |
| const length = 40; | |
| let prevIntervalTotals = 0 | |
| for (let i = 1; i <= length; i++) { | |
| // function determining next degree to spin by (starts around 5, gets to .001) | |
| const y = 21/(Math.pow(2,(i/3))) | |
| // spin each step for a random time between 200-400ms | |
| const intervalLength = Math.random()*200 + 200 | |
| setTimeout((deg) => { | |
| setIsSpinning(true) | |
| const interval = createInterval(deg) | |
| setTimeout((i) => { | |
| clearInterval(interval) | |
| if (i === length) { | |
| setIsSpinning(false) | |
| setSpinFinished(true) | |
| } | |
| }, intervalLength, i) | |
| }, prevIntervalTotals, y) | |
| // update delay for when next interval should start | |
| prevIntervalTotals += intervalLength | |
| } | |
| } | |
| return ( | |
| <div style={{ padding: "2rem" }}> | |
| <button style={buttonStyles} onClick={doSpin} disabled={isSpinning}>Spin</button> | |
| <div ref={ref}> | |
| <svg width={width} height={width}> | |
| <g transform={`rotate(${spinDeg}, ${r}, ${r})`}> | |
| <Pie names={props.names} r={r} /> | |
| </g> | |
| <Pointer r={r}/> | |
| </svg> | |
| </div> | |
| </div> | |
| ) | |
| } | |
| const Pie = (props: PieProps): ReactElement => { | |
| const {r} = props; | |
| const n = props.names.length; | |
| const points = getPoints(n, r, false); | |
| const namePoints = getPoints(n, 2*r/3, true) | |
| const pixels = points.map((point) => transformPoint(point, r)); | |
| const namePixels = namePoints.map((point) => transformPoint(point, r)); | |
| const getFill = (i: number): string => { | |
| const colorIndex = i % colorsDefault.length | |
| // prevent two colors next to each other of the same color | |
| if (i === n-1 && colorIndex === 0) { | |
| return '#000000' | |
| } | |
| return colorsDefault[colorIndex] | |
| } | |
| return ( | |
| <> | |
| <circle cx={r} cy={r} r={r} style={{fill: "#f1f1f1"}}/> | |
| {pixels.map(([x,y], i) => { | |
| const [prevX, prevY] = i === 0 ? pixels[pixels.length - 1] : pixels[i-1] | |
| return ( | |
| <> | |
| <path style={{fill: getFill(i)}} key={i} d={`M${r},${r} L${prevX},${prevY} A${r},${r} 1 0,0 ${x},${y} z`}/> | |
| </> | |
| ) | |
| })} | |
| {namePixels.map(([x,y], i) => { | |
| const theta = 360/n; | |
| const rotation = theta/2 - theta*i | |
| return ( | |
| <g transform={`translate(${x}, ${y})`}> | |
| <text text-anchor="middle" transform={`rotate(${rotation})`}>{props.names[i]}</text> | |
| </g> | |
| ) | |
| })} | |
| </> | |
| ) | |
| } | |
| export const Pointer = ({r}: PointerProps): ReactElement => { | |
| const pointerLen = 100; | |
| const pointerHeight = 20; | |
| return ( | |
| <> | |
| <path style={{fill: "#000000"}} d={`M${2*r-pointerLen},${r} L${10+r*2},${r-pointerHeight} A${pointerHeight*2},${pointerHeight*2} 1 0,1 ${10+r*2},${r+pointerHeight} z`}/> | |
| </> | |
| ) | |
| } | |
| // [x, y] | |
| type Point = number[] | |
| function getPoints(n: number, r: number, includeHalfOffset: boolean): Point[] { | |
| const theta = (Math.PI * 2)/n; | |
| const points = []; | |
| const halfTheta = theta/2 | |
| const offset = includeHalfOffset ? halfTheta : 0 | |
| for (let i = 0; i < n; i++) { | |
| const x = Math.cos((theta * i) - offset) * r; | |
| const y = Math.sin((theta * i) - offset) * r; | |
| points.push([x,y]); | |
| } | |
| return points; | |
| } | |
| function transformPoint([x,y]: Point, offset: number): Point { | |
| const transformedX = x + offset; | |
| const transformedY = -y + offset; | |
| return [Math.floor(transformedX), Math.floor(transformedY)]; | |
| } | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment