How to rotate square following mouse by mouse movement angle?

Viewed 336

I have square that follows my cursor. Its border top is red to see if the rotation is right. I'm trying to rotate it depending on mouse movement angle. Like if mouse goes 45deg top right then square must rotate by 45deg. The problem is that when I move my mouse slowly the square starts to rotate like crazy. But if I move my mouse fast enough square rotates pretty smooth.

Actually it's just a part of my task that I'm trying to accomplish. My whole task is to make custom circle cursor that stretches when mouse moving. The idea I'm trying to implement: rotate circle by mouse movement angle and then just scaleX it to make stretching effect. But I cannot do it because of problem I described above. I need my follower to rotate smoothly when mouse speed is slow.

class Cursor {
    constructor() {
        this.prevX = null;
        this.prevY = null;
        this.curX = null;
        this.curY = null;
        this.angle = null;

        this.container = document.querySelector(".cursor");
        this.follower = this.container.querySelector(".cursor-follower");

        document.addEventListener("mousemove", (event) => {
            this.curX = event.clientX;
            this.curY = event.clientY;
        });

        this.position();
    }


    position(timestamp) {
        this.follower.style.top = `${this.curY}px`;
        this.follower.style.left = `${this.curX}px`;


        this.angle = Math.atan2(this.curY - this.prevY, this.curX - this.prevX) * 180/Math.PI;
        console.log(this.angle + 90);

        this.follower.style.transform = `rotateZ(${this.angle + 90}deg)`;

        this.prevX = this.curX;
        this.prevY = this.curY;

        requestAnimationFrame(this.position.bind(this));
    }
}


        const cursor = new Cursor();
.cursor-follower {
    position: fixed;
    top: 0;
    left: 0;
    z-index: 9999;

    pointer-events: none;
    user-select: none;

    width: 76px;
    height: 76px;
    margin: -38px;
    border: 1.5px solid #000;

    border-top: 1.5px solid red;
}
<div class="cursor">
  <div class="cursor-follower"></div>
</div>

2 Answers

Following the cursor tangent smoothly isn't as simple as it first feels. In modern browsers mousemove event fires nearby at the frame rate (typically 60 FPS). When the mouse is moving slowly, the cursor moves only a pixel or two between the events. When calculating the angle, vertical + horizontal move of 1px resolves to 45deg. Then there's another problem, the event firing rate is not consistent, during the mouse is moving, event firing rate can drop to 30 FPS or even to 24 FPS, which actually helps to get more accurate angle, but makes the scale calculations heavily inaccurate (your real task seems to need scale calculations too).

One solution is to use CSS Transitions to make the animation smoother. However, adding a transition makes the angle calculations much more complex, because the jumps between negative and positive angles Math.atan2 returns when crossing PI will become visible when using transition.

Here's a sample code of how to use transition to make the cursor follower smoother.

