Last active
August 19, 2023 01:48
-
-
Save tg2648/ca14e0678249d88df53292cfd2516d5b to your computer and use it in GitHub Desktop.
Spirograph with HTML Canvas and JavaScript
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<div class="grid"> | |
<div> | |
<canvas id="canvas"></canvas> | |
</div> | |
<div> | |
<label for="color"> | |
Pen color | |
<input type="color" id="color" name="color" value="#0000FF" /> | |
</label> | |
<label for="smallRadius"> | |
Small radius <span id="smallRadiusValue"></span> | |
<input | |
type="range" | |
id="smallRadius" | |
name="smallRadius" | |
min="10" | |
max="250" | |
value="50" | |
step="5" | |
/> | |
</label> | |
<label for="largeRadius"> | |
Large radius <span id="largeRadiusValue"></span> | |
<input | |
type="range" | |
id="largeRadius" | |
name="largeRadius" | |
min="10" | |
max="250" | |
value="135" | |
step="5" | |
/> | |
</label> | |
<label for="ratio"> | |
Ratio <span id="ratioValue"></span> | |
<input | |
type="range" | |
id="ratio" | |
name="ratio" | |
min="0.2" | |
max="0.9" | |
value="0.7" | |
step="0.05" | |
/> | |
</label> | |
<fieldset> | |
<label for="showGears"> | |
<input | |
type="checkbox" | |
id="showGears" | |
name="showGears" | |
role="switch" | |
checked | |
/> | |
Show gears | |
</label> | |
</fieldset> | |
<input type="button" id="animateButton" /> | |
</div> | |
</div> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<script> | |
// https://stackoverflow.com/a/17445322 | |
function gcd(a: number, b: number) { | |
a = Math.abs(a); | |
b = Math.abs(b); | |
if (b > a) { | |
var temp = a; | |
a = b; | |
b = temp; | |
} | |
while (true) { | |
if (b == 0) return a; | |
a %= b; | |
if (a == 0) return b; | |
b %= a; | |
} | |
} | |
class Spiro { | |
r: number; // Radius of the smaller circle | |
R: number; // Radius of the larger circle | |
k: number; | |
l: number; // How far the pen's tip is from the center of the smaller circle | |
currTheta: number; | |
maxTheta: number; | |
x: number; | |
y: number; | |
totalPath: Path2D; | |
showGears: boolean; | |
k1: number; | |
k1k: number; | |
lk: number; | |
lineThickness: number; | |
lineColor: string; | |
gearColor: string; | |
constructor({ | |
r, | |
R, | |
ratio, | |
thickness = 1, | |
lineColor = "blue", | |
gearColor = "black", | |
showGears = true, | |
}) { | |
// Spiro constants | |
this.r = r; | |
this.R = R; | |
this.k = r / R; | |
this.l = ratio; | |
let maxRevolutions = r / gcd(r, R); | |
this.currTheta = 0; | |
this.maxTheta = 2 * Math.PI * maxRevolutions; | |
this.totalPath = new Path2D(); | |
this.showGears = showGears; | |
this.x = this.getX(0); | |
this.y = this.getY(0); | |
// Pre-compute additional constant values | |
this.k1 = 1 - this.k; | |
this.k1k = (1 - this.k) / this.k; | |
this.lk = this.l * this.k; | |
this.lineThickness = thickness; | |
this.lineColor = lineColor; | |
this.gearColor = gearColor; | |
} | |
/** | |
* Calculate the X coordinate of the pen's tip | |
* @param theta angle in radians | |
*/ | |
getX(theta: number) { | |
return ( | |
this.R * | |
(this.k1 * Math.cos(theta) + this.lk * Math.cos(this.k1k * theta)) | |
); | |
} | |
/** | |
* Calculate the Y coordinate of the pen's tip | |
* @param theta angle in radians | |
*/ | |
getY(theta: number) { | |
return ( | |
this.R * | |
(this.k1 * Math.sin(theta) - this.lk * Math.sin(this.k1k * theta)) | |
); | |
} | |
drawLargeGear(ctx: CanvasRenderingContext2D) { | |
ctx.save(); | |
ctx.strokeStyle = this.gearColor; | |
ctx.beginPath(); | |
ctx.arc(0, 0, this.R, 0, Math.PI * 2, true); | |
ctx.stroke(); | |
ctx.restore(); | |
} | |
drawSmallGear(ctx: CanvasRenderingContext2D) { | |
ctx.save(); | |
ctx.strokeStyle = this.gearColor; | |
ctx.rotate(this.currTheta); | |
ctx.beginPath(); | |
ctx.arc(this.R - this.r, 0, this.r, 0, Math.PI * 2, true); | |
ctx.stroke(); | |
ctx.restore(); | |
} | |
drawPenHole(ctx: CanvasRenderingContext2D) { | |
ctx.beginPath(); | |
ctx.strokeStyle = this.gearColor; | |
if (this.x && this.y) { | |
ctx.arc(this.x, this.y, 8, 0, Math.PI * 2, true); | |
} | |
ctx.stroke(); | |
ctx.restore(); | |
} | |
/** | |
* Draws everything on the canvas | |
* @param ctx Canvas context | |
* @returns Whether there is more spirograph to draw | |
*/ | |
draw(ctx: CanvasRenderingContext2D): boolean { | |
ctx.restore(); // Restore original state | |
ctx.fillStyle = "floralwhite"; | |
ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height); | |
ctx.save(); // Save original state | |
ctx.translate(ctx.canvas.width / 2, ctx.canvas.height / 2); | |
// Since each frame needs to be re-drawn completely | |
// Store the spiro path in Path2D and add to it with each frame | |
let currPath = new Path2D(); | |
// Move cursor to previous coords | |
currPath.moveTo(this.x, this.y); | |
// Calculate and create line to new coords | |
this.x = this.getX(this.currTheta); | |
this.y = this.getY(this.currTheta); | |
currPath.lineTo(this.x, this.y); | |
// Add line to total path and draw it | |
this.totalPath.addPath(currPath); | |
ctx.strokeStyle = this.lineColor; | |
ctx.stroke(this.totalPath); | |
// "Gears" | |
if (this.showGears) { | |
this.drawLargeGear(ctx); | |
this.drawSmallGear(ctx); | |
this.drawPenHole(ctx); | |
} | |
if (this.currTheta > this.maxTheta) { | |
return false; | |
} else { | |
this.currTheta = this.currTheta + THETA_DELTA; | |
return true; | |
} | |
} | |
} | |
const THETA_DELTA = 0.1; | |
const CANVAS_HEIGHT_RATIO = 1; | |
const canvas = document.getElementById("canvas") as HTMLCanvasElement; | |
canvas.height = canvas.width * CANVAS_HEIGHT_RATIO; // https://stackoverflow.com/a/49531850/9009483 | |
const ctx = canvas.getContext("2d"); | |
const smallRadiusInput = document.getElementById( | |
"smallRadius" | |
) as HTMLInputElement; | |
const largeRadiusInput = document.getElementById( | |
"largeRadius" | |
) as HTMLInputElement; | |
const ratioInput = document.getElementById("ratio") as HTMLInputElement; | |
const smallRadiusValueContainer = document.getElementById("smallRadiusValue"); | |
const largeRadiusValueContainer = document.getElementById("largeRadiusValue"); | |
const ratioValue = document.getElementById("ratioValue") as HTMLInputElement; | |
const colorInput = document.getElementById("color") as HTMLInputElement; | |
const animateButton = document.getElementById( | |
"animateButton" | |
) as HTMLButtonElement; | |
const showGearsInput = document.getElementById( | |
"showGears" | |
) as HTMLInputElement; | |
let isAnimating = false; | |
let animationIntervalId: NodeJS.Timeout; | |
function instantDraw(ctx: CanvasRenderingContext2D | null, spiro: Spiro) { | |
if (!ctx) { | |
return; | |
} | |
while (spiro.draw(ctx)) {} | |
} | |
function animate(ctx: CanvasRenderingContext2D | null, spiro: Spiro) { | |
if (!ctx) { | |
return; | |
} | |
animationIntervalId = setInterval(() => { | |
if (!spiro.draw(ctx)) { | |
stopAnimation(animationIntervalId); | |
} | |
}, 50); | |
} | |
function getSpiro() { | |
const params = { | |
r: smallRadiusInput.value, | |
R: largeRadiusInput.value, | |
ratio: ratioInput.value, | |
lineColor: colorInput.value, | |
showGears: showGearsInput.checked, | |
}; | |
return new Spiro(params); | |
} | |
function disableInputs() { | |
smallRadiusInput.disabled = true; | |
largeRadiusInput.disabled = true; | |
ratioInput.disabled = true; | |
colorInput.disabled = true; | |
showGearsInput.disabled = true; | |
} | |
function enableInputs() { | |
smallRadiusInput.disabled = false; | |
largeRadiusInput.disabled = false; | |
ratioInput.disabled = false; | |
colorInput.disabled = false; | |
showGearsInput.disabled = false; | |
} | |
function stopAnimation(intervalId: NodeJS.Timeout) { | |
animateButton!.value = "Animate"; | |
enableInputs(); | |
isAnimating = false; | |
clearInterval(intervalId); | |
} | |
function prepareForAnimation() { | |
animateButton.value = "Stop Animation"; | |
disableInputs(); | |
isAnimating = true; | |
} | |
smallRadiusInput.addEventListener("input", (e) => { | |
smallRadiusValueContainer!.innerText = `(${ | |
(e.target as HTMLInputElement).value | |
})`; | |
instantDraw(ctx, getSpiro()); | |
}); | |
largeRadiusInput.addEventListener("input", (e) => { | |
largeRadiusValueContainer!.innerText = `(${ | |
(e.target as HTMLInputElement).value | |
})`; | |
instantDraw(ctx, getSpiro()); | |
}); | |
ratioInput.addEventListener("input", (e) => { | |
ratioValue.innerText = `(${(e.target as HTMLInputElement).value})`; | |
instantDraw(ctx, getSpiro()); | |
}); | |
colorInput.addEventListener("change", () => { | |
instantDraw(ctx, getSpiro()); | |
}); | |
showGearsInput.addEventListener("change", () => { | |
instantDraw(ctx, getSpiro()); | |
}); | |
animateButton.addEventListener("click", () => { | |
if (isAnimating) { | |
stopAnimation(animationIntervalId); | |
instantDraw(ctx, getSpiro()); | |
} else { | |
prepareForAnimation(); | |
animate(ctx, getSpiro()); | |
} | |
}); | |
window.addEventListener("load", () => { | |
animateButton.value = "Animate"; | |
smallRadiusValueContainer!.innerText = `(${smallRadiusInput.value})`; | |
largeRadiusValueContainer!.innerText = `(${largeRadiusInput.value})`; | |
ratioValue.innerText = `(${ratioInput.value})`; | |
instantDraw(ctx, getSpiro()); | |
}); | |
</script> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<style> | |
#canvas { | |
width: 100%; | |
border: 1px solid black; | |
margin-bottom: 1em; | |
} | |
</style> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment