The white curve below is a spirograph generated by drawing a line through the centroid of a series of spinning points. Each point allows adjustments to the rotation speed, initial angular offset, and radial distance from an origin. As the points spin, the position at the geometric mean of the points – the centroid – traces the path of the spirograph.

Presets

Each point has an origin at [x, y], and is offset from the origin at radius r. The point spins at a rate of s, with an initial rotation of z. The Cartesian coordinates of any point at time t can be found using the following formula.

const px = x + Math.cos(Math.PI * 2 * (t * s) + z) * r
const py = y + Math.sin(Math.PI * 2 * (t * s) + z) * r

Modify the radius, angular offset, speed and position of each point to see how the spirograph changes. Holding down the shift key will snap to round values and produce more idealised forms. More organic shapes can be made by using less regimented values.

Points with higher speeds complete more revolutions compared to lower speeds. A negative speed causes a point to spin counter-clockwise. Any point with a speed of zero is ignored when computing the centroid.

Because the spirograph is drawn at the centroid of all points, changing the origin of any point will only translate the resulting path – it won’t affect the overall shape. However, it does allows the “gears” of the spirograph to be moved freely around the canvas for easier manipulation.

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

const tau = Math.PI * 2

// Array from 0 to 1 to map to points along the curve
const pathCount = 1000
const pathTimes = Array.from({ length: pathCount }, (e, i) => i / pathCount)

// Average position of all points on the hull
const centroid = (hull) =>
  hull.reduce(
    (m, a) => [m[0] + a[0] / hull.length, m[1] + a[1] / hull.length],
    [0, 0]
  )

// A point at time t spinning at speed s around
// origin [x, y] with radius r, offset by angle z.
const spin = (x, y, z, r, s, t) => [
  x + Math.cos(tau * (t * s) + z) * r,
  y + Math.sin(tau * (t * s) + z) * r,
]

// Convert the nodes to their articulated points at time t
const hull = (nodes, t) =>
  nodes.map((a) =>
    spin(
      +a.dataset.x,
      +a.dataset.y,
      +a.dataset.z,
      +a.dataset.r,
      +a.dataset.s,
      t
    )
  )

// Calculate discrete points along the articulated centroid
const path = (nodes) => pathTimes.map((t) => centroid(hull(nodes, t)))

const draw = ({ ctx, hull, path }) => {
  const [cx, cy] = centroid(hull)
  const pointRadius = 3

  ctx.lineJoin = "round"

  // Draw line around nodes
  ctx.beginPath()

  hull.forEach(([x, y]) => {
    ctx.lineTo(x, y)
  })

  ctx.closePath()

  // Draw lines from nodes to centroid
  hull.forEach(([x, y]) => {
    ctx.moveTo(x, y)
    ctx.lineTo(cx, cy)
  })

  ctx.strokeStyle = palette.muted
  ctx.lineWidth = 1
  ctx.stroke()

  // Draw each point
  ctx.beginPath()
  hull.forEach(([x, y]) => {
    ctx.moveTo(x + pointRadius, y)
    ctx.arc(x, y, pointRadius, 0, tau)
  })

  ctx.fillStyle = palette.muted
  ctx.fill()

  // Draw point at centroid
  ctx.beginPath()
  ctx.moveTo(cx + pointRadius, cy)
  ctx.arc(cx, cy, pointRadius, 0, tau)

  ctx.fillStyle = palette.canvas
  ctx.fill()

  // Draw articulated path
  ctx.beginPath()
  path.forEach(([x, y]) => ctx.lineTo(x, y))
  ctx.closePath()

  ctx.strokeStyle = palette.canvas
  ctx.lineWidth = 3
  ctx.stroke()
}

export default (el) => {
  const readyEvent = new Event("ready")
  const canvas = el.querySelector("canvas")
  const ctx = canvas.getContext("2d")

  const scale = window.devicePixelRatio

  const w = el.offsetWidth
  const h = el.offsetHeight

  canvas.width = w * scale
  canvas.height = h * scale

  const tx = canvas.width / 2
  const ty = canvas.height / 2

  ctx.translate(tx, ty)
  ctx.scale(scale, scale)

  const svg = el.querySelector("svg")
  const circles = Array.from(el.querySelectorAll(".circle"))

  circles.forEach((c) => (c.dataset.f = w / svg.viewBox.baseVal.width))

  svg.setAttribute("viewBox", `${-w / 2} ${-h / 2} ${w} ${h}`)

  circles.forEach((c) => c.dispatchEvent(readyEvent))

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

  let active = []
  let points = []

  const reset = () => {
    active = circles.filter((a) => a.dataset.s !== "0")

    points = helpers.arrayify(
      helpers.simplify(helpers.objectify(path(active)), 0.1, true)
    )

    duration = 7000 * Math.max(...active.map((a) => Math.abs(a.dataset.s)))
  }

  reset()
  ;(function loop() {
    if (playing) {
      const time = (Date.now() - start) / duration

      ctx.clearRect(-w / 2, -h / 2, w, h)

      draw({ ctx, hull: hull(active, time), path: points })

      window.requestAnimationFrame(loop)
    }
  })()

  const click = (e) => {
    if (e.target.matches("#copy")) {
      const data = circles.map((e) => ({
        x: +e.dataset.x,
        y: +e.dataset.y,
        z: +e.dataset.s ? +e.dataset.z : 0,
        r: +e.dataset.s ? +e.dataset.r : 100,
        s: +e.dataset.s,
      }))

      const path = points.reduce(
        (m, e, i, a) =>
          m +
          (i === 0 ? "M" : "L") +
          e.map((n) => +n.toFixed(4)).join() +
          (i === a.length - 1 ? "Z" : ""),
        ""
      )

      console.log(JSON.stringify(data))
      console.log(`<svg><path d="${path}" /></svg>`)
    }

    if (e.target.matches(".preset")) {
      const data = JSON.parse(e.target.dataset.list)

      circles.forEach((circle, i) => {
        Object.entries(data[i]).forEach(([key, val]) => {
          circle.dataset[key] = ["x", "y"].includes(key) ? (val / 800) * w : val
        })

        circle.dispatchEvent(readyEvent)
      })
    }
  }

  el.addEventListener("reset", reset)

  el.addEventListener("click", click)

  return {
    cancel: () => {
      el.removeEventListener("click", click)
      el.removeEventListener("reset", reset)

      playing = false
    },
  }
}