Ex: 0.00 N/C
Ey: 0.00 N/C
|E|: 0.00 N/C
Developer Reference

Core Algorithm & Standalone Script

Standalone, zero-dependency JavaScript implementation powering this tool. Free to inspect, copy, and build upon.

const canvas = document.getElementById('canvas');
    const ctx = canvas.getContext('2d');

    const K = 8.99e9; // Coulomb's constant (normalized for display)
    const K_DISPLAY = 1e4; // Scaled for visualization

    // State
    let charges = [
      { x: 200, y: 200, q: 2 },
      { x: canvas.offsetWidth - 200, y: 200, q: -2 }
    ];

    let addingCharge = null; // null, 'positive', or 'negative'
    let draggingIndex = -1;
    let dragOffsetX = 0;
    let dragOffsetY = 0;

    let showFieldLines = true;
    let showColorField = true;
    let showEquipotential = false;

    // Resize canvas
    function resizeCanvas() {
      canvas.width = canvas.offsetWidth;
      canvas.height = canvas.offsetHeight;
      draw();
    }

    window.addEventListener('resize', resizeCanvas);
    resizeCanvas();

    // Calculate electric field at point (x, y)
    function getFieldAt(x, y) {
      let Ex = 0, Ey = 0;
      for (let charge of charges) {
        const dx = x - charge.x;
        const dy = y - charge.y;
        const r = Math.sqrt(dx * dx + dy * dy);
        if (r > 1) {
          const E = (K_DISPLAY * charge.q) / (r * r);
          Ex += E * (dx / r);
          Ey += E * (dy / r);
        }
      }
      return { Ex, Ey, mag: Math.sqrt(Ex * Ex + Ey * Ey) };
    }

    // Trace field line using Euler method
    function traceFieldLine(startX, startY, charge) {
      const path = [{ x: startX, y: startY }];
      let x = startX, y = startY;
      const stepSize = 2;
      const maxSteps = 1000;
      const minFieldMag = 0.1;

      for (let step = 0; step < maxSteps; step++) {
        const field = getFieldAt(x, y);
        if (field.mag < minFieldMag) break;

        const nx = x + (charge.q > 0 ? field.Ex : -field.Ex) / field.mag * stepSize;
        const ny = y + (charge.q > 0 ? field.Ey : -field.Ey) / field.mag * stepSize;

        // Check bounds
        if (nx < 0 || nx > canvas.width || ny < 0 || ny > canvas.height) break;

        // Check collision with other charges
        let hitCharge = false;
        for (let other of charges) {
          const dist = Math.hypot(nx - other.x, ny - other.y);
          if (dist < 15) {
            hitCharge = true;
            break;
          }
        }
        if (hitCharge) break;

        path.push({ x: nx, y: ny });
        x = nx;
        y = ny;
      }
      return path;
    }

    // Draw field lines
    function drawFieldLines() {
      for (let charge of charges) {
        const lineCount = Math.max(4, Math.ceil(Math.abs(charge.q) * 4));
        const color = charge.q > 0 ? '#ff2200' : '#4488ff';

        for (let i = 0; i < lineCount; i++) {
          const angle = (i / lineCount) * Math.PI * 2;
          const startX = charge.x + Math.cos(angle) * 12;
          const startY = charge.y + Math.sin(angle) * 12;

          const path = traceFieldLine(startX, startY, charge);

          // Draw line
          ctx.strokeStyle = color;
          ctx.lineWidth = 1.5;
          ctx.globalAlpha = 0.7;
          ctx.beginPath();
          ctx.moveTo(path[0].x, path[0].y);
          for (let j = 1; j < path.length; j++) {
            ctx.lineTo(path[j].x, path[j].y);
          }
          ctx.stroke();
          ctx.globalAlpha = 1;

          // Draw arrowheads
          for (let j = 10; j < path.length; j += 15) {
            const p1 = path[j - 1];
            const p2 = path[j];
            const angle = Math.atan2(p2.y - p1.y, p2.x - p1.x);
            drawArrow(p2.x, p2.y, angle, color);
          }
        }
      }
    }

    // Draw arrowhead
    function drawArrow(x, y, angle, color) {
      const len = 8;
      ctx.save();
      ctx.translate(x, y);
      ctx.rotate(angle);
      ctx.fillStyle = color;
      ctx.beginPath();
      ctx.moveTo(0, 0);
      ctx.lineTo(-len, -len / 2);
      ctx.lineTo(-len * 0.6, 0);
      ctx.lineTo(-len, len / 2);
      ctx.closePath();
      ctx.fill();
      ctx.restore();
    }

    // Draw color field overlay
    function drawColorField() {
      const gridSize = 60;
      const cellW = canvas.width / gridSize;
      const cellH = canvas.height / gridSize;

      let maxField = 0;
      const fieldGrid = [];

      // Calculate field on grid
      for (let iy = 0; iy < gridSize; iy++) {
        fieldGrid[iy] = [];
        for (let ix = 0; ix < gridSize; ix++) {
          const x = ix * cellW + cellW / 2;
          const y = iy * cellH + cellH / 2;
          const field = getFieldAt(x, y);
          fieldGrid[iy][ix] = field;
          maxField = Math.max(maxField, field.mag);
        }
      }

      // Draw cells
      ctx.globalAlpha = 0.3;
      for (let iy = 0; iy < gridSize; iy++) {
        for (let ix = 0; ix < gridSize; ix++) {
          const field = fieldGrid[iy][ix];
          const normMag = Math.log(field.mag + 1) / Math.log(maxField + 1);

          // Determine hue based on dominant charge influence
          let hue = 0;
          let posInfluence = 0, negInfluence = 0;
          for (let charge of charges) {
            const dx = ix * cellW + cellW / 2 - charge.x;
            const dy = iy * cellH + cellH / 2 - charge.y;
            const r = Math.sqrt(dx * dx + dy * dy) + 1;
            if (charge.q > 0) {
              posInfluence += charge.q / r;
            } else {
              negInfluence += Math.abs(charge.q) / r;
            }
          }

          if (posInfluence > negInfluence) {
            hue = 0; // Red
          } else {
            hue = 240; // Blue
          }

          const brightness = Math.floor(50 + normMag * 150);
          ctx.fillStyle = `hsl(${hue}, 100%, ${brightness}%)`;
          ctx.fillRect(ix * cellW, iy * cellH, cellW, cellH);
        }
      }
      ctx.globalAlpha = 1;
    }

    // Draw equipotential lines (simplified marching squares)
    function drawEquipotential() {
      const gridSize = 40;
      const cellW = canvas.width / gridSize;
      const cellH = canvas.height / gridSize;

      // Calculate potential on grid
      const potentialGrid = [];
      let minPot = Infinity, maxPot = -Infinity;

      for (let iy = 0; iy < gridSize + 1; iy++) {
        potentialGrid[iy] = [];
        for (let ix = 0; ix < gridSize + 1; ix++) {
          const x = ix * cellW;
          const y = iy * cellH;
          let V = 0;
          for (let charge of charges) {
            const r = Math.hypot(x - charge.x, y - charge.y) + 0.1;
            V += (K_DISPLAY * charge.q) / r;
          }
          potentialGrid[iy][ix] = V;
          minPot = Math.min(minPot, V);
          maxPot = Math.max(maxPot, V);
        }
      }

      // Draw contours
      const levels = 12;
      ctx.strokeStyle = '#666666';
      ctx.lineWidth = 0.5;
      ctx.globalAlpha = 0.5;

      for (let level = 0; level < levels; level++) {
        const targetV = minPot + (maxPot - minPot) * (level / levels);
        // Simple contour drawing (trace where V is close to targetV)
        for (let iy = 0; iy < gridSize; iy++) {
          for (let ix = 0; ix < gridSize; ix++) {
            const v00 = potentialGrid[iy][ix];
            const v10 = potentialGrid[iy][ix + 1];
            const v01 = potentialGrid[iy + 1][ix];
            const v11 = potentialGrid[iy + 1][ix + 1];

            // Check if contour crosses this cell
            const cross0 = (v00 - targetV) * (v10 - targetV) < 0;
            const cross1 = (v01 - targetV) * (v11 - targetV) < 0;
            const cross2 = (v00 - targetV) * (v01 - targetV) < 0;
            const cross3 = (v10 - targetV) * (v11 - targetV) < 0;

            if (cross0 || cross1 || cross2 || cross3) {
              const x = ix * cellW;
              const y = iy * cellH;
              ctx.fillRect(x, y, cellW, cellH);
            }
          }
        }
      }
      ctx.globalAlpha = 1;
    }

    // Draw charges
    function drawCharges() {
      for (let i = 0; i < charges.length; i++) {
        const charge = charges[i];
        const color = charge.q > 0 ? '#ff2200' : '#4488ff';
        const radius = Math.min(Math.abs(charge.q) * 6, 20);

        // Circle
        ctx.fillStyle = color;
        ctx.globalAlpha = 0.3;
        ctx.beginPath();
        ctx.arc(charge.x, charge.y, radius + 5, 0, Math.PI * 2);
        ctx.fill();
        ctx.globalAlpha = 1;

        // Dot
        ctx.fillStyle = color;
        ctx.beginPath();
        ctx.arc(charge.x, charge.y, radius, 0, Math.PI * 2);
        ctx.fill();

        // Label
        ctx.fillStyle = '#e8e0d5';
        ctx.font = 'bold 12px DM Mono';
        ctx.textAlign = 'center';
        ctx.textBaseline = 'middle';
        ctx.fillText(charge.q.toFixed(1), charge.x, charge.y);
      }
    }

    // Main draw function
    function draw() {
      // Clear
      ctx.fillStyle = '#0a0a0a';
      ctx.fillRect(0, 0, canvas.width, canvas.height);

      if (showColorField) drawColorField();
      if (showEquipotential) drawEquipotential();
      if (showFieldLines) drawFieldLines();
      drawCharges();
    }

    // Update charge list UI
    function updateChargeList() {
      const list = document.getElementById('chargesList');
      list.innerHTML = '';

      charges.forEach((charge, i) => {
        const div = document.createElement('div');
        div.className = `charge-item ${charge.q > 0 ? 'positive' : 'negative'}`;

        div.innerHTML = `
          <div class="charge-item-header">
            <span class="charge-item-label">Charge ${i + 1}</span>
            <button class="charge-item-delete" onclick="deleteCharge(${i})">×</button>
          </div>
          <div class="slider-group">
            <label class="slider-label">
              <span>Magnitude</span>
              <span id="qValue${i}">${charge.q.toFixed(2)}</span>
            </label>
            <input
              type="range"
              class="slider-input"
              min="-5"
              max="5"
              step="0.1"
              value="${charge.q}"
              onchange="updateChargeQ(${i}, this.value)"
              oninput="updateChargeQPreview(${i}, this.value)"
            />
          </div>
        `;
        list.appendChild(div);
      });
    }

    function updateChargeQ(index, value) {
      charges[index].q = parseFloat(value);
      document.getElementById(`qValue${index}`).textContent = charges[index].q.toFixed(2);
      draw();
    }

    function updateChargeQPreview(index, value) {
      document.getElementById(`qValue${index}`).textContent = parseFloat(value).toFixed(2);
      draw();
    }

    function deleteCharge(index) {
      charges.splice(index, 1);
      updateChargeList();
      draw();
    }

    // Toggle buttons
    document.querySelectorAll('[data-toggle]').forEach(label => {
      label.addEventListener('click', function() {
        const checkbox = this.querySelector('input[type="checkbox"]');
        checkbox.checked = !checkbox.checked;

        const toggle = this.dataset.toggle;
        if (toggle === 'fieldLines') {
          showFieldLines = checkbox.checked;
        } else if (toggle === 'colorField') {
          showColorField = checkbox.checked;
        } else if (toggle === 'equipotential') {
          showEquipotential = checkbox.checked;
        }

        this.classList.toggle('active', checkbox.checked);
        draw();
      });
    });

    // Add charge mode
    document.getElementById('addPositive').addEventListener('click', () => {
      addingCharge = addingCharge === 'positive' ? null : 'positive';
      document.getElementById('addPositive').style.opacity = addingCharge === 'positive' ? '1' : '0.6';
      canvas.style.cursor = addingCharge ? 'pointer' : 'crosshair';
    });

    document.getElementById('addNegative').addEventListener('click', () => {
      addingCharge = addingCharge === 'negative' ? null : 'negative';
      document.getElementById('addNegative').style.opacity = addingCharge === 'negative' ? '1' : '0.6';
      canvas.style.cursor = addingCharge ? 'pointer' : 'crosshair';
    });

    // Canvas interactions
    canvas.addEventListener('mousedown', (e) => {
      const rect = canvas.getBoundingClientRect();
      const x = e.clientX - rect.left;
      const y = e.clientY - rect.top;

      if (addingCharge) {
        charges.push({ x, y, q: addingCharge === 'positive' ? 1 : -1 });
        addingCharge = null;
        document.getElementById('addPositive').style.opacity = '0.6';
        document.getElementById('addNegative').style.opacity = '0.6';
        canvas.style.cursor = 'crosshair';
        updateChargeList();
        draw();
        return;
      }

      // Check if clicking on a charge
      for (let i = 0; i < charges.length; i++) {
        const dx = x - charges[i].x;
        const dy = y - charges[i].y;
        if (Math.sqrt(dx * dx + dy * dy) < 25) {
          if (e.button === 2) {
            // Right click to delete
            deleteCharge(i);
          } else {
            draggingIndex = i;
            dragOffsetX = dx;
            dragOffsetY = dy;
            canvas.style.cursor = 'grabbing';
          }
          return;
        }
      }
    });

    canvas.addEventListener('mousemove', (e) => {
      const rect = canvas.getBoundingClientRect();
      const x = e.clientX - rect.left;
      const y = e.clientY - rect.top;

      if (draggingIndex >= 0) {
        charges[draggingIndex].x = x - dragOffsetX;
        charges[draggingIndex].y = y - dragOffsetY;
        draw();
      }

      // Update cursor field info
      const field = getFieldAt(x, y);
      document.getElementById('exValue').textContent = field.Ex.toFixed(2);
      document.getElementById('eyValue').textContent = field.Ey.toFixed(2);
      document.getElementById('magnValue').textContent = field.mag.toFixed(2);
    });

    canvas.addEventListener('mouseup', () => {
      draggingIndex = -1;
      canvas.style.cursor = addingCharge ? 'pointer' : 'crosshair';
    });

    canvas.addEventListener('contextmenu', (e) => e.preventDefault());

    // Buttons
    document.getElementById('resetBtn').addEventListener('click', () => {
      charges = [
        { x: 200, y: 200, q: 2 },
        { x: canvas.offsetWidth - 200, y: 200, q: -2 }
      ];
      addingCharge = null;
      document.getElementById('addPositive').style.opacity = '0.6';
      document.getElementById('addNegative').style.opacity = '0.6';
      canvas.style.cursor = 'crosshair';
      updateChargeList();
      draw();
    });

    document.getElementById('clearBtn').addEventListener('click', () => {
      charges = [];
      addingCharge = null;
      document.getElementById('addPositive').style.opacity = '0.6';
      document.getElementById('addNegative').style.opacity = '0.6';
      canvas.style.cursor = 'crosshair';
      updateChargeList();
      draw();
    });

    // Initialize
    updateChargeList();
    draw();