9

Electric Field Lines: Drag the Charges

drag a charge, tap empty space to add one

An electric field lines simulator for point charges: each line follows summed over every charge, leaving positive charges (red) and landing on negative ones (blue), with arrowheads marking the direction a test charge would feel. The lines re-trace live as you press and drag any charge, so you can watch a dipole's loops stretch into the fringing pattern of a capacitor or the spray of a lone monopole. Tap empty space to add charges (alternating + and −, up to six) and hit RESET to return to the classic dipole; the HUD reads out the field magnitude under your finger while you drag.

idle
157 lines · vanilla
view source
const K = 26000, GRAB = 38, BTN = 44, PAD = 12;
let charges, W, H, dragIdx, nextQ, wasDown;
let fx = 0, fy = 0; // fieldAt output

function init({ ctx, width, height }) {
  W = width; H = height;
  dragIdx = -1; nextQ = 1; wasDown = false;
  resetCharges();
}

function resetCharges() {
  charges = [
    { x: W * 0.34, y: H * 0.5, q: 1 },
    { x: W * 0.66, y: H * 0.5, q: -1 },
  ];
  nextQ = 1;
}

function fieldAt(x, y) {
  fx = 0; fy = 0;
  for (let i = 0; i < charges.length; i++) {
    const c = charges[i];
    const dx = x - c.x, dy = y - c.y;
    const r2 = dx * dx + dy * dy + 40;
    const inv = (c.q * K) / (r2 * Math.sqrt(r2));
    fx += dx * inv; fy += dy * inv;
  }
}

function nearest(mx, my) {
  let best = -1, bd = GRAB * GRAB;
  for (let i = 0; i < charges.length; i++) {
    const dx = mx - charges[i].x, dy = my - charges[i].y;
    const d = dx * dx + dy * dy;
    if (d < bd) { bd = d; best = i; }
  }
  return best;
}

function traceLine(ctx, sx, sy) {
  let x = sx, y = sy, ax = 0, ay = 0, adx = 0, ady = 0;
  ctx.beginPath(); ctx.moveTo(x, y);
  for (let s = 0; s < 340; s++) {
    fieldAt(x, y);
    let m = Math.sqrt(fx * fx + fy * fy);
    if (m < 1e-6) break;
    const mx2 = x + 2.5 * fx / m, my2 = y + 2.5 * fy / m;
    fieldAt(mx2, my2);
    m = Math.sqrt(fx * fx + fy * fy);
    if (m < 1e-6) break;
    const ux = fx / m, uy = fy / m;
    x += 5 * ux; y += 5 * uy;
    ctx.lineTo(x, y);
    if (s === 42) { ax = x; ay = y; adx = ux; ady = uy; }
    if (x < -60 || x > W + 60 || y < -60 || y > H + 60) break;
    let stop = false;
    for (let i = 0; i < charges.length; i++) {
      if (charges[i].q < 0) {
        const dx = x - charges[i].x, dy = y - charges[i].y;
        if (dx * dx + dy * dy < 144) { ctx.lineTo(charges[i].x, charges[i].y); stop = true; break; }
      }
    }
    if (stop) break;
  }
  ctx.stroke();
  if (adx !== 0 || ady !== 0) {
    ctx.beginPath();
    ctx.moveTo(ax + adx * 6, ay + ady * 6);
    ctx.lineTo(ax - adx * 4 - ady * 4, ay - ady * 4 + adx * 4);
    ctx.lineTo(ax - adx * 4 + ady * 4, ay - ady * 4 - adx * 4);
    ctx.fill();
  }
}

function inRect(x, y, rx, ry, rw, rh) { return x >= rx && x <= rx + rw && y >= ry && y <= ry + rh; }

