La Hire lines

Turning circle, spoked hypocycloids.

Take two circles, one whose radius is half that of the other. Place the smaller circle inside and roll it around the circumference of the larger circle. If you take a point on the circumference of the smaller circle and follow its movement, it will trace a line through the centre of the larger circle.

You can create the illusion of the smaller circle rolling inside the larger one by working backwards. Divide the larger circle into evenly rotated spokes, and move a dot back and forth along each spoke. If the movement of each dot uses circular easing and is delayed by equal amounts of time, the dots combine visually to form the smaller circle.

La Hire line is a term borrowed from Robert Ferréol.

import palette from "/scratchpad/_lib/palette.asset.mjs"

const tau = Math.PI * 2

const spokes = Array.from({ length: 12 }, (el, i) => i)

const draw = {
  concept: ({ ctx, r0, r1, time }) => {
    const r2 = r1 / 2

    const x1 = Math.cos(tau * time) * r2
    const y1 = Math.sin(tau * time) * r2

    const x2 = x1 + Math.cos(tau * (-time + 0.5)) * r2
    const y2 = y1 + Math.sin(tau * (-time + 0.5)) * r2

    ctx.beginPath()

    ctx.moveTo(r1, 0)
    ctx.arc(0, 0, r1, 0, tau)

    ctx.moveTo(x1 + r2, y1)
    ctx.arc(x1, y1, r2, 0, tau)

    ctx.moveTo(0, -r1)
    ctx.lineTo(0, +r1)

    ctx.stroke()

    ctx.beginPath()
    ctx.moveTo(x2 + r0, y2)
    ctx.arc(x2, y2, r0, 0, tau)
    ctx.fill()
  },

  illusion: ({ ctx, r0, r1, time }) => {
    ctx.beginPath()
    ctx.moveTo(r1, 0)
    ctx.arc(0, 0, r1, 0, tau)

    spokes.forEach((i) => {
      const t = i / (spokes.length * 2)

      const dx = Math.cos(tau * t) * r1
      const dy = Math.sin(tau * t) * r1

      ctx.moveTo(-dx, -dy)
      ctx.lineTo(+dx, +dy)
    })

    ctx.stroke()

    ctx.beginPath()

    spokes.forEach((i) => {
      const t = i / (spokes.length * 2)

      const tx = Math.sin(tau * (time + t)) * Math.cos(tau * t) * r1
      const ty = Math.sin(tau * (time + t)) * Math.sin(tau * t) * r1

      ctx.moveTo(tx, 0)
      ctx.arc(tx, ty, r0, 0, tau)
    })

    ctx.fill()
  },
}

export default (canvas) => {
  const fn = draw[canvas.dataset.draw]
  const ctx = canvas.getContext("2d")

  let playing = true
  let start = Date.now()
  let duration = 5000

  ;(function loop() {
    if (playing) {
      canvas.width = canvas.offsetWidth * window.devicePixelRatio
      canvas.height = canvas.offsetHeight * window.devicePixelRatio

      const size = Math.min(canvas.width, canvas.height)

      const r0 = size / 40
      const r1 = size / 2 - r0
      const [cx, cy] = [canvas.width / 2, canvas.height / 2]

      const time = Math.min((Date.now() - start) / duration, 1)

      ctx.fillStyle = palette.canvas
      ctx.strokeStyle = palette.muted
      ctx.lineWidth = 3

      ctx.clearRect(0, 0, canvas.width, canvas.height)
      ctx.save()
      ctx.translate(cx, cy)

      fn({ ctx, r0, r1, time })

      ctx.restore()

      if (time === 1) start = Date.now()

      window.requestAnimationFrame(loop)
    }
  })()

  return { cancel: () => (playing = false) }
}