class Follower {
  // Default options
  threshold = 4;
  smoothness = 10;
  stretchRate = 100;
  stretchMax = 100;
  stretchSlow = 100;
  baseAngle = Math.PI / 2;
  // Class initialization
  initialized = false;
  // Listens mousemove event
  static moveCursor (e) {
    if (Follower.active) {
      Follower.prototype.crsrMove.call(Follower.active, e);
    }
  }
  static active = null;
  // Adds/removes mousemove listener
  static init () {
    if (this.initialized) {
      document.removeEventListener('mousemove', this.moveCursor);
      if (this.active) {
        this.active.cursor.classList.add('hidden');
      }
    } else {
      document.addEventListener('mousemove', this.moveCursor);
    }
    this.initialized = !this.initialized;
  }
  // Base values of instances
  x = -1000;
  y = -1000;
  angle = 0;
  restoreTimer = -1;
  stamp = 0;
  speed = [0];
  // Prototype properties
  constructor (selector) {
    this.cursor = document.querySelector(selector);
    this.restore = this.restore.bind(this);
  }
  // Activates a new cursor
  activate (options = {}) {
    // Remove the old cursor
    if (Follower.active) {
      Follower.active.cursor.classList.add('hidden');
      Follower.active.cursor.classList.remove('cursor', 'transitioned');
    }
    // Set the new cursor
    Object.assign(this, options);
    this.setCss = this.cursor.style.setProperty.bind(this.cursor.style);
    this.cursor.classList.remove('hidden');
    this.cHW = this.cursor.offsetWidth / 2;
    this.cHH = this.cursor.offsetHeight / 2;
    this.setCss('--smoothness', this.smoothness / 100 + 's');
    this.cursor.classList.add('cursor');
    setTimeout(() => this.cursor.classList.add('transitioned'), 0); // Snap to the current angle
    this.crsrMove({
      clientX: this.x,
      clientY: this.y
    });
    Follower.active = this;
    return this;
  }
  // Moves the cursor with effects
  crsrMove (e) {
    clearTimeout(this.restoreTimer); // Cancel reset timer
    const PI = Math.PI,
      pi = PI / 2,
      x = e.clientX,
      y = e.clientY,
      dX = x - this.x,
      dY = y - this.y,
      dist = Math.hypot(dX, dY);
    let rad = this.angle + this.baseAngle,
      dTime = e.timeStamp - this.stamp,
      len = this.speed.length,
      sSum = this.speed.reduce((a, s) => a += s),
      speed = dTime
        ? ((1000 / dTime) * dist + sSum) / len
        : this.speed[len - 1], // Old speed when dTime = 0
      scale = Math.min(
        this.stretchMax / 100,
        Math.max(speed / (500 - this.stretchRate || 1),
          this.stretchSlow / 100
        )
      );
    // Update base values and rotation angle
    if (isNaN(dTime)) {
      scale = this.scale;
    } // Prevents a snap of a new cursor
    if (len > 5) {
      this.speed.length = 1;
    }
    // Update angle only when mouse has moved enough from the previous update
    if (dist > this.threshold) {
      let angle = Math.atan2(dY, dX),
        dAngle = angle - this.angle,
        adAngle = Math.abs(dAngle),
        cw = 0;
      // Smoothen small angles
      if (adAngle < PI / 90) {
        angle += dAngle * 0.5;
      }
      // Crossing ±PI angles
      if (adAngle >= 3 * pi) {
        cw = -Math.sign(dAngle) * Math.sign(dX); // Rotation direction: -1 = CW, 1 = CCW
        angle += cw * 2 * PI - dAngle; // Restores the current position with negated angle
        // Update transform matrix without transition & rendering
        this.cursor.classList.remove('transitioned');
        this.setCss('--angle', `${angle + this.baseAngle}rad`);
        this.cursor.offsetWidth; // Matrix isn't updated without layout recalculation
        this.cursor.classList.add('transitioned');
        adAngle = 0; // The angle was handled, prevent further adjusts
      }
      // Orthogonal mouse turns
      if (adAngle >= pi && adAngle < 3 * pi) {
        this.cursor.classList.remove('transitioned');
        setTimeout(() => this.cursor.classList.add('transitioned'), 0);
      }
      rad = angle + this.baseAngle;
      this.x = x;
      this.y = y;
      this.angle = angle;
    }
    this.scale = scale;
    this.stamp = e.timeStamp;
    this.speed.push(speed);
    // Transform the cursor
    this.setCss('--angle', `${rad}rad`);
    this.setCss('--scale', `${scale}`);
    this.setCss('--tleft', `${x - this.cHW}px`);
    this.setCss('--ttop', `${y - this.cHH}px`);
    // Reset the cursor when mouse stops
    this.restoreTimer = setTimeout(this.restore, this.smoothness + 100, x, y);
  }
  // Returns the position parameters of the cursor
  position () {
    const {x, y, angle, scale, speed} = this;
    return {x, y, angle, scale, speed};
  }
  // Restores the cursor
  restore (x, y) {
    this.state = 0;
    this.setCss('--scale', 1);
    this.scale = 1;
    this.speed = [0];
    this.x = x;
    this.y = y;
  }
}
Follower.init();

const crsr = new Follower('.crsr').activate();
body {
  margin: 0px;
}

.crsr {
  width: 76px;
  height: 76px;
  border: 2px solid #000;
  border-radius: 0%;
  text-align: center;
  font-size: 20px;
}

.cursor {
  position: fixed;
  cursor: default;
  user-select: none;
  left: var(--tleft);
  top: var(--ttop);
  transform: rotate(var(--angle)) scaleY(var(--scale));
}

.transitioned {
  transition: transform var(--smoothness) linear;
}

.hidden {
  display: none;
}
<div class="crsr hidden">A</div>

The basic idea of the code is to wait until the mouse has moved enough pixels (threshold) to calculate the angle. The "mad circle" effect is tackled by setting the angle to the same position, but at the negated angle when crossing PI. This change is made invisibly between the renderings.

CSS variables are used for the actual values in transform, this allows to change a single parameter of the transform functions at the time, you don't have to rewrite the entire rule. setCss method is just syntactic sugar, it makes the code a little bit shorter.

The current parameters are showing a rectangle follower as it is in your question. Setting ex. stretchMax = 300 and stretchSlow = 125 and adding 50% border radius to CSS might be near to what you finally need. stretchRate defines the stretch related to the speed of the mouse. If the slow motion is still not smooth enough for your purposes, you can create a better algorithm to // Smoothen small angles section (in crsrMove method). You can play with the parameters at jsFiddle.

Try like this

class Cursor {
    constructor() {
        this.prevX = null;
        this.prevY = null;
        this.curX = null;
        this.curY = null;
        this.angle = null;

        this.container = document.querySelector(".cursor");
        this.follower = this.container.querySelector(".cursor-follower");

        document.addEventListener("mousemove", (event) => {
            this.curX = event.clientX;
            this.curY = event.clientY;
        });

        this.position();
    }


    position(timestamp) {
        this.follower.style.top = `${this.curY}px`;
        this.follower.style.left = `${this.curX}px`;


        if (this.curY !== this.prevY && this.curX !== this.prevX) {
        this.angle = Math.atan2(this.curY - this.prevY, this.curX - this.prevX) * 180/Math.PI;
        }
        console.log(this.angle + 90);

        this.follower.style.transform = `rotateZ(${this.angle + 90}deg)`;

        this.prevX = this.curX;
        this.prevY = this.curY;

        requestAnimationFrame(this.position.bind(this));
    }
}


        const cursor = new Cursor();
Related