UNPKG

react-hook-roulette

Version:

Minimal roulette wheel component. built with React Hooks.

481 lines (473 loc) 12.4 kB
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 };