skip to Main Content

So as the title suggests I am rotating an element, nothing special. To do so I am using the PointerEvents. I do calculate an offset and update an initial value for the degree to make sure that the element does not jump on consecutive user actions, e.g. when you let loose of the element and start to rotate over again.
What I am struggling with is to add a few constraints. For example I would want to make sure that:

  • If N is pointing to north, allow only counter clockwise rotation.
  • Only if N is not pointing to north allow rotation in both directions until N is pointing to north.
  • Overall allow only one full turn.

The problem is not so much to set up the conditions for those constraints but rather that they do not seem to be working consistently while the pointermove event is being triggered.
For example for the first rule I did try to do something like this:

if (currentDegree + initialDegree - offsetDegree <= 0) {
  box.style.transform = `rotate(${
    currentDegree + initialDegree - offsetDegree
  }deg)`;
}

This does somewhat work but if I only move the pointer fast enough I can easily overcome the restriction. Sometimes the element even stops before N is pointing to north, so I am wondering if that is even the right approach to the problem.
I am posting a fiddle with no constraints so you can get an idea on what I am trying to do.
I would appreciate any advice, but suggesting a library which would help me to implement those rules is not what I need. Thanks in advance.

const box = document.querySelector('.box');
const { x, y, width } = box.getBoundingClientRect();
let boxCenter = width / 2;
let boxXPos = x;
let boxYPos = y;
let initialDegree = 0;
let offsetDegree = 0;
let currentDegree = 0;

function calcDegree(x, y) {
  const degree = Math.floor(Math.atan2(y, x) * (180 / Math.PI) + 90);
  return degree < 0 ? 360 + degree : degree;
}

function onPointerMove(event) {
  const x = event.clientX - boxXPos - boxCenter;
  const y = event.clientY - boxYPos - boxCenter;
  currentDegree = calcDegree(x, y);

  box.style.transform = `rotate(${
    currentDegree + initialDegree - offsetDegree
  }deg)`;
}

box.addEventListener('pointerdown', (event) => {
  box.setPointerCapture(event.pointerId);
  const x = event.clientX - boxXPos - boxCenter;
  const y = event.clientY - boxYPos - boxCenter;
  offsetDegree = calcDegree(x, y);

  box.addEventListener('pointermove', onPointerMove);
  box.addEventListener(
    'pointerup',
    () => {
      initialDegree += currentDegree - offsetDegree;
      box.removeEventListener('pointermove', onPointerMove);
    },
    { once: true }
  );
});

window.addEventListener('resize', () => {
  const { x, y } = box.getBoundingClientRect();
  boxXPos = x;
  boxYPos = y;
});
body {
  margin: 0;
  height: 100vh;
}

#app {
  display: grid;
  place-items: center;
  height: inherit;
}

.box {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  grid-template-rows: repeat(3, 1fr);
  align-items: center;
  justify-items: center;
  width: 200px;
  height: 200px;
  background-color: #bbd6b8;
  border: 1px solid #94af9f;
  cursor: move;
}

.direction {
  width: 50px;
  height: 50px;
  background-color: #333;
  border-radius: 50%;
  color: white;
  text-align: center;
  line-height: 50px;
  user-select: none;
}

.north {
  grid-column: 2;
  grid-row: 1;
}

.east {
  grid-column: 3;
  grid-row: 2;
}

.south {
  grid-column: 2;
  grid-row: 3;
}

.west {
  grid-column: 1;
  grid-row: 2;
}
<div id="app">
  <div class="box">
    <div class="direction north">N</div>
    <div class="direction east">E</div>
    <div class="direction south">S</div>
    <div class="direction west">W</div>
  </div>
</div>

2

