Skip to content

Instantly share code, notes, and snippets.

@aloverso
Last active February 26, 2025 20:45
Show Gist options
  • Select an option

  • Save aloverso/c7ed8e2ba822af935f8dd23fa807468d to your computer and use it in GitHub Desktop.

Select an option

Save aloverso/c7ed8e2ba822af935f8dd23fa807468d to your computer and use it in GitHub Desktop.
Spinner.tsx
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