Search code examples
javascriptanimationmultiplicationmodulo

JavaScript Smoother Multiplication Circle


The Idea

I came across this idea of multiplication circles from a YouTube video that I stumbled upon and I thought that would be a fun thing to try and recreate using JavasSript and the canvas element. The Original Video

The Problem

I smoothed out the animation the best I could but it still doesn't look as proper as I'd like. I suspect coming up with a solution would require a decent amount of math. To grasp the problem in detail I think it's easier to look at the code

window.onload = () => {
  const app = document.querySelector('#app')
  const canvas = document.createElement('canvas')
  const ctx = canvas.getContext('2d')
  const { cos, sin, PI } = Math
  const Tao = PI * 2
  const width = window.innerWidth
  const height = window.innerHeight
  const cx = width / 2
  const cy = height / 2
  const baseNumberingSystem = 200
  const stop = 34
  let color = 'teal'
  let multiplier = 0
  let drawQue = []

  // setup canvas
  canvas.width = width
  canvas.height = height

  class Circle {
    constructor(x, y, r, strokeColor, fillColor) {
      this.x = x
      this.y = y
      this.r = r
      this.strokeColor = strokeColor || '#fff'
      this.fillColor = fillColor || '#fff'
    }

    draw(stroke, fill) {
      ctx.moveTo(this.x, this.y)
      ctx.beginPath()
      ctx.arc(this.x, this.y, this.r, 0, Tao)
      ctx.closePath()

      if (fill) {
        ctx.fillStyle = this.fillColor
        ctx.fill()
      }

      if (stroke) {
        ctx.strokeStyle = this.strokeColor
        ctx.stroke()
      }
    }

    createChildCircleAt(degree, radius, strokeColor, fillColor) {
      const radian = degreeToRadian(degree)
      const x = this.x + (this.r * cos(radian))
      const y = this.y + (this.r * sin(radian))
      return new Circle(x, y, radius, strokeColor, fillColor)
    }

    divideCircle(nTimes, radius) {
      const degree = 360 / nTimes
      let division = 1;
      while (division <= nTimes) {
        drawQue.push(this.createChildCircleAt(division * degree, radius))
        division++
      }
    }
  }

  function degreeToRadian(degree) {
    return degree * (PI / 180)
  }

  function draw() {
    const mainCircle = new Circle(cx, cy, cy * 0.9)
    
    // empty the que
    drawQue = []
    // clear canvas
    ctx.clearRect(0, 0, width, height)
    ctx.fillStyle = "black"
    ctx.fillRect(0, 0, width, height)

    // redraw everything
    mainCircle.draw()
    mainCircle.divideCircle(baseNumberingSystem, 4)
    drawQue.forEach(item => item.draw())

    // draw modular times table
    for (let i = 1; i <= drawQue.length; i++) {
      const product = i * multiplier;
      const firstPoint = drawQue[i]
      const secondPoint = drawQue[product % drawQue.length]

      if (firstPoint && secondPoint) {
        ctx.beginPath()
        ctx.moveTo(firstPoint.x, firstPoint.y)
        ctx.strokeStyle = color
        ctx.lineTo(secondPoint.x, secondPoint.y)
        ctx.closePath()
        ctx.stroke()
      }
    }
  }

  function animate() {
    multiplier+= 0.1
    multiplier = parseFloat(multiplier.toFixed(2))
    draw()
  
    console.log(multiplier, stop)

    if (multiplier === stop) {
      clearInterval(animation)
    }
  }

  app.appendChild(canvas)
  let animation = setInterval(animate, 120)
}

So the main issue comes from when we increment the multiplier by values less than 1 in an attempt to make the animation more fluid feeling. Example: multiplier+= 0.1. When we do this it increase the amount of times our if block in our draw function will fail because the secondPoint will return null.

      const product = i * multiplier; // this is sometimes a decimal
      const firstPoint = drawQue[i]
      const secondPoint = drawQue[product % drawQue.length] // therefore this will often not be found

      // Then this if block wont execute. Which is good because if it did we the code would crash
      // But I think what we need is an if clause to still draw a line to a value in between the two
      // closest indices of our drawQue 
      if (firstPoint && secondPoint) {
        //...
      }

Possible Solution

I think what I'd need to do is when we fail to find the secondPoint get the remainder of product % drawQue.length and use that to create a new circle in between the two closest circles in the drawQue array and use that new circle as the second point of our line.


Solution

  • My possible solution ended up working. I'll leave the added else if block here for anyone whos interested. I also had to store the degree value in my circle objects when they were made as well as calculate the distance between each subdivision of the circle.

    Added If Else Statement

    for (let i = 1; i <= drawQue.length; i++) {
      const product = i * multiplier;
      const newIndex = product % drawQue.length
      const firstPoint = drawQue[i]
      const secondPoint = drawQue[newIndex]
    
      if (firstPoint && secondPoint) {
        ctx.beginPath()
        ctx.moveTo(firstPoint.x, firstPoint.y)
        ctx.strokeStyle = color
        ctx.lineTo(secondPoint.x, secondPoint.y)
        ctx.closePath()
        ctx.stroke()
      } else if (!secondPoint) {
        const percent = newIndex % 1
        const closest = drawQue[Math.floor(newIndex)]
        const newDegree = closest.degree + (degreeIncriments * percent)
        const target = mainCircle.createChildCircleAt(newDegree, 4)
    
        if (firstPoint && target) {
          ctx.beginPath()
          ctx.moveTo(firstPoint.x, firstPoint.y)
          ctx.strokeStyle = color
          ctx.lineTo(target.x, target.y)
          ctx.closePath()
          ctx.stroke()
        }
      }
    

    Other changes

      // ...
      const degreeIncriments = 360 / baseNumberingSystem
      // ... 
      class Circle {
        constructor(/* ... */, degree )
          // ... 
          this.degree = degree || 0
      }
    

    Hope someone finds this useful...