Skip to content

Instantly share code, notes, and snippets.

@johnburnmurdoch
Last active March 25, 2023 06:13
Show Gist options
  • Save johnburnmurdoch/309de23b678aba316f3b3d802a67967f to your computer and use it in GitHub Desktop.
Save johnburnmurdoch/309de23b678aba316f3b3d802a67967f to your computer and use it in GitHub Desktop.
Spirograph effect in HTML canvas
license: mit
height: 545
scrolling: no
border: no

Have a play around with the circle radii and pen placement to create your own artworks!

You can slow down the drawing speed to focus on the mechanics of the whole operation, or speed it up to get from zero to your own hypotrochoids as quickly as possible.

Hint(s):

  • See what happens if you reduce the inner circle's radius below zero(!) (clue)
  • In a deliberate mistake (😀), I made it possible to have the inner circle larger than the outer one. The bad news is, this is impossible in the real world. The good news is, it can produce cool results.

Created based on the parametric equations set out in the Wikipedia article. The grey outer ring, inner circle, and pen tip are displayed to show how the mechanics work, but these can be hidden if you just want to see the geometric patterns that result. Either way, when downloading an image, these representations of the physical objects are stripped out, leaving only the patterns.

<!doctype html>
<html lang="">
<head>
<meta charset="utf-8">
<title>Spirograph effect in HTML canvas</title>
<meta name="description" content="">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script type='text/javascript' src='https://unpkg.com/d3'></script>
<script type='text/javascript' src='https://unpkg.com/d3-selection-multi'></script>
<style>
body{
font-family: Arial, Helvetica, sans-serif;
}
#canvasHolder, input{
position: relative;
}
#circle{
position: relative;
background: black;
}
#spiro{
position: absolute;
left: 0;
}
#image{
position: absolute;
display: none;
}
#sliders div{
display: inline-block;
padding: 10px 5px 5px 0px;
}
button{
margin: 10px 5px 0 0;
padding: 5px;
border-radius: 3px;
}
#draw{
background: #419BF9;
color: white;
}
#draw[disabled]{
background: #97c8fc;
color: white;
}
input[type=checkbox], button{
cursor: pointer;
}
input[type=range]{
cursor: ew-resize;
}
input[type=checkbox][disabled], button[disabled], input[type=range][disabled]{
cursor: not-allowed;
}
#note{
opacity: 0.6;
font: 400 11px system-ui;
}
</style>
</head>
<body>
<div id=canvasHolder>
<canvas id=circle></canvas>
<canvas id=spiro></canvas>
<canvas id=image></canvas>
</div>
Show circles <input type=checkbox id=showCircles checked>
<div id=sliders>
<div>
<span>Set outer circle size</span><br>
<input type=range min=50 max=190 value=150 id=bigCircle>
</div>
<div>
<span>Set inner circle size</span><br>
<input type=range min=-100 max=100 value=52 id=smallCircle>
</div>
<div>
<span>Set pen position</span><br>
<input type=range min=0 max=1 value=0.6 step=any id=p>
</div>
<div>
<span>Set duration of one circuit</span><br>
<input type=range min=1000 max=5000 value=2000 id=loopTime>
</div>
</div>
</div>
<button id=draw>Draw</button>
<a id=save><button>Save image*</button></a>
<button id=reset>Reset</button>
<br><br>
<span id=note>* Saved image removes outer and inner grey circles, keeping only the spirograph drawing</span>
<script type='text/javascript'>
let width = 400,
height = 400,
PR = window.devicePixelRatio || 1,
scaledWidth = width*PR,
scaledHeight = height*PR,
showCircles = true,
loopTime = 2000,
tickTime = 10,
R = 150, r = 52, l = 0.6,
p = l*r,
k = r/R,
t;
let backContext = d3.select('canvas#circle')
.attrs({
width: scaledWidth,
height: scaledHeight,
})
.styles({
width: `${width}px`,
height: `${height}px`
})
.node().getContext("2d");
let context = d3.select('canvas#spiro')
.attrs({
width: scaledWidth,
height: scaledHeight,
})
.styles({
width: `${width}px`,
height: `${height}px`
})
.node().getContext("2d");
let contextIMG = d3.select('canvas#image')
.attrs({
width: scaledWidth,
height: scaledHeight,
})
.styles({
width: `${width}px`,
height: `${height}px`
})
.node().getContext("2d");
backContext.scale(PR, PR);
context.scale(PR, PR);
contextIMG.scale(PR, PR);
function pinPoint(angle){
return [
(R*( ((1-k)*(Math.cos(angle))) + (l*k*Math.cos(((1-k)/k)*angle)) )) + (width/2),
(R*( ((1-k)*(Math.sin(angle))) - (l*k*Math.sin(((1-k)/k)*angle)) )) + (height/2)
]
}
let p0 = [(width/2)+(R-r)+p,height/2];
backContext.strokeStyle = 'gray';
backContext.fillStyle = 'rgba(255, 255, 255, 0.2)';
context.strokeStyle = 'gold';
function initCircles(){
p = l*r;
k = r/R;
p0 = [(width/2)+(R-r)+p,height/2];
backContext.clearRect(0, 0, width, height);
backContext.beginPath();
backContext.arc(width/2, height/2, R, 0, Math.PI * 2);
backContext.stroke();
backContext.beginPath();
backContext.arc((R-r)*Math.cos(0) + (width/2), (R-r)*Math.sin(0) + (height/2), Math.abs(r), 0, Math.PI * 2);
backContext.fill();
backContext.fillStyle = 'gold';
backContext.beginPath();
backContext.arc(...p0, 3, 0, Math.PI * 2);
backContext.fill();
backContext.fillStyle = 'rgba(255, 255, 255, 0.2)';
}
d3.select('#bigCircle').on('input', () => {
R = d3.select('#bigCircle').node().value;
initCircles();
});
d3.select('#smallCircle').on('input', () => {
r = d3.select('#smallCircle').node().value;
initCircles();
});
d3.select('#p').on('input', () => {
l = d3.select('#p').node().value;
initCircles();
});
d3.select('#loopTime').on('input', () => {
loopTime = d3.select('#loopTime').node().value;
});
d3.select('#showCircles').on('click', () => {
showCircles = d3.select('#showCircles').node().checked;
initCircles();
if(showCircles == false) backContext.clearRect(0, 0, width, height);
});
initCircles();
d3.select('#draw').on('click', () => {
d3.selectAll('#draw, #showCircles, #sliders input').attr('disabled', 1);
t = d3.interval((e) => {
let a = (e/loopTime) * Math.PI * 2;
let xy = pinPoint(a);
if(showCircles == true){
backContext.clearRect(0, 0, width, height);
backContext.beginPath();
backContext.arc(width/2, height/2, R, 0, Math.PI * 2);
backContext.stroke();
backContext.beginPath();
backContext.arc((R-r)*Math.cos(a) + (width/2), (R-r)*Math.sin(a) + (height/2), Math.abs(r), 0, Math.PI * 2);
backContext.fill();
backContext.fillStyle = 'gold';
backContext.beginPath();
backContext.arc(...xy, 3, 0, Math.PI * 2);
backContext.fill();
backContext.fillStyle = 'rgba(255, 255, 255, 0.2)';
}
context.beginPath();
context.moveTo(...p0);
context.lineTo(...xy);
context.stroke();
p0 = xy;
},tickTime);
});
d3.select('#save').on('click', () => {
t.stop();
d3.select('#save button').attr('disabled', 1);
contextIMG.fillStyle = 'black';
contextIMG.fillRect(0, 0, width, height);
contextIMG.drawImage(d3.select('#spiro').node(), 0, 0, width, height);
let image = d3.select('#image').node().toDataURL();
d3.select('#save').node().href = image;
d3.select('#save').node().download = "spiro.png";
});
d3.select('#reset').on('click', () => {
if(typeof(t) == 'object') t.stop();
showCircles = true;
R = 150; r = 52; l = 0.6;
p = l*r;
k = r/R;
loopTime = 2000;
p0 = [(width/2)+(R-r)+p,height/2];
backContext.clearRect(0, 0, width, height);
context.clearRect(0, 0, width, height);
initCircles();
d3.selectAll('#draw, #showCircles, #save button, #sliders input').attr('disabled', null);
showCircles = d3.select('#showCircles').node().checked = true;
d3.select('#bigCircle').node().value = 150;
d3.select('#smallCircle').node().value = 52;
d3.select('#p').node().value = 0.6;
d3.select('#loopTime').node().value = 2000;
});
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment