5

Ohm's Law Circuit Simulator

Drag the V and R sliders

An interactive Ohm's law simulator: a battery drives current around a simple loop, and the law sets how fast and how densely the electrons flow. The resistor glows from dark red to bright orange as dissipated power rises, and the operating point slides along the line in the corner graph. Drag the two big sliders to change voltage (1–24 V) and resistance (1–100 Ω) and watch current, power, and electron flow respond instantly.

idle
142 lines · vanilla
view source
const PAD = 12, NE = 80, TAU = Math.PI * 2;
let W, H, V, R, elec, activeSlider, prevDown, pos;

function init({ ctx, width, height }) {
  W = width; H = height;
  V = 12; R = 20;
  elec = new Float32Array(NE);
  for (let i = 0; i < NE; i++) elec[i] = i / NE;
  activeSlider = -1; prevDown = false;
  pos = new Float32Array(2);
  ctx.fillStyle = "#0a0e14"; ctx.fillRect(0, 0, W, H);
}

function posOnLoop(s, x0, y0, x1, y1) {
  const w = x1 - x0, h = y1 - y0, P = 2 * (w + h);
  let d = (s - Math.floor(s)) * P;
  if (d < w) { pos[0] = x0 + d; pos[1] = y0; return; }
  d -= w;
  if (d < h) { pos[0] = x1; pos[1] = y0 + d; return; }
  d -= h;
  if (d < w) { pos[0] = x1 - d; pos[1] = y1; return; }
  d -= w;
  pos[0] = x0; pos[1] = y1 - d;
}

function fmtA(I) { return I >= 1 ? I.toFixed(2) + " A" : (I * 1000).toFixed(0) + " mA"; }
function fmtW(P) { return P >= 1 ? P.toFixed(1) + " W" : (P * 1000).toFixed(0) + " mW"; }

function drawResistor(ctx, cx, y, hw, t) {
  ctx.save();
  ctx.shadowColor = "rgba(255,150,40,0.95)";
  ctx.shadowBlur = 4 + 28 * t;
  ctx.strokeStyle = `rgb(${(90 + 165 * t) | 0},${(25 + 150 * t) | 0},${(15 + 45 * t) | 0})`;
  ctx.lineWidth = 3.5;
  ctx.beginPath();
  ctx.moveTo(cx - hw, y);
  const n = 6, seg = (2 * hw) / n;
  for (let i = 0; i < n; i++) ctx.lineTo(cx - hw + seg * (i + 0.5), y + (i % 2 ? 9 : -9));
  ctx.lineTo(cx + hw, y);
  ctx.stroke();
  ctx.restore();
}

function drawSlider(ctx, y, sh, name, valStr, frac, active, tx0, tx1) {
  ctx.fillStyle = "rgba(0,0,0,0.55)";
  ctx.fillRect(PAD, y, W - 2 * PAD, sh - 6);
  ctx.fillStyle = "#fff"; ctx.font = "bold 14px monospace"; ctx.textAlign = "left";
  ctx.fillText(name, PAD + 10, y + 20);
  ctx.font = "11px monospace"; ctx.fillStyle = "#9fb6d4";
  ctx.fillText(valStr, PAD + 10, y + 36);
  ctx.fillStyle = "#26344a";
  ctx.fillRect(tx0, y + sh / 2 - 7, tx1 - tx0, 8);
  ctx.fillStyle = active ? "#ffb347" : "#5a8de0";
  ctx.fillRect(tx0, y + sh / 2 - 7, (tx1 - tx0) * frac, 8);
  ctx.fillStyle = active ? "#ffd17a" : "#cfe0ff";
  ctx.beginPath();
  ctx.arc(tx0 + (tx1 - tx0) * frac, y + sh / 2 - 3, 11, 0, TAU);
  ctx.fill();
}

