In the previous part we’ve used Html5 Canvas to draw lines and circles with custom fill and stroke styles.

In this part, we will make our widget interactive by letting the user customize our gears setup.

Part II: Rendering two interlocked gears

Our objective is to let the user control the position of the second gear. At the same time, we want the two gears to remain interlocked and synchronized.

Here is the first draft:

Gear.prototype.connect = function (x, y) {
    var r = this.radius;
    var dist = distance(x, y, this.x, this.y); // Calculate the distance between two gears

    // To create new gear we have to know the number of its touth
    var newRadius = Math.max(dist - r, 10);
    var newDiam = newRadius * 2 * Math.PI;
    // The number of teeth must be an integer:
    var newTeeth = Math.round(newDiam / (4 * this.connectionRadius));

    // Make new gear
    var newGear = new Gear(x, y, this.connectionRadius, newTeeth, 
                           this.fillStyle, this.strokeStyle);

    // Adjust new gear's rotation to be in direction oposite to the original
    var gearRatio = this.teeth / newTeeth;
    newGear.angularSpeed = -this.angularSpeed * gearRatio;
    return newGear;
}

We can now hook this up to Canvas Mouse-Move event,

var gear2 = gear.connect(3 * (W / 4), H / 2);

// Helper function to translate (x,y) to coordinates relative to the canvas
function getMousePos(canvas, evnt) {
    var rect = canvas.getBoundingClientRect();
    return {
        x: evnt.clientX - rect.left,
        y: evnt.clientY - rect.top
    };
}

canvas.onmousemove = function (evnt) {
    var pos = getMousePos(canvas, evnt);
    var x = Math.min(0.7 * W, Math.max(0.3 * W, pos.x));
    var y = Math.min(0.7 * H, Math.max(0.3 * H, pos.y));
    gear2 = gear.connect(x, y);
}
setInterval(function () {
    canvas.width = canvas.width;
    gear.render(context);
    gear2.render(context);
}, 20);

Mouse-over to activate

This is a step in the right direction, but not everything is right yet. Gears are not interlocked, and each is doing its own thing.

Here are the two problems we must address:

  1. gear2.radius differs from the newRadius we’ve calculated. This is since we had to keep the number of teeth round.
  2. Rotation of gear2 is not synced up with the first gear.

To take care of (1) we must let our new gear change it’s actual position to allow to have both the number of teeth it desires and let it be interlocked with the first gear.

Gear.prototype.connect = function (x, y) {
    var r = this.radius;
    var dist = distance(x, y, this.x, this.y);

    // To create new gear we have to know the number of its touth
    var newRadius = Math.max(dist - r, 10);
    var newDiam = newRadius * 2 * Math.PI;
    var newTeeth = Math.round(newDiam / (4 * this.connectionRadius));

    // Calculate the ACTUAL position for the new gear, 
    // that would allow it to interlock with this gear
    var actualDiameter = newTeeth * 4 * this.connectionRadius;
    var actualRadius = actualDiameter / (2 * Math.PI);
    var actualDist = r + actualRadius; // Actual distance from center of this gear

    // Angle between center of this gear and (x,y):
    var alpha = Math.atan2(y - this.y, x - this.x); 
    var actualX = this.x + Math.cos(alpha) * actualDist; 
    var actualY = this.y + Math.sin(alpha) * actualDist;

    // Make new gear
    var newGear = new Gear(actualX, actualY, this.connectionRadius, newTeeth, 
                           this.fillStyle, this.strokeStyle);

    // Adjust new gear's rotation to be in direction oposite to the original
    var gearRatio = this.teeth / newTeeth;
    newGear.angularSpeed = -this.angularSpeed * gearRatio;

    return newGear;
}

Mouse-over to activate

This adjustment alone took care of an annoying effect where we could have place a gear inside another gear, but it was not enough to sync-up the two.

Now we must make the gears interlock at the point of connection:

this.phi0 = alpha; // At time t=0, rotate this gear to be at angle Alpha
newGear.phi0 = alpha + Math.PI + (Math.PI / newTeeth);
// At the same time (t=0), rotate the new gear to be at (180 - Alpha), facing the first gear,
// And add a half gear rotation to make the teeth interlock
newGear.createdAt = this.createdAt; // Also, syncronize their clocks

This will do the trick. The downside of this method, however, is that we are changing this. It means that any other gears that were previously synced up with this are no longer synced. Every time this.phi is updated by some delta, by how much newGear.phi should be updated? The answer is delta * (newGear.angularSpeed / this.angularSpeed) as it is the ratio between rotation speeds of the gears. Knowing this, we can update both gears by delta = (this.phi0 - alpha), cancelling the effect on this:

// At time t=0, rotate this gear to be at angle Alpha
this.phi0 = alpha + (this.phi0 - alpha); // = this.phi0, This does nothing.
newGear.phi0 = alpha + Math.PI + (Math.PI / newTeeth) + 
               (this.phi0 - alpha) * (newGear.angularSpeed / this.angularSpeed);
// At the same time (t=0), rotate the new gear to be at (180 - Alpha), 
// facing the first gear, and add a half-a-tooth rotation to make the teeth interlock
newGear.createdAt = this.createdAt; // Also, synchronize their clocks

This way, this.phi0 remains unchanged, while the other gear syncs-up to it:


Mouse-over to activate

Source code: part2.js