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.
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.
Get occasional updates about new fonts, designs and other interesting things.
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
},
}
}