Drag the sliders to choose the outer radius R and the inner radius r, then watch the hypotrochoid draw itself in real time. The pen offset d moves the tracing point closer to or farther from the centre of the rolling gear — try extreme values for looped vs pointy petals.
<!-- Spirograph hypotrochoid demo - outer ring R, inner gear r, pen offset d -->
<div class="controls">
<label>Outer radius R: <span id="R-val">9</span>
<input type="range" id="R-range" min="3" max="12" value="9" step="1">
</label>
<label>Inner radius r: <span id="r-val">4</span>
<input type="range" id="r-range" min="1" max="11" value="4" step="1">
</label>
<label>Pen offset d: <span id="d-val">3.5</span>
<input type="range" id="d-range" min="0.5" max="6" value="3.5" step="0.5">
</label>
</div>
<canvas id="canvas" width="340" height="280" aria-label="Spirograph roulette curve drawn on a canvas"></canvas>
<div id="info" class="info">Adjust sliders and press Draw to animate.</div>
<div class="btns">
<button id="btn-animate" type="button">Draw (animated)</button>
<button id="btn-reset" type="button" class="ghost">Show full curve</button>
</div>
/* Spirograph demo styles */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; padding: .6rem; }
.controls { display: flex; flex-direction: column; gap: .4rem; margin-bottom: .6rem; }
label { font-size: .85rem; display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; }
label span { font-weight: 700; min-width: 2.5ch; text-align: right; color: #1d3557; }
input[type=range] { flex: 1; min-width: 120px; accent-color: #1d3557; }
canvas { display: block; border: 1px solid #cdd9e3; border-radius: 8px; background: #f7f9fc;
max-width: 100%; }
.info { font-size: .88rem; color: #444; margin: .5rem 0; min-height: 1.3em; }
.info strong { color: #1d3557; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
/* Hypotrochoid: x = (R-r)cos(t) + d*cos((R-r)/r*t), y = (R-r)sin(t) - d*sin((R-r)/r*t) */
/* Euclidean GCD — controls petal count and closing period */
function gcd(a, b) { return b === 0 ? a : gcd(b, a % b); }
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const infoEl = document.getElementById('info');
let R = 9, r = 4, d = 3.5;
let animId = null;
/* Compute p/q (reduced ratio) and lobe count from R and r */
function hypoParams(R, r) {
const g = gcd(R, r);
const p = R / g;
const q = r / g;
const lobes = Math.abs(R - r) / g;
const steps = Math.ceil(2 * Math.PI * r / g * 200);
return { p, q, lobes, steps };
}
/* Draw the hypotrochoid up to the given progress fraction (0..1) */
function drawCurve(progress) {
const W = canvas.width, H = canvas.height;
const cx = W / 2, cy = H / 2;
const scale = Math.min(W, H) / 2 / (Math.abs(R - r) + d + 2);
ctx.clearRect(0, 0, W, H);
/* Draw the fixed outer ring guide */
ctx.beginPath();
ctx.arc(cx, cy, R * scale, 0, 2 * Math.PI);
ctx.strokeStyle = '#cdd9e3';
ctx.lineWidth = 1;
ctx.stroke();
const { steps, lobes } = hypoParams(R, r);
const totalT = 2 * Math.PI * r / gcd(R, r);
const tEnd = totalT * progress;
ctx.beginPath();
let first = true;
const dt = totalT / steps;
for (let t = 0; t <= tEnd; t += dt) {
const x = cx + ((R - r) * Math.cos(t) + d * Math.cos((R - r) / r * t)) * scale;
const y = cy + ((R - r) * Math.sin(t) - d * Math.sin((R - r) / r * t)) * scale;
if (first) { ctx.moveTo(x, y); first = false; } else { ctx.lineTo(x, y); }
}
ctx.strokeStyle = '#e63946';
ctx.lineWidth = 1.6;
ctx.stroke();
/* Draw the rolling inner circle and current pen position */
if (progress < 1) {
const t = tEnd;
const gx = cx + (R - r) * Math.cos(t) * scale;
const gy = cy + (R - r) * Math.sin(t) * scale;
ctx.beginPath();
ctx.arc(gx, gy, r * scale, 0, 2 * Math.PI);
ctx.strokeStyle = '#1d3557';
ctx.lineWidth = 1;
ctx.stroke();
const px = cx + ((R - r) * Math.cos(t) + d * Math.cos((R - r) / r * t)) * scale;
const py = cy + ((R - r) * Math.sin(t) - d * Math.sin((R - r) / r * t)) * scale;
ctx.beginPath();
ctx.arc(px, py, 3, 0, 2 * Math.PI);
ctx.fillStyle = '#e63946';
ctx.fill();
}
/* Update the info line with petal count */
const g = gcd(R, r);
infoEl.innerHTML = i18nFmt('R=%R%, r=%r% → ratio %p%/%q% → <strong>%lobes% lobe(s)</strong>, closes after %q% outer turn(s)', { R: R, r: r, p: R/g, q: r/g, lobes: lobes });
}
function animate() {
let start = null;
const duration = 4000;
cancelAnimation();
const step = (ts) => {
if (!start) start = ts;
const progress = Math.min((ts - start) / duration, 1);
drawCurve(progress);
if (progress < 1) animId = requestAnimationFrame(step);
else animId = null;
};
animId = requestAnimationFrame(step);
}
function cancelAnimation() {
if (animId !== null) { cancelAnimationFrame(animId); animId = null; }
}
function reset() {
cancelAnimation();
drawCurve(1);
}
function syncSliders() {
R = parseInt(document.getElementById('R-range').value);
r = Math.min(parseInt(document.getElementById('r-range').value), R - 1);
d = parseFloat(document.getElementById('d-range').value);
document.getElementById('R-val').textContent = R;
document.getElementById('r-val').textContent = r;
document.getElementById('d-val').textContent = d;
/* Ensure r stays strictly less than R */
document.getElementById('r-range').max = R - 1;
}
['R-range','r-range','d-range'].forEach(id => {
document.getElementById(id).addEventListener('input', () => { syncSliders(); drawCurve(1); });
});
document.getElementById('btn-animate').addEventListener('click', animate);
document.getElementById('btn-reset').addEventListener('click', reset);
syncSliders();
drawCurve(1);
<!-- Spirograph hypotrochoid demo - outer ring R, inner gear r, pen offset d -->
<div class="controls">
<label>Outer radius R: <span id="R-val">9</span>
<input type="range" id="R-range" min="3" max="12" value="9" step="1">
</label>
<label>Inner radius r: <span id="r-val">4</span>
<input type="range" id="r-range" min="1" max="11" value="4" step="1">
</label>
<label>Pen offset d: <span id="d-val">3.5</span>
<input type="range" id="d-range" min="0.5" max="6" value="3.5" step="0.5">
</label>
</div>
<canvas id="canvas" width="340" height="280" aria-label="Spirograph roulette curve drawn on a canvas"></canvas>
<div id="info" class="info">Adjust sliders and press Draw to animate.</div>
<div class="btns">
<button id="btn-animate" type="button">Draw (animated)</button>
<button id="btn-reset" type="button" class="ghost">Show full curve</button>
</div>
/* Spirograph demo styles */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; padding: .6rem; }
.controls { display: flex; flex-direction: column; gap: .4rem; margin-bottom: .6rem; }
label { font-size: .85rem; display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; }
label span { font-weight: 700; min-width: 2.5ch; text-align: right; color: #1d3557; }
input[type=range] { flex: 1; min-width: 120px; accent-color: #1d3557; }
canvas { display: block; border: 1px solid #cdd9e3; border-radius: 8px; background: #f7f9fc;
max-width: 100%; }
.info { font-size: .88rem; color: #444; margin: .5rem 0; min-height: 1.3em; }
.info strong { color: #1d3557; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
/* Hypotrochoid: x = (R-r)cos(t) + d*cos((R-r)/r*t), y = (R-r)sin(t) - d*sin((R-r)/r*t) */
/* Euclidean GCD — controls petal count and closing period */
function gcd(a, b) { return b === 0 ? a : gcd(b, a % b); }
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const infoEl = document.getElementById('info');
let R = 9, r = 4, d = 3.5;
let animId = null;
/* Compute p/q (reduced ratio) and lobe count from R and r */
function hypoParams(R, r) {
const g = gcd(R, r);
const p = R / g;
const q = r / g;
const lobes = Math.abs(R - r) / g;
const steps = Math.ceil(2 * Math.PI * r / g * 200);
return { p, q, lobes, steps };
}
/* Draw the hypotrochoid up to the given progress fraction (0..1) */
function drawCurve(progress) {
const W = canvas.width, H = canvas.height;
const cx = W / 2, cy = H / 2;
const scale = Math.min(W, H) / 2 / (Math.abs(R - r) + d + 2);
ctx.clearRect(0, 0, W, H);
/* Draw the fixed outer ring guide */
ctx.beginPath();
ctx.arc(cx, cy, R * scale, 0, 2 * Math.PI);
ctx.strokeStyle = '#cdd9e3';
ctx.lineWidth = 1;
ctx.stroke();
const { steps, lobes } = hypoParams(R, r);
const totalT = 2 * Math.PI * r / gcd(R, r);
const tEnd = totalT * progress;
ctx.beginPath();
let first = true;
const dt = totalT / steps;
for (let t = 0; t <= tEnd; t += dt) {
const x = cx + ((R - r) * Math.cos(t) + d * Math.cos((R - r) / r * t)) * scale;
const y = cy + ((R - r) * Math.sin(t) - d * Math.sin((R - r) / r * t)) * scale;
if (first) { ctx.moveTo(x, y); first = false; } else { ctx.lineTo(x, y); }
}
ctx.strokeStyle = '#e63946';
ctx.lineWidth = 1.6;
ctx.stroke();
/* Draw the rolling inner circle and current pen position */
if (progress < 1) {
const t = tEnd;
const gx = cx + (R - r) * Math.cos(t) * scale;
const gy = cy + (R - r) * Math.sin(t) * scale;
ctx.beginPath();
ctx.arc(gx, gy, r * scale, 0, 2 * Math.PI);
ctx.strokeStyle = '#1d3557';
ctx.lineWidth = 1;
ctx.stroke();
const px = cx + ((R - r) * Math.cos(t) + d * Math.cos((R - r) / r * t)) * scale;
const py = cy + ((R - r) * Math.sin(t) - d * Math.sin((R - r) / r * t)) * scale;
ctx.beginPath();
ctx.arc(px, py, 3, 0, 2 * Math.PI);
ctx.fillStyle = '#e63946';
ctx.fill();
}
/* Update the info line with petal count */
const g = gcd(R, r);
infoEl.innerHTML = i18nFmt('R=%R%, r=%r% → ratio %p%/%q% → <strong>%lobes% lobe(s)</strong>, closes after %q% outer turn(s)', { R: R, r: r, p: R/g, q: r/g, lobes: lobes });
}
function animate() {
let start = null;
const duration = 4000;
cancelAnimation();
const step = (ts) => {
if (!start) start = ts;
const progress = Math.min((ts - start) / duration, 1);
drawCurve(progress);
if (progress < 1) animId = requestAnimationFrame(step);
else animId = null;
};
animId = requestAnimationFrame(step);
}
function cancelAnimation() {
if (animId !== null) { cancelAnimationFrame(animId); animId = null; }
}
function reset() {
cancelAnimation();
drawCurve(1);
}
function syncSliders() {
R = parseInt(document.getElementById('R-range').value);
r = Math.min(parseInt(document.getElementById('r-range').value), R - 1);
d = parseFloat(document.getElementById('d-range').value);
document.getElementById('R-val').textContent = R;
document.getElementById('r-val').textContent = r;
document.getElementById('d-val').textContent = d;
/* Ensure r stays strictly less than R */
document.getElementById('r-range').max = R - 1;
}
['R-range','r-range','d-range'].forEach(id => {
document.getElementById(id).addEventListener('input', () => { syncSliders(); drawCurve(1); });
});
document.getElementById('btn-animate').addEventListener('click', animate);
document.getElementById('btn-reset').addEventListener('click', reset);
syncSliders();
drawCurve(1);
Notice that the curve closes — returns exactly to where it started — only when rR is a rational number, which it always is for whole-number gear sizes. When R/r=p/q in lowest terms, the curve needs exactly p full turns of the outer circle (equivalently q full rotations of the inner gear) to close, and it draws exactly ∣R−r∣/gcd(R,r) lobes. Try R=5,r=3 for two lobes; R=7,r=3 for four lobes; R=9,r=4 for a star-like five-pointed figure.
Comments
Loading comments...