function tick({ ctx, dt, width, height, input }) {
  if (width !== W || height !== H) {
    const sx = width / W, sy = height / H;
    for (const c of charges) { c.x *= sx; c.y *= sy; }
    W = width; H = height;
  }
  const rbW = 88, rbX = W - PAD - rbW, rbY = H - PAD - BTN;

  if (input.mouseDown && !wasDown && !inRect(input.mouseX, input.mouseY, rbX, rbY, rbW, BTN))
    dragIdx = nearest(input.mouseX, input.mouseY);
  if (!input.mouseDown) dragIdx = -1;
  wasDown = input.mouseDown;
  if (dragIdx >= 0) {
    charges[dragIdx].x = Math.max(10, Math.min(W - 10, input.mouseX));
    charges[dragIdx].y = Math.max(10, Math.min(H - 10, input.mouseY));
  }

  for (const c of input.consumeClicks()) {
    if (inRect(c.x, c.y, rbX, rbY, rbW, BTN)) { resetCharges(); continue; }
    if (nearest(c.x, c.y) < 0 && charges.length < 6) {
      charges.push({ x: c.x, y: c.y, q: nextQ });
      nextQ = -nextQ;
    }
  }

  ctx.fillStyle = '#070a12';
  ctx.fillRect(0, 0, W, H);

  // sparse field-vector texture
  ctx.strokeStyle = 'rgba(120,150,210,0.28)';
  ctx.lineWidth = 1;
  for (let gy = 30; gy < H; gy += 48) {
    for (let gx = 30; gx < W; gx += 48) {
      fieldAt(gx, gy);
      const m = Math.sqrt(fx * fx + fy * fy);
      if (m < 1e-6) continue;
      const L = Math.min(13, 4 + m * 3);
      ctx.beginPath();
      ctx.moveTo(gx - fx / m * L * 0.5, gy - fy / m * L * 0.5);
      ctx.lineTo(gx + fx / m * L * 0.5, gy + fy / m * L * 0.5);
      ctx.stroke();
    }
  }

  // field lines from every positive charge
  ctx.lineWidth = 1.4;
  ctx.strokeStyle = 'rgba(255,214,110,0.75)';
  ctx.fillStyle = 'rgba(255,214,110,0.75)';
  for (let i = 0; i < charges.length; i++) {
    if (charges[i].q <= 0) continue;
    for (let s = 0; s < 16; s++) {
      const a = (s / 16) * Math.PI * 2;
      traceLine(ctx, charges[i].x + Math.cos(a) * 13, charges[i].y + Math.sin(a) * 13);
    }
  }

  // charges
  for (let i = 0; i < charges.length; i++) {
    const c = charges[i];
    const col = c.q > 0 ? '255,80,70' : '80,140,255';
    const g = ctx.createRadialGradient(c.x, c.y, 2, c.x, c.y, 30);
    g.addColorStop(0, `rgba(${col},0.85)`);
    g.addColorStop(1, `rgba(${col},0)`);
    ctx.fillStyle = g;
    ctx.beginPath(); ctx.arc(c.x, c.y, 30, 0, Math.PI * 2); ctx.fill();
    ctx.fillStyle = c.q > 0 ? '#ff5046' : '#508cff';
    ctx.beginPath(); ctx.arc(c.x, c.y, 11, 0, Math.PI * 2); ctx.fill();
    ctx.fillStyle = '#fff';
    ctx.font = 'bold 16px ui-sans-serif, system-ui';
    ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
    ctx.fillText(c.q > 0 ? '+' : '−', c.x, c.y + 1);
  }

  // HUD
  ctx.fillStyle = 'rgba(0,0,0,0.55)';
  ctx.fillRect(PAD, PAD, 208, 62);
  ctx.fillStyle = '#cfe0ff';
  ctx.font = '13px monospace';
  ctx.textAlign = 'left'; ctx.textBaseline = 'alphabetic';
  ctx.fillText(`charges: ${charges.length}/6`, PAD + 10, PAD + 20);
  if (input.mouseDown) {
    fieldAt(input.mouseX, input.mouseY);
    ctx.fillText(`|E| = ${Math.sqrt(fx * fx + fy * fy).toFixed(2)}`, PAD + 10, PAD + 38);
  } else {
    ctx.fillText('drag charge / tap = add', PAD + 10, PAD + 38);
  }
  ctx.fillStyle = 'rgba(207,224,255,0.7)';
  ctx.font = '11px monospace';
  ctx.fillText(`next tap adds ${nextQ > 0 ? '+' : '−'}`, PAD + 10, PAD + 56);

  // reset button
  const hot = input.mouseDown && inRect(input.mouseX, input.mouseY, rbX, rbY, rbW, BTN);
  ctx.fillStyle = hot ? 'rgba(255,160,60,0.85)' : 'rgba(0,0,0,0.6)';
  ctx.fillRect(rbX, rbY, rbW, BTN);
  ctx.strokeStyle = 'rgba(255,255,255,0.45)';
  ctx.strokeRect(rbX + 0.5, rbY + 0.5, rbW - 1, BTN - 1);
  ctx.fillStyle = '#fff';
  ctx.font = 'bold 14px ui-sans-serif, system-ui';
  ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
  ctx.fillText('RESET', rbX + rbW / 2, rbY + BTN / 2);
}

Comments (0)

Log in to comment.