Animação de partículas seguindo o mouse com Canvas

<canvas id="canvas"></canvas>
const NUMBER_OF_PARTICLES = 50;
const MAX_PARTICLE_SIZE = 20;
const MAX_SPEED = 5;
const TIMEOUT_FACTOR = 600 / NUMBER_OF_PARTICLES;

const canvas = document.querySelector("#canvas");

canvasResize(canvas);
window.addEventListener("resize", () => canvasResize(canvas));

const ctx = canvas.getContext("2d", {
  alpha: false
});

function Particle(options) {
  Object.assign(this, options);

  this.vy = Math.random() * MAX_SPEED - MAX_SPEED / 2;
  this.vx = Math.random() * MAX_SPEED - MAX_SPEED / 2;

  this.initialV = {
    x: Math.abs(this.vx),
    y: Math.abs(this.vy)
  };
}

Particle.prototype.moveToPoint = function (x, y, force = null) {
  const deltaX = (x - this.x) / canvas.width;
  const deltaY = (y - this.y) / canvas.width;

  force ??= this.getDistance(x, y) * 0.2;

  this.vx = deltaX * force;
  this.vy = deltaY * force;
};

Particle.prototype.nextMove = function () {
  const maxY = canvas.height - this.size;
  const maxX = canvas.width - this.size;
  const minX = this.size;
  const minY = this.size;

  // Isso aqui desacelera a parada
  this.vx =
    Math.sign(this.vx) * Math.max(this.initialV.x, Math.abs(this.vx) - 0.05);
  this.vy =
    Math.sign(this.vy) * Math.max(this.initialV.y, Math.abs(this.vy) - 0.05);

  this.x = Math.min(maxX, Math.max(this.x + this.vx, minX));
  this.y = Math.min(maxY, Math.max(this.y + this.vy, minY));

  if (this.y >= maxY || this.y <= minY) {
    this.vy *= -1;
  }

  if (this.x >= maxX || this.x <= minX) {
    this.vx *= -1;
  }

  return this;
};

Particle.prototype.draw = function (ctx) {
  const offset = canvas.width * 10;

  ctx.beginPath();
  ctx.fillStyle = this.color;
  ctx.shadowOffsetX = 2 + offset;
  ctx.shadowOffsetY = 2 + offset;
  ctx.shadowBlur = this.size;
  ctx.shadowColor = this.color;

  this.nextMove();

  ctx.arc(this.x - offset, this.y - offset, this.size, 0, Math.PI * 2, true);
  ctx.closePath();
  ctx.fill();
};
Particle.prototype.getRandomSpeed = function () {
  return Math.random() * MAX_SPEED - MAX_SPEED / 2;
};

Particle.prototype.getRelativePosition = function (x, y) {
  return { x: this.x - x, y: this.y - y };
};

Particle.prototype.getDistance = function (inputX, inputY) {
  const { x, y } = this.getRelativePosition(inputX, inputY);
  return Math.sqrt(x * x + y * y);
};

Particle.prototype.inPoints = function (x, y, radius = 1) {
  return this.getDistance(x, y) < this.size + radius;
};

const particles = Array.from({
  length: NUMBER_OF_PARTICLES
}).map(() => {
  return new Particle({
    y: Math.random() * canvas.height,
    x: Math.random() * canvas.width,
    color: "#ff00ff",
    size: Math.random() * MAX_PARTICLE_SIZE
  });
});

function nextFrame() {
  // isso remove o efeito de blur
  // context.clearRect(0,0,canvas.width, canvas.height);

  // com opacity menor que 1 fica o "blur motion"
  ctx.fillStyle = "rgba(0, 0, 0, 1)";
  ctx.fillRect(0, 0, canvas.width, canvas.height);

  particles.forEach((p) => p.draw(ctx));

  requestAnimationFrame(nextFrame);
}

function canvasResize(canvas) {
  canvas.width = window.innerWidth;
  canvas.height = window.innerHeight;
}

nextFrame();

const handleMovement = (eventX, eventY) => {
  const rect = canvas.getBoundingClientRect();
  const scaleX = canvas.width / rect.width;
  const scaleY = canvas.height / rect.height;

  let x = (eventX - rect.left) * scaleX;
  let y = (eventY - rect.top) * scaleY;

  particles.forEach((p, i) => {
    setTimeout(() => {
      p.moveToPoint(x, y);
    }, i * 10);
  });
};

canvas.addEventListener("mousemove", (e) =>
  handleMovement(e.clientX, e.clientY)
);

canvas.addEventListener("mousedown", (e) => {
  particles.forEach((particle, index) => {
    if (Math.abs(particle.vx) >= 10) return;

    particle.moveToPoint(e.clientX, e.clientY, -50);
  });
});

canvas.addEventListener(
  "touchmove",
  (e) => {
    const x = e.touches[0].clientX;
    const y = e.touches[0].clientY;
    handleMovement(x, y);
  },
  { passive: true }
);