react-hook-roulette
Version:
Minimal roulette wheel component. built with React Hooks.
481 lines (473 loc) • 12.4 kB
JavaScript
import * as React from 'react';
import { useMemo, useRef, useState, useCallback, useEffect } from 'react';
const Roulette = ({
roulette
}) => {
const { size } = roulette;
return /* @__PURE__ */ React.createElement("canvas", { ref: roulette.canvasRef, width: size, height: size });
};
const defaultOptions = {
size: 400,
maxSpeed: 100,
rotationDirection: "clockwise",
acceleration: 1,
deceleration: 1,
initialAngle: 0,
determineAngle: 45,
showArrow: true,
style: {
canvas: {
bg: "#fff"
},
arrow: {
bg: "#000",
size: 16
},
label: {
font: "16px Arial",
align: "right",
baseline: "middle",
offset: 0.75,
defaultColor: "#000"
},
pie: {
border: false,
borderColor: "#000",
borderWidth: 2,
theme: [
{
bg: "#e0e7ff"
},
{
bg: "#a5b4fc"
},
{
bg: "#6366f1",
color: "#fff"
},
{
bg: "#4338ca",
color: "#fff"
}
]
}
}
};
const degreesToCanvasRadians = (degrees) => {
return (360 - degrees) * Math.PI / 180;
};
const calculateLabelPosition = ({
geometry,
segmentStartAngle,
anglePerSegment,
labelOffset
}) => {
const startDeg = degreesToCanvasRadians(segmentStartAngle);
const endDeg = degreesToCanvasRadians(segmentStartAngle + anglePerSegment);
const labelRadius = geometry.radius * labelOffset;
const labelAngle = startDeg + (endDeg - startDeg) / 2;
const labelX = geometry.center.x + labelRadius * Math.cos(labelAngle);
const labelY = geometry.center.y + labelRadius * Math.sin(labelAngle);
return {
labelX,
labelY,
labelAngle
};
};
const createCalculateTotalWeight = () => {
let lastPieList = null;
let lastTotalWeight = 0;
return (pieList) => {
if (lastPieList === pieList) {
return lastTotalWeight;
}
const totalWeight = pieList.reduce(
(acc, pie) => acc + (pie.weight || 1),
0
);
lastPieList = pieList;
lastTotalWeight = totalWeight;
return totalWeight;
};
};
const calculateTotalWeight = createCalculateTotalWeight();
const getResultName = ({
rouletteItemList,
totalRotation,
rotationDirection
}) => {
const checkDegree = Math.abs(totalRotation) % 360;
const normalizedRotation = checkDegree > 360 ? checkDegree - 360 : checkDegree;
const totalWeight = calculateTotalWeight(rouletteItemList);
const directionResolvedData = rotationDirection === "clockwise" ? [...rouletteItemList].reverse() : rouletteItemList;
let currentAngle = 0;
for (const item of directionResolvedData) {
const itemWeight = item.weight || 1;
const itemAngle = itemWeight / totalWeight * 360;
if (normalizedRotation >= currentAngle && normalizedRotation < currentAngle + itemAngle) {
return item.name;
}
currentAngle += itemAngle;
}
return "No Result";
};
const setupCanvas = (canvas) => {
if (!canvas)
throw new Error("Canvas is not available.");
const context = canvas.getContext("2d");
if (!context)
throw new Error("Could not obtain 2D context from canvas.");
return {
canvas,
context
};
};
const drawArrow = ({
context,
geometry,
determineAngle,
style
}) => {
const radian = degreesToCanvasRadians(determineAngle);
const arrowTipX = geometry.center.x + geometry.radius * Math.cos(radian);
const arrowTipY = geometry.center.y + geometry.radius * Math.sin(radian);
const dimension = style.arrow.size;
const baseCenterX = arrowTipX - dimension * Math.cos(radian);
const baseCenterY = arrowTipY - dimension * Math.sin(radian);
const leftX = baseCenterX + dimension * Math.sin(radian);
const leftY = baseCenterY - dimension * Math.cos(radian);
const rightX = baseCenterX - dimension * Math.sin(radian);
const rightY = baseCenterY + dimension * Math.cos(radian);
context.beginPath();
context.moveTo(arrowTipX, arrowTipY);
context.lineTo(leftX, leftY);
context.lineTo(rightX, rightY);
context.closePath();
context.fillStyle = style.arrow.bg;
context.fill();
};
const drawPieLabel = ({
context,
geometry,
pie,
segmentStartAngle,
anglePerSegment,
style
}) => {
const { labelX, labelY, labelAngle } = calculateLabelPosition({
geometry,
segmentStartAngle,
anglePerSegment,
labelOffset: style.label.offset
});
context.save();
context.translate(labelX, labelY);
context.rotate(labelAngle);
context.textAlign = style.label.align;
context.textBaseline = style.label.baseline;
context.fillStyle = pie.color;
context.font = style.label.font;
context.fillText(pie.name, 0, 0);
context.restore();
};
const drawPie = ({
context,
geometry,
pie,
segmentStartAngle,
anglePerSegment,
style
}) => {
const startDeg = degreesToCanvasRadians(segmentStartAngle);
const endDeg = degreesToCanvasRadians(segmentStartAngle + anglePerSegment);
context.beginPath();
context.moveTo(geometry.center.x, geometry.center.y);
context.fillStyle = pie.bg;
context.arc(
geometry.center.x,
geometry.center.y,
geometry.radius,
startDeg,
endDeg,
true
);
context.fill();
if (style?.pie?.border === false || style?.pie?.borderWidth == null || style?.pie?.borderColor == null) {
return;
}
context.beginPath();
context.strokeStyle = style.pie.borderColor;
context.lineWidth = style.pie.borderWidth;
context.moveTo(geometry.center.x, geometry.center.y);
context.lineTo(
geometry.center.x + geometry.radius * Math.cos(startDeg),
geometry.center.y + geometry.radius * Math.sin(startDeg)
);
context.moveTo(geometry.center.x, geometry.center.y);
context.lineTo(
geometry.center.x + geometry.radius * Math.cos(endDeg),
geometry.center.y + geometry.radius * Math.sin(endDeg)
);
context.stroke();
};
const drawRoulette = ({
initialAngleOffset,
context,
startAngle,
geometry,
pieList,
style
}) => {
const weightCount = calculateTotalWeight(pieList);
const anglePerWeight = 360 / weightCount;
let segmentEndAngle = initialAngleOffset % 360 + startAngle;
const resolvedPieList = pieList.map((pie, index) => {
const bg = pie.bg || style.pie.theme[index % style.pie.theme.length].bg;
const color = pie.color || style.pie.theme[index % style.pie.theme.length].color || style.label.defaultColor;
const weight = pie.weight || 1;
const anglePerSegment = anglePerWeight * weight;
return {
name: pie.name,
bg,
color,
weight,
anglePerWeight,
anglePerSegment
};
});
for (const pie of resolvedPieList) {
const anglePerSegment = anglePerWeight * pie.weight;
const segmentStartAngle = (segmentEndAngle - anglePerSegment + 360) % 360;
drawPie({
context,
geometry,
pie,
segmentStartAngle,
anglePerSegment,
style
});
drawPieLabel({
context,
geometry,
pie,
segmentStartAngle,
anglePerSegment,
style
});
segmentEndAngle = segmentStartAngle;
}
if (style?.pie?.border === false || style?.pie?.borderWidth == null || style?.pie?.borderColor == null) {
return;
}
const lineWidth = style.pie.borderWidth;
context.beginPath();
context.strokeStyle = style.pie.borderColor;
context.lineWidth = lineWidth;
context.arc(
geometry.center.x,
geometry.center.y,
geometry.radius - lineWidth / 2,
0,
Math.PI * 2,
true
);
context.stroke();
};
const drawCanvas = ({
rouletteItemList,
mergedOptions,
context,
geometry,
rouletteRef
}) => {
context.beginPath();
context.fillStyle = mergedOptions.style.canvas.bg;
context.fillRect(0, 0, geometry.radius * 2, geometry.radius * 2);
drawRoulette({
pieList: rouletteItemList,
initialAngleOffset: rouletteRef.current.totalRotation,
startAngle: mergedOptions.determineAngle,
style: mergedOptions.style,
context,
geometry
});
if (mergedOptions.showArrow) {
drawArrow({
context,
style: mergedOptions.style,
geometry,
determineAngle: mergedOptions.determineAngle
});
}
};
const calculateSpeed = (status, curSpeed, options) => {
if (status === "running" && curSpeed <= options.maxSpeed) {
return curSpeed + options.acceleration;
}
if (status === "ending" && curSpeed >= 0) {
return curSpeed - options.deceleration;
}
return curSpeed;
};
const calcTotalRotation = (rotationDirection) => {
if (rotationDirection === "clockwise") {
return (totalRotation, curSpeed) => {
return totalRotation - curSpeed;
};
}
return (totalRotation, curSpeed) => {
return totalRotation + curSpeed;
};
};
const animateRoulette = ({
rouletteItemList,
mergedOptions,
context,
status,
geometry,
rouletteRef,
onFinish
}) => {
let animationFrameId;
let curSpeed = rouletteRef.current.speed;
const getTotalRotation = calcTotalRotation(mergedOptions.rotationDirection);
const animate = () => {
curSpeed = calculateSpeed(status, curSpeed, mergedOptions);
rouletteRef.current.speed = curSpeed;
rouletteRef.current.totalRotation = getTotalRotation(
rouletteRef.current.totalRotation,
curSpeed
);
const complete = status === "ending" && curSpeed < 0;
if (complete) {
const rouletteResult = getResultName({
rouletteItemList,
rotationDirection: mergedOptions.rotationDirection,
totalRotation: rouletteRef.current.totalRotation
});
onFinish(rouletteResult);
return;
}
drawCanvas({
rouletteItemList,
mergedOptions,
context,
geometry,
rouletteRef
});
animationFrameId = requestAnimationFrame(animate);
};
animate();
return () => cancelAnimationFrame(animationFrameId);
};
const useRoulette = ({
items,
onSpinUp,
onSpinDown,
onSpinEnd,
options = {}
}) => {
const mergedOptions = useMemo(() => {
return {
...defaultOptions,
...options,
style: {
canvas: {
...defaultOptions.style.canvas,
...options.style?.canvas
},
label: {
...defaultOptions.style.label,
...options.style?.label
},
arrow: {
...defaultOptions.style.arrow,
...options.style?.arrow
},
pie: {
...defaultOptions.style.pie,
...options.style?.pie,
theme: [
...options.style?.pie?.theme || defaultOptions.style.pie.theme
]
}
}
};
}, [options]);
const canvasRef = useRef(null);
const rouletteRef = useRef({
speed: 0,
totalRotation: mergedOptions.initialAngle
});
const resultRef = useRef("");
const geometry = useMemo(() => {
const half = mergedOptions.size / 2;
return {
radius: half,
center: {
x: half,
y: half
}
};
}, [mergedOptions]);
const [status, setStatus] = useState("stop");
const onStart = useCallback(() => {
if (status !== "stop")
return;
setStatus("running");
}, [status]);
const onStop = useCallback(() => {
if (status !== "running")
return;
setStatus("ending");
}, [status]);
useEffect(() => {
if (status !== "stop")
return;
const { context } = setupCanvas(canvasRef.current);
drawCanvas({
rouletteItemList: items,
mergedOptions,
context,
geometry,
rouletteRef
});
}, [status, geometry, items, mergedOptions]);
useEffect(() => {
if (status !== "running" && status !== "ending")
return;
if (status === "running") {
onSpinUp?.();
}
if (status === "ending") {
onSpinDown?.();
}
const { context } = setupCanvas(canvasRef.current);
const cancelAnimation = animateRoulette({
rouletteItemList: items,
mergedOptions,
context,
status,
geometry,
rouletteRef,
onFinish: (rouletteResult) => {
setStatus("stop");
resultRef.current = rouletteResult;
onSpinEnd?.(rouletteResult);
}
});
return () => {
cancelAnimation();
};
}, [status, geometry, items, mergedOptions, onSpinEnd, onSpinDown, onSpinUp]);
return {
roulette: {
size: mergedOptions.size,
canvasRef
},
result: resultRef.current,
onStart,
onStop
};
};
export { Roulette, useRoulette };