function tick({ ctx, dt, width, height, input }) {
  if (width !== W || height !== H) { W = width; H = height; }
  const sh = 52, ry1 = H - 2 * sh - 6, ry2 = H - sh - 2;
  const md = input.mouseDown;
  if (md && !prevDown) {
    if (input.mouseY >= ry1 && input.mouseY < ry1 + sh - 4) activeSlider = 0;
    else if (input.mouseY >= ry2 && input.mouseY < ry2 + sh) activeSlider = 1;
    else activeSlider = -1;
  }
  if (!md) activeSlider = -1;
  prevDown = md;
  const tx0 = 96, tx1 = W - PAD - 16;
  if (activeSlider >= 0) {
    const f = Math.max(0, Math.min(1, (input.mouseX - tx0) / (tx1 - tx0)));
    if (activeSlider === 0) V = 1 + f * 23; else R = 1 + f * 99;
  }
  input.consumeClicks();

  const I = V / R, P = V * I;
  const glow = Math.min(1, Math.log1p(P) / Math.log1p(580));

  const gw = Math.min(180, W * 0.44), gh = Math.min(122, H * 0.24);
  const gx = W - PAD - gw, gy = PAD;
  const x0 = 38, x1 = W - 38;
  const y0 = Math.max(118, gy + gh + 18), y1 = ry1 - 24;
  const ym = ((y0 + y1) / 2) | 0, xm = (x0 + x1) / 2;
  const rhw = Math.min(58, (x1 - x0) * 0.2);

  ctx.fillStyle = "#0a0e14"; ctx.fillRect(0, 0, W, H);
  ctx.strokeStyle = "#46566e"; ctx.lineWidth = 3;
  ctx.strokeRect(x0, y0, x1 - x0, y1 - y0);

  // electrons: count and speed both scale with I = V/R
  const count = Math.min(NE, Math.round(8 + 26 * Math.sqrt(I)));
  const per = 2 * ((x1 - x0) + (y1 - y0));
  const ds = (Math.min(340, 26 + 64 * Math.sqrt(I)) * dt) / per;
  for (let i = 0; i < NE; i++) { elec[i] += ds; if (elec[i] >= 1) elec[i] -= 1; }
  ctx.fillStyle = "#7fd4ff";
  const stride = NE / count;
  for (let i = 0; i < count; i++) {
    posOnLoop(elec[(i * stride) | 0], x0, y0, x1, y1);
    const ex = pos[0], ey = pos[1];
    if (ey === y0 && Math.abs(ex - xm) < rhw + 4) continue;
    if (ex === x0 && Math.abs(ey - ym) < 18) continue;
    if (ex === x1 && Math.abs(ey - ym) < 17) continue;
    ctx.beginPath(); ctx.arc(ex, ey, 2.4, 0, TAU); ctx.fill();
  }

  drawResistor(ctx, xm, y0, rhw, glow);
  ctx.fillStyle = "#9fb6d4"; ctx.font = "11px monospace"; ctx.textAlign = "center";
  ctx.fillText(`R = ${R.toFixed(0)} Ω`, xm, y0 + 24);

  // battery
  ctx.fillStyle = "#0a0e14"; ctx.fillRect(x0 - 11, ym - 15, 22, 30);
  ctx.strokeStyle = "#e8e8e8"; ctx.lineWidth = 3;
  ctx.beginPath(); ctx.moveTo(x0 - 14, ym - 6); ctx.lineTo(x0 + 14, ym - 6); ctx.stroke();
  ctx.lineWidth = 6;
  ctx.beginPath(); ctx.moveTo(x0 - 7, ym + 6); ctx.lineTo(x0 + 7, ym + 6); ctx.stroke();
  ctx.fillStyle = "#ffd17a"; ctx.font = "12px monospace"; ctx.textAlign = "left";
  ctx.fillText("+", x0 + 18, ym - 9);
  ctx.fillText("−", x0 + 18, ym + 15);
  ctx.fillStyle = "#9fb6d4"; ctx.textAlign = "center"; ctx.font = "11px monospace";
  ctx.fillText(`${V.toFixed(1)} V`, x0 + 6, ym + 34);

  // ammeter
  ctx.fillStyle = "#101826";
  ctx.beginPath(); ctx.arc(x1, ym, 15, 0, TAU); ctx.fill();
  ctx.strokeStyle = "#7fd4ff"; ctx.lineWidth = 2; ctx.stroke();
  ctx.fillStyle = "#7fd4ff"; ctx.font = "bold 13px monospace";
  ctx.fillText("A", x1, ym + 4);
  ctx.fillStyle = "#9fb6d4"; ctx.font = "11px monospace";
  ctx.fillText(fmtA(I), x1 - 6, ym + 33);

  // V-I graph with sliding operating point
  ctx.fillStyle = "rgba(0,0,0,0.6)"; ctx.fillRect(gx, gy, gw, gh);
  ctx.strokeStyle = "rgba(255,255,255,0.25)"; ctx.lineWidth = 1;
  ctx.strokeRect(gx + 0.5, gy + 0.5, gw - 1, gh - 1);
  const lx = gx + 24, lyB = gy + gh - 16, lw = gw - 34, lh = gh - 32;
  const ImaxG = (24 / R) * 1.12;
  ctx.strokeStyle = "rgba(255,255,255,0.4)";
  ctx.beginPath(); ctx.moveTo(lx, gy + 12); ctx.lineTo(lx, lyB); ctx.lineTo(lx + lw, lyB); ctx.stroke();
  ctx.strokeStyle = "#5a8de0"; ctx.lineWidth = 2;
  ctx.beginPath(); ctx.moveTo(lx, lyB);
  ctx.lineTo(lx + lw, lyB - ((24 / R) / ImaxG) * lh); ctx.stroke();
  ctx.fillStyle = "#ffb347";
  ctx.beginPath(); ctx.arc(lx + (V / 24) * lw, lyB - (I / ImaxG) * lh, 4.5, 0, TAU); ctx.fill();
  ctx.fillStyle = "rgba(255,255,255,0.65)"; ctx.font = "10px monospace";
  ctx.textAlign = "left"; ctx.fillText("I = V/R", lx + 6, gy + 14);
  ctx.textAlign = "right"; ctx.fillText("V→24", gx + gw - 6, lyB + 12);

  // HUD
  ctx.fillStyle = "rgba(0,0,0,0.6)"; ctx.fillRect(PAD, PAD, 148, 92);
  ctx.fillStyle = "#fff"; ctx.font = "13px monospace"; ctx.textAlign = "left";
  ctx.fillText(`V = ${V.toFixed(1)} V`, PAD + 10, PAD + 22);
  ctx.fillText(`R = ${R.toFixed(0)} Ω`, PAD + 10, PAD + 40);
  ctx.fillStyle = "#7fd4ff";
  ctx.fillText(`I = ${fmtA(I)}`, PAD + 10, PAD + 58);
  ctx.fillStyle = "#ffb347";
  ctx.fillText(`P = ${fmtW(P)}`, PAD + 10, PAD + 76);

  drawSlider(ctx, ry1, sh, "V", `${V.toFixed(1)} V`, (V - 1) / 23, activeSlider === 0, tx0, tx1);
  drawSlider(ctx, ry2, sh, "R", `${R.toFixed(0)} Ω`, (R - 1) / 99, activeSlider === 1, tx0, tx1);
}

Comments (0)

Log in to comment.