Skip to content

Instantly share code, notes, and snippets.

@tg2648
Last active August 19, 2023 01:48
Show Gist options
  • Save tg2648/ca14e0678249d88df53292cfd2516d5b to your computer and use it in GitHub Desktop.
Save tg2648/ca14e0678249d88df53292cfd2516d5b to your computer and use it in GitHub Desktop.
Spirograph with HTML Canvas and JavaScript
<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>
<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>
<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