10
Gear Ratio Simulator
Drag sideways for RPM; tap a gear to change teeth
idle
108 lines · vanilla
view source
const TOOTH_OPTS = [8, 12, 16, 24, 32];
const PAD = 12, TAU = Math.PI * 2;
let W, H, teeth, rpm, th0, lastMX, dragDist, prevDown;
function init({ ctx, width, height }) {
W = width; H = height;
teeth = [8, 16, 24];
rpm = 25; th0 = 0;
lastMX = 0; dragDist = 0; prevDown = false;
ctx.fillStyle = "#0c0f17"; ctx.fillRect(0, 0, W, H);
}
// Phase-correct meshing: tooth of A at the contact point lines up with the
// gap of B (contact direction is +x from A, so π from B's side).
function meshAngle(thA, NA, NB) {
return Math.PI + Math.PI / NB - (NA / NB) * thA;
}
function hueOf(r) { return 215 - 195 * Math.min(1, Math.abs(r) / 60); }
function drawGear(ctx, x, y, N, m, th, hue) {
const r = (m * N) / 2, rT = r + m * 0.85, rR = r - m;
const p = TAU / N;
ctx.beginPath();
for (let k = 0; k < N; k++) {
const a = th + k * p;
const a1 = a - 0.18 * p, a2 = a + 0.18 * p, a3 = a + 0.3 * p, a4 = a + 0.7 * p;
if (k === 0) ctx.moveTo(x + Math.cos(a1) * rT, y + Math.sin(a1) * rT);
else ctx.lineTo(x + Math.cos(a1) * rT, y + Math.sin(a1) * rT);
ctx.lineTo(x + Math.cos(a2) * rT, y + Math.sin(a2) * rT);
ctx.lineTo(x + Math.cos(a3) * rR, y + Math.sin(a3) * rR);
ctx.lineTo(x + Math.cos(a4) * rR, y + Math.sin(a4) * rR);
}
ctx.closePath();
ctx.fillStyle = `hsla(${hue.toFixed(0)},70%,46%,0.92)`;
ctx.fill();
ctx.strokeStyle = `hsla(${hue.toFixed(0)},85%,72%,0.9)`;
ctx.lineWidth = 1.5;
ctx.stroke();
ctx.fillStyle = "rgba(10,12,20,0.85)";
ctx.beginPath(); ctx.arc(x, y, Math.max(9, r * 0.35), 0, TAU); ctx.fill();
// marked tooth 0: radial line + dot so the eye can verify the ratio
const mx = x + Math.cos(th) * (r + m * 0.3), my = y + Math.sin(th) * (r + m * 0.3);
ctx.strokeStyle = "rgba(255,255,255,0.85)"; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(mx, my); ctx.stroke();
ctx.fillStyle = "#fff";
ctx.beginPath(); ctx.arc(mx, my, 3.5, 0, TAU); ctx.fill();
ctx.font = "bold 13px monospace"; ctx.textAlign = "center";
ctx.fillText(String(N), x, y + 4);
}
function tick({ ctx, dt, time, width, height, input }) {
if (width !== W || height !== H) { W = width; H = height; }
const N0 = teeth[0], N1 = teeth[1], N2 = teeth[2];
const maxN = Math.max(N0, N1, N2);
const hudH = 100;
const cy = hudH + (H - 32 - hudH) / 2;
const m = Math.min(
(W - 28) / (N0 + N1 + N2 + 1.7),
Math.min(cy - hudH - 4, H - 36 - cy) / (maxN / 2 + 0.85)
);
const r0 = (m * N0) / 2, r1 = (m * N1) / 2, r2 = (m * N2) / 2;
const full = m * (N0 + N1 + N2 + 1.7);
const xA = (W - full) / 2 + 0.85 * m + r0;
const xB = xA + r0 + r1, xC = xB + r1 + r2;
// drag horizontally anywhere to change driver RPM (negative = reverse)
const md = input.mouseDown;
if (md && !prevDown) { lastMX = input.mouseX; dragDist = 0; }
if (md && prevDown) {
const dx = input.mouseX - lastMX;
rpm = Math.max(-60, Math.min(60, rpm + dx * 0.25));
dragDist += Math.abs(dx);
lastMX = input.mouseX;
}
prevDown = md;
// tap a gear (without dragging) to cycle its tooth count
for (const c of input.consumeClicks()) {
if (dragDist > 10) continue;
const dA = (c.x - xA) * (c.x - xA) + (c.y - cy) * (c.y - cy);
const dB = (c.x - xB) * (c.x - xB) + (c.y - cy) * (c.y - cy);
const dC = (c.x - xC) * (c.x - xC) + (c.y - cy) * (c.y - cy);
let i = -1;
if (dA < (r0 + m) * (r0 + m)) i = 0;
else if (dB < (r1 + m) * (r1 + m)) i = 1;
else if (dC < (r2 + m) * (r2 + m)) i = 2;
if (i >= 0) teeth[i] = TOOTH_OPTS[(TOOTH_OPTS.indexOf(teeth[i]) + 1) % TOOTH_OPTS.length];
}
th0 += ((rpm * TAU) / 60) * dt;
const th1 = meshAngle(th0, N0, N1);
const th2 = meshAngle(th1, N1, N2);
const rpm1 = (-rpm * N0) / N1;
const rpm2 = (rpm * N0) / N2;
ctx.fillStyle = "#0c0f17"; ctx.fillRect(0, 0, W, H);
// axle line
ctx.strokeStyle = "rgba(255,255,255,0.08)"; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(xA - r0, cy); ctx.lineTo(xC + r2, cy); ctx.stroke();
drawGear(ctx, xA, cy, N0, m, th0, hueOf(rpm));
drawGear(ctx, xB, cy, N1, m, th1, hueOf(rpm1));
drawGear(ctx, xC, cy, N2, m, th2, hueOf(rpm2));
// per-gear rpm labels under each gear
ctx.font = "11px monospace"; ctx.textAlign = "center";
ctx.fillStyle = "rgba(255,255,255,0.7)";
ctx.fillText(`${rpm.toFixed(1)}`, xA, cy + r0 + m + 16);
ctx.fillText(`${rpm1.toFixed(1)}`, xB, cy + r1 + m + 16);
ctx.fillText(`${rpm2.toFixed(1)} rpm`, xC, cy + r2 + m + 16);
ctx.fillStyle = "rgba(255,255,255,0.45)";
ctx.fillText("driver", xA, cy - r0 - m - 8);
// HUD
ctx.fillStyle = "rgba(0,0,0,0.6)"; ctx.fillRect(PAD, PAD, 252, 86);
ctx.font = "13px monospace"; ctx.textAlign = "left";
ctx.fillStyle = "#fff";
ctx.fillText(`teeth ${N0} : ${N1} : ${N2}`, PAD + 10, PAD + 20);
ctx.fillStyle = "#7fd4ff";
ctx.fillText(`ω ratio ×${(N0 / N1).toFixed(2)} ×${(N0 / N2).toFixed(2)}`, PAD + 10, PAD + 38);
ctx.fillStyle = "#9fe87a";
ctx.fillText(`in ${rpm.toFixed(1)} rpm → out ${rpm2.toFixed(1)}`, PAD + 10, PAD + 56);
ctx.fillStyle = "#ffb347";
ctx.fillText(`torque ×${(N2 / N0).toFixed(2)}`, PAD + 10, PAD + 74);
ctx.fillStyle = "rgba(255,255,255,0.55)"; ctx.font = "11px monospace"; ctx.textAlign = "center";
ctx.fillText("drag ⇄ to change rpm · tap a gear to change teeth", W / 2, H - 12);
}
Comments (0)
Log in to comment.