Home > other >  Why is this Canvas component rendering twice and shifting abruptly at the end?
Why is this Canvas component rendering twice and shifting abruptly at the end?

Time:11-30

I've been trying to implement Roco C. Buljan's JS Spinning Wheel in React and I'm completely stuck at this particular point. The intended behavior is that the wheel spins once and after stopping displays the pointed sector.

Here's what my code looks like:

import React, { useEffect, useRef } from "react";
import "./SpinWheel.css";

function SpinWheel() {
  const sectors = [
    { color: "#f82", label: "Stack" },
    { color: "#0bf", label: "10" },
    { color: "#fb0", label: "200" },
    { color: "#0fb", label: "50" },
    { color: "#b0f", label: "100" },
    { color: "#f0b", label: "5" },
  ];

  // Generate random float in range min-max:
  const rand = (m, M) => Math.random() * (M - m)   m;

  const tot = sectors.length;
  const wheel = useRef(null);
  const spin = useRef(null);
  useEffect(() => {
    const ctx = wheel.current.getContext("2d");
    const elSpin = spin.current;
    spinWheel(ctx, elSpin);
  }, []);

  function spinWheel(ctx, elSpin) {
    const dia = ctx.canvas.width;
    const rad = dia / 2;
    const PI = Math.PI;
    const TAU = 2 * PI;
    const arc = TAU / sectors.length;
    const friction = 0.991; // 0.995=soft, 0.99=mid, 0.98=hard
    const angVelMin = 0.002; // Below that number will be treated as a stop
    let angVelMax = 0; // Random ang.vel. to acceletare to
    let angVel = 0; // Current angular velocity
    let ang = 0; // Angle rotation in radians
    let isSpinning = false;
    let isAccelerating = false;

    //* Get index of current sector */
    const getIndex = () => Math.floor(tot - (ang / TAU) * tot) % tot;

    //* Draw sectors and prizes texts to canvas */
    const drawSector = (sector, i) => {
      const ang = arc * i;
      ctx.save();
      // COLOR
      ctx.beginPath();
      ctx.fillStyle = sector.color;
      ctx.moveTo(rad, rad);
      ctx.arc(rad, rad, rad, ang, ang   arc);
      ctx.lineTo(rad, rad);
      ctx.fill();
      // TEXT
      ctx.translate(rad, rad);
      ctx.rotate(ang   arc / 2);
      ctx.textAlign = "right";
      ctx.fillStyle = "#fff";
      ctx.font = "bold 30px sans-serif";
      ctx.fillText(sector.label, rad - 10, 10);
      //
      ctx.restore();
    };


    //* CSS rotate CANVAS Element */
    const rotate = () => {
      const sector = sectors[getIndex()];
      ctx.canvas.style.transform = `rotate(${ang - PI / 2}rad)`;
 elSpin.textContent = !angVel ? sector.label : "SPIN";
 elSpin.style.background = sector.color; 
    };

    const frame = () => {
      if (!isSpinning) return;

      if (angVel >= angVelMax) isAccelerating = false;

      // Accelerate
      if (isAccelerating) {
        angVel ||= angVelMin; // Initial velocity kick
        angVel *= 1.06; // Accelerate
      }

      // Decelerate
      else {
        isAccelerating = false;
        angVel *= friction; // Decelerate by friction

        // SPIN END:
        if (angVel < angVelMin) {
          isSpinning = false;
          angVel = 0;
        }
      }

      ang  = angVel; // Update angle
      ang %= TAU; // Normalize angle
      rotate(); // CSS rotate!
    };

    const engine = () => {
      frame();
      requestAnimationFrame(engine);
    };

    elSpin.addEventListener("click", () => {
      if (isSpinning) return;
      isSpinning = true;
      isAccelerating = true;
      angVelMax = rand(0.25, 0.4);
    });

    // INIT!
    sectors.forEach(drawSector);
    rotate(); // Initial rotation
    engine(); // Start engine!
  }

  return (
    <div id="wheelOfFortune">
      <canvas id="wheel" ref={wheel} width="300" height="300"></canvas>
      <div id="spin" ref={spin}>
        SPIN asd asd asd as dasd as dasd asd asd as d
      </div>
    </div>
  );
}

export default SpinWheel;

And here's the sandbox link

The issue is that if I console.log the useEffect block, I can see that it executes twice at the start which is not what I intend. I tried putting an empty array as the dependency array but it still doesn't work for me. The second issue is that the wheel supposedly stops and then spins a small amount yet again, resulting in two final sector outputs. Again, not what I intend. I can only guess that it is rendering more than once but I'm not sure. Any ideas what I'm doing wrong?

CodePudding user response:

There are a couple things you need to do to get this working in a react context:

  1. The spinWheel method should return start/stop methods for both starting the spin and stopping. The stop method should also clean up any artifacts such as event handlers and whatnot.

    const startSpin = () => {
      if (isSpinning) return;
      isSpinning = true;
      isAccelerating = true;
      angVelMax = rand(0.25, 0.4);
    };
    
    const stopSpin = () => {
      isSpinning = false;
      isAccelerating = false;
    }
    
    elSpin.addEventListener("click", startSpin);
    
    const cleanup = () => {
      elSpin.removeEventListener("click", startSpin);
    };
    
    ...
    return { startSpin, stopSpin, cleanup };
    
  2. As mentioned in a comment, you need to make sure your effect has a "cleanup" function which stops the wheel (see this link). This is so that the setup → cleanup → setup sequence works in development as described on that page.

     useEffect(() => {
       const ctx = wheel.current.getContext("2d");
       const elSpin = spin.current;
       const { startSpin, stopSpin, cleanup } = spinWheel(ctx, elSpin);
       startSpin();
       return () => {
         stopSpin();
         cleanup();
       }
     }, []);
    

Here's a link to a working sandbox: https://codesandbox.io/s/spin-wheel-forked-tb42zf?file=/src/components/SpinWheel/SpinWheel.js:546-826

  • Related