03Pattern Whose Symmetry-Axis Count Comes From the Tooth Ratio
The petal count is computed from the greatest common divisor of the two tooth counts, so a new ratio resets the symmetry-axis count and the number of turns before the curve closes along with it.
Best forEducational demo that makes the math visible
PEN_RADIUS_RATIOAXIS_COUNTTURN_COUNT

<main class="stage">
<div class="sp3 is-demo" id="sp3Root" tabindex="0">
<canvas class="sp3__canvas" id="sp3Canvas" width="480" height="300"></canvas>
<p class="sp3__badge" id="sp3Badge"></p>
<p class="sp3__caption">Calculates the pattern's petal count from the greatest common divisor of the two gears' tooth counts, so when the ratio changes, the symmetry-axis count is set to match and the number of turns it takes the curve to close changes with it</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-orange);
.sp3 {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $stage-orange;
}
.sp3__canvas {
display: block;
width: 100%;
height: 100%;
}
.sp3__badge {
position: absolute;
left: $sp-3;
top: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 247, 230, .9);
color: $subject-ink;
font-weight: 600;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
.sp3__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 78%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 247, 230, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sp3__caption { font-size: 10px; padding: $sp-1 $sp-2; }
.sp3__badge { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.sp3 { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sp3 {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #ff4d1f;
}
.sp3__canvas {
display: block;
width: 100%;
height: 100%;
}
.sp3__badge {
position: absolute;
left: 12px;
top: 12px;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.9);
color: #17141a;
font-weight: 600;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
.sp3__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 78%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sp3__caption {
font-size: 10px;
padding: 4px 8px;
}
.sp3__badge {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.sp3 {
cursor: default;
}
}(function () {
// 기법: 하이포트로코이드 + 최대공약수(gcd) 계산 — 링 이빨 수와 톱니 이빨 수의 gcd 로
// 꽃잎(symmetry axes) 수와 곡선이 닫히기까지 도는 바퀴 수를 구해 배지에 그대로 표시한다.
var root = document.getElementById('sp3Root');
var canvas = document.getElementById('sp3Canvas');
var badge = document.getElementById('sp3Badge');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var CALC_DIM = 200;
var GEAR_RING_TEETH = 90;
var GEAR_ROLL_TEETH = 24;
var PEN_RADIUS_RATIO = 0.6;
var TOOTH_UNIT = 0.8;
var SEGMENTS_PER_TURN = 220;
function gcd(a, b) { a = Math.abs(a); b = Math.abs(b); while (b) { var t = b; b = a % b; a = t; } return a; }
var R = GEAR_RING_TEETH * TOOTH_UNIT;
var r = GEAR_ROLL_TEETH * TOOTH_UNIT;
var d = r * PEN_RADIUS_RATIO;
var g = gcd(GEAR_RING_TEETH, GEAR_ROLL_TEETH);
var AXIS_COUNT = GEAR_RING_TEETH / g;
var TURN_COUNT = GEAR_ROLL_TEETH / g;
var TOTAL_T = TURN_COUNT * Math.PI * 2;
var CYCLE_SECONDS = 6;
var SPEED = TOTAL_T / CYCLE_SECONDS;
badge.textContent = 'Symmetry axes: ' + AXIS_COUNT + ' · closes in ' + TURN_COUNT + ' turns';
function point(t) {
var k = (R - r) / r;
return {
x: (R - r) * Math.cos(t) + d * Math.cos(k * t),
y: (R - r) * Math.sin(t) - d * Math.sin(k * t)
};
}
var cw = 480, ch = 300, scale = 1, cx = 240, cy = 150;
function fit() {
var rect = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(rect.width));
ch = Math.max(1, Math.round(rect.height));
canvas.width = cw; canvas.height = ch;
scale = Math.min(cw, ch) / CALC_DIM * 0.92;
cx = cw / 2; cy = ch / 2;
}
function toScreen(p) { return { x: cx + p.x * scale, y: cy + p.y * scale }; }
function drawFull(tMax) {
ctx.clearRect(0, 0, cw, ch);
var tipW = toScreen(point(tMax));
var wash = ctx.createRadialGradient(tipW.x, tipW.y, 0, tipW.x, tipW.y, Math.max(cw, ch) * 0.9);
wash.addColorStop(0, 'rgba(255,255,255,.60)');
wash.addColorStop(0.5, 'rgba(255,225,170,.32)');
wash.addColorStop(1, 'rgba(200,190,255,.16)');
ctx.fillStyle = wash;
ctx.fillRect(0, 0, cw, ch);
var fillSteps = Math.max(2, Math.round((tMax / (Math.PI * 2)) * SEGMENTS_PER_TURN));
ctx.beginPath();
for (var fi = 0; fi <= fillSteps; fi += 1) {
var fs = toScreen(point((fi / fillSteps) * tMax));
if (fi === 0) ctx.moveTo(fs.x, fs.y); else ctx.lineTo(fs.x, fs.y);
}
ctx.closePath();
ctx.fillStyle = 'rgba(255,247,230,.18)';
ctx.fill('evenodd');
ctx.lineWidth = Math.max(3, scale * 0.04);
ctx.strokeStyle = 'rgba(255,247,230,.94)';
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
var steps = Math.max(2, Math.round((tMax / (Math.PI * 2)) * SEGMENTS_PER_TURN));
ctx.beginPath();
for (var i = 0; i <= steps; i += 1) {
var t = (i / steps) * tMax;
var s = toScreen(point(t));
if (i === 0) ctx.moveTo(s.x, s.y); else ctx.lineTo(s.x, s.y);
}
ctx.stroke();
if (tMax > 0.001) {
var tip = toScreen(point(tMax));
ctx.fillStyle = 'rgba(255,247,230,1)';
ctx.beginPath();
ctx.arc(tip.x, tip.y, Math.max(3, scale * 0.05), 0, Math.PI * 2);
ctx.fill();
}
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) root.classList.remove('is-demo');
fit();
if (reduce) {
drawFull(TOTAL_T);
} else {
var elapsed = 0.3;
var lastTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = Math.min(ts - lastTs, 48);
lastTs = ts;
elapsed += dt / 1000;
var t = elapsed * SPEED;
if (t > TOTAL_T) { elapsed = 0; t = 0; }
drawFull(t);
requestAnimationFrame(tick);
}
drawFull(elapsed * SPEED);
window.addEventListener('resize', function () { fit(); drawFull(elapsed * SPEED); });
requestAnimationFrame(tick);
}
})();import { useEffect, useRef } from "react";
import styles from "./SpiroSceneToothRatioSymmetry.module.scss";
type Pt = { x: number; y: number };
export default function SpiroSceneToothRatioSymmetry() {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const badgeRef = useRef<HTMLParagraphElement>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const badge = badgeRef.current;
if (!root || !canvas || !badge) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const wrap = canvas.parentElement as HTMLElement;
const CALC_DIM = 200;
const GEAR_RING_TEETH = 90;
const GEAR_ROLL_TEETH = 24;
const PEN_RADIUS_RATIO = 0.6;
const TOOTH_UNIT = 0.8;
const SEGMENTS_PER_TURN = 220;
function gcd(a: number, b: number): number {
a = Math.abs(a); b = Math.abs(b);
while (b) { const t = b; b = a % b; a = t; }
return a;
}
const R = GEAR_RING_TEETH * TOOTH_UNIT;
const r = GEAR_ROLL_TEETH * TOOTH_UNIT;
const d = r * PEN_RADIUS_RATIO;
const g = gcd(GEAR_RING_TEETH, GEAR_ROLL_TEETH);
const AXIS_COUNT = GEAR_RING_TEETH / g;
const TURN_COUNT = GEAR_ROLL_TEETH / g;
const TOTAL_T = TURN_COUNT * Math.PI * 2;
const CYCLE_SECONDS = 6;
const SPEED = TOTAL_T / CYCLE_SECONDS;
badge.textContent = "대칭 축 " + AXIS_COUNT + "개 · " + TURN_COUNT + "바퀴 닫힘";
function point(t: number): Pt {
const k = (R - r) / r;
return {
x: (R - r) * Math.cos(t) + d * Math.cos(k * t),
y: (R - r) * Math.sin(t) - d * Math.sin(k * t)
};
}
let cw = 480, ch = 300, scale = 1, cx = 240, cy = 150;
function fit() {
const rect = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(rect.width));
ch = Math.max(1, Math.round(rect.height));
canvas!.width = cw; canvas!.height = ch;
scale = Math.min(cw, ch) / CALC_DIM * 0.92;
cx = cw / 2; cy = ch / 2;
}
function toScreen(p: Pt): Pt { return { x: cx + p.x * scale, y: cy + p.y * scale }; }
function drawFull(tMax: number) {
ctx!.clearRect(0, 0, cw, ch);
const tipW = toScreen(point(tMax));
const wash = ctx!.createRadialGradient(tipW.x, tipW.y, 0, tipW.x, tipW.y, Math.max(cw, ch) * 0.9);
wash.addColorStop(0, 'rgba(255,255,255,.60)');
wash.addColorStop(0.5, 'rgba(255,225,170,.32)');
wash.addColorStop(1, 'rgba(200,190,255,.16)');
ctx!.fillStyle = wash;
ctx!.fillRect(0, 0, cw, ch);
const fillSteps = Math.max(2, Math.round((tMax / (Math.PI * 2)) * SEGMENTS_PER_TURN));
ctx!.beginPath();
for (let fi = 0; fi <= fillSteps; fi += 1) {
const fs = toScreen(point((fi / fillSteps) * tMax));
if (fi === 0) ctx!.moveTo(fs.x, fs.y); else ctx!.lineTo(fs.x, fs.y);
}
ctx!.closePath();
ctx!.fillStyle = 'rgba(255,247,230,.18)';
ctx!.fill('evenodd');
ctx!.lineWidth = Math.max(3, scale * 0.04);
ctx!.strokeStyle = "rgba(255,247,230,.94)";
ctx!.lineJoin = "round";
ctx!.lineCap = "round";
const steps = Math.max(2, Math.round((tMax / (Math.PI * 2)) * SEGMENTS_PER_TURN));
ctx!.beginPath();
for (let i = 0; i <= steps; i += 1) {
const t = (i / steps) * tMax;
const s = toScreen(point(t));
if (i === 0) ctx!.moveTo(s.x, s.y); else ctx!.lineTo(s.x, s.y);
}
ctx!.stroke();
if (tMax > 0.001) {
const tip = toScreen(point(tMax));
ctx!.fillStyle = "rgba(255,247,230,1)";
ctx!.beginPath();
ctx!.arc(tip.x, tip.y, Math.max(3, scale * 0.05), 0, Math.PI * 2);
ctx!.fill();
}
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) root.classList.remove("is-demo");
fit();
let raf = 0;
if (reduce) {
drawFull(TOTAL_T);
} else {
let elapsed = 0.3;
let lastTs: number | null = null;
const onResize = () => { fit(); drawFull(elapsed * SPEED); };
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = Math.min(ts - lastTs, 48);
lastTs = ts;
elapsed += dt / 1000;
let t = elapsed * SPEED;
if (t > TOTAL_T) { elapsed = 0; t = 0; }
drawFull(t);
raf = requestAnimationFrame(tick);
}
drawFull(elapsed * SPEED);
window.addEventListener("resize", onResize);
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", onResize);
};
}
return () => { cancelAnimationFrame(raf); };
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.sp3} is-demo`} ref={rootRef} tabIndex={0}>
<canvas className={styles.sp3__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.sp3__badge} ref={badgeRef} />
<p className={styles.sp3__caption}>두 톱니 이빨 수의 최대공약수로 무늬의 꽃잎 수를 계산해, 비율이 바뀌면 대칭 축 수가 그 값에 맞춰 다시 정해지고 곡선이 닫히는 바퀴 수도 함께 바뀐다</p>
</div>
</main>
);
}03 Pattern Whose Symmetry-Axis Count Comes From the Tooth Ratio — Measured area 59.435% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · Spirograph Generator: 9 Gear-Ratio Curve Patterns
The post this demo lives in
Spirograph Generator: 9 Gear-Ratio Curve PatternsWhen the two gear tooth counts share no common factor the curve closes, and the moment the ratio slips the sheet wanders See all demos in this post
If you like this effect - similar demos
Creative UI02 Symmetry order slider — change n and the pattern rebuildsKaleidoscope Drawing: 9 Canvas Symmetry Tools
Creative UI04 Coral growth — the reaction front reaches outward from a seed9 Reaction Diffusion Simulations, No Library
Creative UI02 Rounded box room — a scene seen from inside9 Ray Marching Scenes in Canvas (No WebGL)