Answers


  1. Your solution depends on the pointer location wrt box center. The closer you are to the center, the curvature (dS/dTheta) of your pointer trail will increase, thus you cross quadrants (0-90,90-180,180-270,270-360) very fast and the box will spin erratic. You can certainly fix your code to put proper restraints on these values.

    I prefer going stepwise in these kind of tasks. That makes it a bit easier and pixel independent. I also throttled the mouseover to 60fps. Replace the mouse event with the pointer ones if you wish.

    In the example below you can adjust dRotMax, dRotMin and rotStep inside resize event if you wanted to.

    !function(){
      const box = document.querySelector('.box');
      let rotStarted = false,
          busy = false,
          prevX = null,
          prevY = null,
          currRot = 360,
          rotStep = 1,
          dRotMax = 10,
          dRotMin = -10;
      window.addEventListener("mousedown", () => rotStarted = true)
      window.addEventListener("mouseup", () => {
        prevX = null;
        prevY = null;
        rotStarted = false;
      })
      window.addEventListener("mousemove", function(e) {
        if(!rotStarted){return}
        if(busy){return}
        busy = true;
        window.requestAnimationFrame(()=> busy = false)
        prevX = prevX ?? e.clientX;
        prevY = prevY ?? e.clientY;
        let dX = -prevX + (prevX = e.clientX),
            dY = -prevY + (prevY = e.clientY),
            dRot = Math.max(dRotMin, Math.min(dRotMax, (dX - dY) * rotStep));
        currRot = Math.max(0, Math.min(360, dRot + currRot))
        box.style.transform = `rotate(${currRot}deg)`
        //console.log(dX, dY, dRot)
      })
    }()
    body {
      margin: 0;
      height: 100vh;
    }
    
    #app {
      display: grid;
      place-items: center;
      height: inherit;
    }
    
    .box {
      display: grid;
      grid-template-columns: repeat(3, 1fr);
      grid-template-rows: repeat(3, 1fr);
      align-items: center;
      justify-items: center;
      width: 200px;
      height: 200px;
      background-color: #bbd6b8;
      border: 1px solid #94af9f;
      cursor: move;
    }
    
    .direction {
      width: 50px;
      height: 50px;
      background-color: #333;
      border-radius: 50%;
      color: white;
      text-align: center;
      line-height: 50px;
      user-select: none;
    }
    
    .north {
      grid-column: 2;
      grid-row: 1;
    }
    
    .east {
      grid-column: 3;
      grid-row: 2;
    }
    
    .south {
      grid-column: 2;
      grid-row: 3;
    }
    
    .west {
      grid-column: 1;
      grid-row: 2;
    }
    <div id="app">
      <div class="box">
        <div class="direction north">N</div>
        <div class="direction east">E</div>
        <div class="direction south">S</div>
        <div class="direction west">W</div>
      </div>
    </div>

    If you want to change how axis control the rotation based on angle quadrants, then convert rotStep into a function. The main advantage is we get rid of getBoundingClientRect and atan2 all together.

    !function(){
      const box = document.querySelector('.box');
      let rotStarted = false,
          busy = false,
          prevX = null,
          prevY = null,
          currRot = 360,
          epsilon = 10e-1,
          rotStep = (r, axis) => { //0 for x, 1 for y
            let quad = r / 90,
                //at transition points, disable 1 axis to prevent jitter
                isTransition = Math.abs(Math.round(quad) - quad) <= epsilon;
            switch(quad | 0) {
              case 0:
                return 1
              case 1: //3 o'clock, 
                return axis 
                  ? 1 
                  : isTransition ? 0 : -1
              case 2: //6 o'clock
                return axis 
                  ? isTransition ? 0 : -1 
                  : -1
              case 3: //9 o'clock
              case 4:
                return axis 
                  ? -1 
                  : isTransition ? 0 : 1
            }
          },
          dRotMax = 10,
          dRotMin = -10;
      window.addEventListener("mousedown", () => rotStarted = true)
      window.addEventListener("mouseup", () => {
        prevX = null;
        prevY = null;
        rotStarted = false;
      })
      window.addEventListener("mousemove", function(e) {
        if(!rotStarted){return}
        if(busy){return}
        busy = true;
        window.requestAnimationFrame(()=> busy = false)
        prevX = prevX ?? e.clientX;
        prevY = prevY ?? e.clientY;
        let dX = -prevX + (prevX = e.clientX),
            dY = -prevY + (prevY = e.clientY),
            dRot = Math.max(dRotMin, Math.min(dRotMax, dX * rotStep(currRot, 0) + dY * rotStep(currRot, 1)));
        currRot = Math.max(0, Math.min(360, dRot + currRot))
        box.style.transform = `rotate(${currRot}deg)`
        //console.log(dX, dY, dRot)
      })
    }()
    body {
      margin: 0;
      height: 100vh;
    }
    
    #app {
      display: grid;
      place-items: center;
      height: inherit;
    }
    
    .box {
      display: grid;
      grid-template-columns: repeat(3, 1fr);
      grid-template-rows: repeat(3, 1fr);
      align-items: center;
      justify-items: center;
      width: 200px;
      height: 200px;
      background-color: #bbd6b8;
      border: 1px solid #94af9f;
      cursor: move;
    }
    
    .direction {
      width: 50px;
      height: 50px;
      background-color: #333;
      border-radius: 50%;
      color: white;
      text-align: center;
      line-height: 50px;
      user-select: none;
    }
    
    .north {
      grid-column: 2;
      grid-row: 1;
    }
    
    .east {
      grid-column: 3;
      grid-row: 2;
    }
    
    .south {
      grid-column: 2;
      grid-row: 3;
    }
    
    .west {
      grid-column: 1;
      grid-row: 2;
    }
    <div id="app">
      <div class="box">
        <div class="direction north">N</div>
        <div class="direction east">E</div>
        <div class="direction south">S</div>
        <div class="direction west">W</div>
      </div>
    </div>
    Login or Signup to reply.
  2. You can limit the angle of "North" to -360 and 0 degrees (clamping). This is negative because you want to allow a counter clockwise move from the initial position, which is a moving in negative direction. If it were clockwise, the range would have to be [0, 360].

    To know which way to update the current angle, you could check the difference that the last pointer move made and interpret a difference of less than 180 degrees as a clockwise move, and anything else a counter clockwise move. This will determine how the current angle should be updated (by increasing it or decreasing it). This means that angle could exceed the range we want to stay within, i.e. [-360, 0]. If that gets exceeded, you would need to re-calibrate the global variable(s) that are involved.

    To help myself understand the meaning of the global variables involved, I named one pointerToNorthDegree, which is the angle between the location of "North" and the Pointer. This angle is to remain constant when the users drags the pointer (because North will move accordingly). Only when there is an overrun, this angle needs to be updated without moving North.

    There was one doubt I had: if the user makes a full counter clockwise turn, putting North again at the top, releases the pointer, and then wants to repeat that operation again, would they still only be allowed to turn counter clock wise, or would they now need to "unwind" and actually move in clockwise direction. The difference is one line of code, which I have marked with a comment.

    I added some other comments as well in the updated code below:

    const box = document.querySelector('.box');
    const { x, y, width } = box.getBoundingClientRect();
    const boxCenter = width / 2;
    let boxXPos = x;
    let boxYPos = y;
    let pointerToNorthDegree; // degrees of where north is relative to where pointer is
    let northDegree = 0; // degrees of where north is relative to top
    
    // Map any angle to range 0..359
    const mod = ang => ((ang % 360) + 360) % 360;
    // Clamp a value between min/max range
    const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
    
    const calcDegree = (x, y) => mod(Math.floor(Math.atan2(y, x) * (180 / Math.PI) + 90));
    
    const getPos = (e) => [e.clientX - boxXPos - boxCenter, e.clientY - boxYPos - boxCenter];
    
    function onPointerMove(event) {
      const pointerDegree = calcDegree(...getPos(event)); 
      // Calculate a relative angle change in the range -180..179, 
      //   and determine where north should be. It will get an 
      //   angle that might exceed 0..359 range, which can be used to
      //   detect an overrun:
      northDegree += mod(pointerDegree + pointerToNorthDegree - northDegree + 180) - 180;
      // Should not rotate beyond home position in either direction:
      const clampedNorthDegree = clamp(northDegree, -360, 0); // This range allows a full CCW turn from 0 degrees
      if (northDegree != clampedNorthDegree) {
        // If cursor was moving further than allowed, then use the current degree 
        //    as the new offset, so that if the cursor moves in the opposite 
        //    direction, rotation will happen immediately from that reference onwards.
        northDegree = clampedNorthDegree;
        pointerToNorthDegree = northDegree - pointerDegree;
      }
      box.style.transform = `rotate(${northDegree}deg)`;
    }
    
    box.addEventListener('pointerdown', (event) => {
      const pointerDegree = calcDegree(...getPos(event));
      // Remove next line if you don't want to allow another CCW turn from the home position 
      //    when the previous operation ended at north via a CCW turn.
      if (northDegree == -360) northDegree = 0;
      pointerToNorthDegree = northDegree - pointerDegree;
      box.setPointerCapture(event.pointerId);
      box.addEventListener('pointermove', onPointerMove);
      box.addEventListener('pointerup', () => box.removeEventListener('pointermove', onPointerMove));
    });
    
    window.addEventListener('resize', () => ({ x: boxXPos, y: boxYPos } = box.getBoundingClientRect()));
    body {
      margin: 0;
      height: 100vh;
    }
    
    #app {
      display: grid;
      place-items: center;
      height: inherit;
    }
    
    .box {
      display: grid;
      grid-template-columns: repeat(3, 1fr);
      grid-template-rows: repeat(3, 1fr);
      align-items: center;
      justify-items: center;
      width: 200px;
      height: 200px;
      background-color: #bbd6b8;
      border: 1px solid #94af9f;
      cursor: move;
    }
    
    .direction {
      width: 50px;
      height: 50px;
      background-color: #333;
      border-radius: 50%;
      color: white;
      text-align: center;
      line-height: 50px;
      user-select: none;
    }
    
    .north {
      grid-column: 2;
      grid-row: 1;
    }
    
    .east {
      grid-column: 3;
      grid-row: 2;
    }
    
    .south {
      grid-column: 2;
      grid-row: 3;
    }
    
    .west {
      grid-column: 1;
      grid-row: 2;
    }
    <div id="app">
      <div class="box">
        <div class="direction north">N</div>
        <div class="direction east">E</div>
        <div class="direction south">S</div>
        <div class="direction west">W</div>
      </div>
    </div>
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search