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();