# Centroidal Spirograph

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"

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 + a / hull.length, m + a / 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)

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.fillStyle = palette.muted
ctx.fill()

// Draw point at centroid
ctx.beginPath()

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 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}`)

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
})

})
}
}