SimulationOfParticles / index.html
wop's picture
Update index.html
b79f369 verified
<!DOCTYPE html>
<html lang="en" class="h-full">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Coulomb's Law & Electric Field Visualizer</title>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<style>
/* Custom styled sliders for consistent UI */
input[type="range"] {
-webkit-appearance: none;
appearance: none;
background: #2d3748;
height: 6px;
border-radius: 9999px;
outline: none;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: #3b82f6;
cursor: pointer;
transition: transform 0.1s ease;
}
input[type="range"]::-webkit-slider-thumb:hover {
transform: scale(1.2);
}
/* Glowing neon utilities */
.neon-shadow-red {
box-shadow: 0 0 15px rgba(239, 68, 68, 0.6);
}
.neon-shadow-blue {
box-shadow: 0 0 15px rgba(59, 130, 246, 0.6);
}
</style>
</head>
<body class="bg-[#0b0f19] text-gray-200 h-full overflow-hidden flex flex-col font-sans select-none">
<!-- Header / Navbar -->
<header class="bg-[#111827]/80 backdrop-blur border-b border-gray-800 px-6 py-4 flex items-center justify-between z-10 shrink-0">
<div class="flex items-center space-x-3">
<div class="bg-gradient-to-tr from-blue-500 to-red-500 p-2 rounded-lg">
<svg class="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<div>
<h1 class="text-lg font-bold tracking-tight text-white flex items-center gap-2">
Coulomb Field Sandbox <span class="text-xs bg-gray-800 text-blue-400 border border-blue-500/30 px-2 py-0.5 rounded-full font-mono">v2.5</span>
</h1>
<p class="text-xs text-gray-400 hidden sm:block">Dynamic electric field lines & many-body Coulomb interactions</p>
</div>
</div>
<!-- Desktop Quick Stats -->
<div class="flex items-center space-x-6 text-xs text-gray-400 font-mono">
<div>Charges: <span id="stat-charges" class="text-white font-bold">0</span></div>
<div>FPS: <span id="stat-fps" class="text-emerald-400 font-bold">60</span></div>
</div>
</header>
<!-- Main Container Layout -->
<main class="flex-1 flex flex-col md:flex-row relative overflow-hidden">
<!-- Canvas Viewport -->
<div class="flex-1 relative bg-black overflow-hidden h-full">
<canvas id="physics-canvas" class="block w-full h-full cursor-grab active:cursor-grabbing"></canvas>
<!-- On-Screen Controls Overlay (Floating) -->
<div class="absolute bottom-6 left-6 right-6 md:right-auto flex flex-wrap gap-3 z-10">
<button id="btn-add-pos" class="flex items-center gap-2 px-4 py-2.5 bg-red-600/90 hover:bg-red-500 text-white font-semibold rounded-lg shadow-lg hover:shadow-red-500/20 active:scale-95 transition duration-150 text-sm">
<span class="text-lg font-black">+</span> Add Positive
</button>
<button id="btn-add-neg" class="flex items-center gap-2 px-4 py-2.5 bg-blue-600/90 hover:bg-blue-500 text-white font-semibold rounded-lg shadow-lg hover:shadow-blue-500/20 active:scale-95 transition duration-150 text-sm">
<span class="text-lg font-black">&minus;</span> Add Negative
</button>
<button id="btn-clear" class="flex items-center gap-2 px-4 py-2.5 bg-gray-800/90 hover:bg-gray-700 text-gray-300 rounded-lg border border-gray-700 hover:border-gray-600 active:scale-95 transition duration-150 text-sm">
Clear Sandbox
</button>
</div>
<!-- Welcome/Instructive overlay banner -->
<div id="instruction-overlay" class="absolute top-4 left-4 right-4 bg-gray-950/85 border border-gray-800 p-4 rounded-xl max-w-md pointer-events-none transition-opacity duration-500 z-10">
<h3 class="text-sm font-semibold text-white flex items-center gap-2">
💡 Sandbox Instructions
</h3>
<ul class="text-xs text-gray-400 mt-1.5 space-y-1 list-disc list-inside">
<li>Drag existing charges to reposition them.</li>
<li>Toggle physics to let them orbit, attract, or repel!</li>
<li>Add multiple charges to see intricate field patterns.</li>
</ul>
</div>
</div>
<!-- Sidebar Config & Tuning Panel -->
<aside class="w-full md:w-80 bg-[#111827] border-t md:border-t-0 md:border-l border-gray-800 p-6 flex flex-col overflow-y-auto shrink-0 z-10 max-h-[40vh] md:max-h-none">
<!-- Subsection: Preset Configurations -->
<div class="mb-6">
<h2 class="text-xs font-semibold text-gray-400 tracking-wider uppercase mb-3">Simulation Presets</h2>
<div class="grid grid-cols-2 gap-2">
<button class="preset-btn px-3 py-2 bg-gray-800 hover:bg-gray-700 rounded-lg text-xs font-medium text-white transition text-center" data-preset="dipole">
🧲 Dipole
</button>
<button class="preset-btn px-3 py-2 bg-gray-800 hover:bg-gray-700 rounded-lg text-xs font-medium text-white transition text-center" data-preset="quadrupole">
🌀 Quadrupole
</button>
<button class="preset-btn px-3 py-2 bg-gray-800 hover:bg-gray-700 rounded-lg text-xs font-medium text-white transition text-center" data-preset="chaos">
💫 Chaotic Orbit
</button>
<button class="preset-btn px-3 py-2 bg-gray-800 hover:bg-gray-700 rounded-lg text-xs font-medium text-white transition text-center" data-preset="grid">
⏹️ Particle Grid
</button>
</div>
</div>
<!-- Subsection: Physics Play/Pause -->
<div class="mb-6 flex gap-2">
<button id="btn-toggle-physics" class="flex-1 py-2.5 rounded-lg font-bold text-sm flex items-center justify-center gap-2 shadow transition duration-200 bg-emerald-600 hover:bg-emerald-500 text-white">
<span id="play-pause-icon"></span> <span id="play-pause-text">Pause Physics</span>
</button>
</div>
<!-- Divider -->
<hr class="border-gray-800 mb-6" />
<!-- Subsection: Toggles -->
<div class="space-y-3 mb-6">
<h2 class="text-xs font-semibold text-gray-400 tracking-wider uppercase mb-1">Display Toggles</h2>
<label class="flex items-center justify-between cursor-pointer py-1">
<span class="text-xs text-gray-300">Show Field Lines</span>
<input type="checkbox" id="toggle-field-lines" class="rounded bg-gray-800 border-gray-700 text-blue-600 focus:ring-blue-500 h-4 w-4" checked>
</label>
<label class="flex items-center justify-between cursor-pointer py-1">
<span class="text-xs text-gray-300">Animate Vector Flow</span>
<input type="checkbox" id="toggle-vector-flow" class="rounded bg-gray-800 border-gray-700 text-blue-600 focus:ring-blue-500 h-4 w-4" checked>
</label>
<label class="flex items-center justify-between cursor-pointer py-1">
<span class="text-xs text-gray-300">Bounce on Canvas Edge</span>
<input type="checkbox" id="toggle-bounds" class="rounded bg-gray-800 border-gray-700 text-blue-600 focus:ring-blue-500 h-4 w-4" checked>
</label>
</div>
<!-- Divider -->
<hr class="border-gray-800 mb-6" />
<!-- Subsection: Param Sliders -->
<div class="space-y-5 flex-1">
<h2 class="text-xs font-semibold text-gray-400 tracking-wider uppercase mb-1">Physical Constants</h2>
<!-- Coulomb Strength -->
<div>
<div class="flex justify-between items-center mb-1.5">
<span class="text-xs text-gray-300">Coulomb Constant ($k_e$)</span>
<span id="val-k" class="text-xs font-mono text-blue-400">1000</span>
</div>
<input type="range" id="slide-k" min="100" max="5000" step="50" value="1200" class="w-full">
</div>
<!-- Simulation Friction (Damping) -->
<div>
<div class="flex justify-between items-center mb-1.5">
<span class="text-xs text-gray-300">Medium Viscosity (Damping)</span>
<span id="val-damping" class="text-xs font-mono text-blue-400">1%</span>
</div>
<input type="range" id="slide-damping" min="0" max="100" step="1" value="5" class="w-full">
</div>
<!-- Softening Factor (to avoid divide by zero singularities) -->
<div>
<div class="flex justify-between items-center mb-1.5">
<span class="text-xs text-gray-300">Singularity Softening ($\epsilon^2$)</span>
<span id="val-softening" class="text-xs font-mono text-blue-400">400</span>
</div>
<input type="range" id="slide-softening" min="100" max="2500" step="50" value="400" class="w-full">
</div>
<!-- Line Density -->
<div>
<div class="flex justify-between items-center mb-1.5">
<span class="text-xs text-gray-300">Lines per Charge</span>
<span id="val-density" class="text-xs font-mono text-blue-400">12</span>
</div>
<input type="range" id="slide-density" min="4" max="24" step="2" value="12" class="w-full">
</div>
<!-- Vector Flow speed -->
<div>
<div class="flex justify-between items-center mb-1.5">
<span class="text-xs text-gray-300">Flow Indicator Velocity</span>
<span id="val-flow-speed" class="text-xs font-mono text-blue-400">Medium</span>
</div>
<input type="range" id="slide-flow-speed" min="1" max="10" step="0.5" value="4" class="w-full">
</div>
</div>
<!-- Bottom Disclaimer/Footer info -->
<div class="mt-6 pt-6 border-t border-gray-800 text-[10px] text-gray-500 leading-relaxed font-mono">
Formula used: $F = k_e \frac{q_1 q_2}{r^2 + \epsilon^2}$. Lines integrated via Euler-Heun path-tracer.
</div>
</aside>
</main>
<!-- App Logic Script -->
<script>
// Setup state variables
const canvas = document.getElementById('physics-canvas');
const ctx = canvas.getContext('2d');
// High DPI Support
function resizeCanvas() {
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * window.devicePixelRatio;
canvas.height = rect.height * window.devicePixelRatio;
ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
}
window.addEventListener('resize', resizeCanvas);
// Physics variables & configs
let particles = [];
let physicsEnabled = true;
let showFieldLines = true;
let showVectorFlow = true;
let bounceBounds = true;
// Constants from slider inputs
let k_e = 1200;
let dampingCoeff = 0.005; // Friction % (damping = 1 - dampingCoeff)
let softening = 400; // soft factor to prevent infinite forces at proximity
let linesPerCharge = 12;
let flowVelocityMultiplier = 4;
// Interaction state
let selectedParticle = null;
let isDragging = false;
let lastMousePos = { x: 0, y: 0 };
// FPS tracking
let lastTime = performance.now();
let fps = 60;
let flowAnimOffset = 0; // global offset parameter for flowing arrows
// Model of particle charge
class ChargeParticle {
constructor(x, y, charge) {
this.x = x;
this.y = y;
this.charge = charge; // Positive (+1) or Negative (-1)
this.vx = 0;
this.vy = 0;
this.radius = 18;
this.mass = Math.abs(charge) * 1.5; // proportional to charge value
}
draw() {
ctx.save();
ctx.beginPath();
ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2);
// Glow style based on charge sign
if (this.charge > 0) {
ctx.fillStyle = '#ef4444'; // Red
ctx.shadowColor = 'rgba(239, 68, 68, 0.7)';
} else {
ctx.fillStyle = '#3b82f6'; // Blue
ctx.shadowColor = 'rgba(59, 130, 246, 0.7)';
}
ctx.shadowBlur = 15;
ctx.fill();
// Draw outline border
ctx.lineWidth = 2;
ctx.strokeStyle = '#ffffff';
ctx.shadowBlur = 0; // turn off shadow for lines
ctx.stroke();
// Draw central sign indicator (+ or -)
ctx.font = 'bold 20px monospace';
ctx.fillStyle = '#ffffff';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(this.charge > 0 ? '+' : '−', this.x, this.y - 1);
ctx.restore();
}
containsPoint(px, py) {
const dist = Math.hypot(this.x - px, this.y - py);
return dist < this.radius + 8;
}
}
// Initialize Simulation Canvas
function init() {
resizeCanvas();
loadPreset('dipole');
setupControls();
// Fade out instructions after 5s
setTimeout(() => {
document.getElementById('instruction-overlay').style.opacity = '0';
}, 6000);
// Start animation loop
requestAnimationFrame(updateLoop);
}
// Setup Controls & UI Observers
function setupControls() {
// Slider linkages
const bindSlider = (id, valId, callback, suffix = '') => {
const slider = document.getElementById(id);
const display = document.getElementById(valId);
slider.addEventListener('input', (e) => {
const val = parseFloat(e.target.value);
display.innerText = val + suffix;
callback(val);
});
};
bindSlider('slide-k', 'val-k', (v) => k_e = v);
bindSlider('slide-damping', 'val-damping', (v) => dampingCoeff = v / 100, '%');
bindSlider('slide-softening', 'val-softening', (v) => softening = v);
bindSlider('slide-density', 'val-density', (v) => linesPerCharge = v);
bindSlider('slide-flow-speed', 'val-flow-speed', (v) => {
flowVelocityMultiplier = v;
const txt = v < 3 ? 'Slow' : (v < 7 ? 'Medium' : 'Fast');
document.getElementById('val-flow-speed').innerText = txt;
});
// Toggles
document.getElementById('toggle-field-lines').addEventListener('change', (e) => {
showFieldLines = e.target.checked;
});
document.getElementById('toggle-vector-flow').addEventListener('change', (e) => {
showVectorFlow = e.target.checked;
});
document.getElementById('toggle-bounds').addEventListener('change', (e) => {
bounceBounds = e.target.checked;
});
// Play/Pause Action
const toggleBtn = document.getElementById('btn-toggle-physics');
const playPauseIcon = document.getElementById('play-pause-icon');
const playPauseText = document.getElementById('play-pause-text');
toggleBtn.addEventListener('click', () => {
physicsEnabled = !physicsEnabled;
if (physicsEnabled) {
toggleBtn.className = "flex-1 py-2.5 rounded-lg font-bold text-sm flex items-center justify-center gap-2 shadow transition duration-200 bg-emerald-600 hover:bg-emerald-500 text-white";
playPauseIcon.innerText = "⏸";
playPauseText.innerText = "Pause Physics";
} else {
toggleBtn.className = "flex-1 py-2.5 rounded-lg font-bold text-sm flex items-center justify-center gap-2 shadow transition duration-200 bg-amber-600 hover:bg-amber-500 text-white";
playPauseIcon.innerText = "▶";
playPauseText.innerText = "Resume Physics";
}
});
// Sandbox clear/add actions
document.getElementById('btn-clear').addEventListener('click', () => {
particles = [];
updateStats();
});
document.getElementById('btn-add-pos').addEventListener('click', () => {
const { w, h } = getCanvasLogicalSize();
const x = w / 2 + (Math.random() - 0.5) * 150;
const y = h / 2 + (Math.random() - 0.5) * 150;
particles.push(new ChargeParticle(x, y, 1.0));
updateStats();
});
document.getElementById('btn-add-neg').addEventListener('click', () => {
const { w, h } = getCanvasLogicalSize();
const x = w / 2 + (Math.random() - 0.5) * 150;
const y = h / 2 + (Math.random() - 0.5) * 150;
particles.push(new ChargeParticle(x, y, -1.0));
updateStats();
});
// Presets
document.querySelectorAll('.preset-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
const preset = e.target.getAttribute('data-preset');
loadPreset(preset);
});
});
// Interaction Drag Logic (Mouse & Touch)
const getCoords = (e) => {
const rect = canvas.getBoundingClientRect();
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
const clientY = e.touches ? e.touches[0].clientY : e.clientY;
// Scale back based on element bounding box vs coordinate spacing
return {
x: (clientX - rect.left),
y: (clientY - rect.top)
};
};
const dragStart = (coords) => {
for (let p of particles) {
if (p.containsPoint(coords.x, coords.y)) {
selectedParticle = p;
isDragging = true;
lastMousePos = coords;
break;
}
}
};
const dragMove = (coords) => {
if (isDragging && selectedParticle) {
selectedParticle.x = coords.x;
selectedParticle.y = coords.y;
// Reset velocities during drag so it doesn't build crazy speed
selectedParticle.vx = 0;
selectedParticle.vy = 0;
}
};
const dragEnd = () => {
isDragging = false;
selectedParticle = null;
};
canvas.addEventListener('mousedown', (e) => dragStart(getCoords(e)));
canvas.addEventListener('mousemove', (e) => dragMove(getCoords(e)));
window.addEventListener('mouseup', dragEnd);
canvas.addEventListener('touchstart', (e) => {
e.preventDefault();
dragStart(getCoords(e));
}, { passive: false });
canvas.addEventListener('touchmove', (e) => {
e.preventDefault();
dragMove(getCoords(e));
}, { passive: false });
window.addEventListener('touchend', dragEnd);
}
// Get Logical size for styling boundaries
function getCanvasLogicalSize() {
const rect = canvas.getBoundingClientRect();
return { w: rect.width, h: rect.height };
}
// Reusable configuration setup
function loadPreset(name) {
const { w, h } = getCanvasLogicalSize();
particles = [];
// Re-center setup dynamically based on size
const cx = w > 0 ? w / 2 : 400;
const cy = h > 0 ? h / 2 : 300;
if (name === 'dipole') {
particles.push(new ChargeParticle(cx - 100, cy, 1.0));
particles.push(new ChargeParticle(cx + 100, cy, -1.0));
} else if (name === 'quadrupole') {
particles.push(new ChargeParticle(cx - 100, cy - 80, 1.0));
particles.push(new ChargeParticle(cx + 100, cy - 80, -1.0));
particles.push(new ChargeParticle(cx - 100, cy + 80, -1.0));
particles.push(new ChargeParticle(cx + 100, cy + 80, 1.0));
} else if (name === 'chaos') {
// Fixed massive heavy negative charge inside, lightweight positive orbits
const heavyCenter = new ChargeParticle(cx, cy, -3.0);
heavyCenter.radius = 26;
particles.push(heavyCenter);
const orbiter1 = new ChargeParticle(cx - 160, cy, 0.8);
orbiter1.vy = 4.5; // horizontal spin-velocity
orbiter1.vx = -1.2;
particles.push(orbiter1);
const orbiter2 = new ChargeParticle(cx + 180, cy + 10, 0.8);
orbiter2.vy = -3.8;
orbiter2.vx = 0.5;
particles.push(orbiter2);
} else if (name === 'grid') {
// Alternating matrix of positive and negatives
const sizeX = 3;
const sizeY = 3;
const spacingX = Math.min(150, w / 4);
const spacingY = Math.min(130, h / 4);
const startX = cx - ((sizeX - 1) * spacingX) / 2;
const startY = cy - ((sizeY - 1) * spacingY) / 2;
for (let i = 0; i < sizeX; i++) {
for (let j = 0; j < sizeY; j++) {
const type = (i + j) % 2 === 0 ? 1.0 : -1.0;
particles.push(new ChargeParticle(startX + i * spacingX, startY + j * spacingY, type));
}
}
}
updateStats();
}
// Live stats rendering
function updateStats() {
document.getElementById('stat-charges').innerText = particles.length;
}
// Calculate Electric Field Vector E(x, y) at any coordinate
function calculateEField(x, y) {
let Ex = 0;
let Ey = 0;
for (let p of particles) {
const dx = x - p.x;
const dy = y - p.y;
const dSq = dx * dx + dy * dy;
// Introduce softening factor to avoid numerical infinity (singularity) near the point charge
const denominator = Math.pow(dSq + softening, 1.5);
// E = k_e * q / r^2
const magnitude = (k_e * p.charge) / denominator;
Ex += magnitude * dx;
Ey += magnitude * dy;
}
return { Ex, Ey };
}
// Vector tracing algorithm for field lines
function traceFieldLine(startX, startY, direction) {
const { w, h } = getCanvasLogicalSize();
const points = [{ x: startX, y: startY }];
let x = startX;
let y = startY;
const maxSteps = 160;
const stepSize = 8; // Step size of the Euler integration path tracer
for (let step = 0; step < maxSteps; step++) {
const { Ex, Ey } = calculateEField(x, y);
const E_mag = Math.hypot(Ex, Ey);
if (E_mag < 0.0001) break; // field is zero
// Direction of integration step
const dx = (Ex / E_mag) * stepSize * direction;
const dy = (Ey / E_mag) * stepSize * direction;
x += dx;
y += dy;
points.push({ x, y });
// Bounds termination check
if (x < -100 || x > w + 100 || y < -100 || y > h + 100) {
break;
}
// Hit detection check with nearby charge
let hitCharge = false;
for (let p of particles) {
const distToP = Math.hypot(x - p.x, y - p.y);
if (distToP < p.radius - 2) {
// Landed on or inside the charge
hitCharge = true;
break;
}
}
if (hitCharge) {
break;
}
}
return points;
}
// Render calculated lines and fluid movement vectors
function drawElectricField() {
if (particles.length === 0) return;
const linesToTrace = [];
// Spawn paths radiating outward from positive charges, and inward to negative ones
particles.forEach(p => {
const N = Math.floor(linesPerCharge * Math.abs(p.charge));
for (let i = 0; i < N; i++) {
const angle = (Math.PI * 2 * i) / N;
// Slightly offset outward from the border of the charge
const startX = p.x + Math.cos(angle) * (p.radius + 1);
const startY = p.y + Math.sin(angle) * (p.radius + 1);
// Integrate forward (+) if charge is positive, backward (-) if negative
const dir = p.charge > 0 ? 1 : -1;
linesToTrace.push(traceFieldLine(startX, startY, dir));
}
});
// Draw Paths on Canvas
linesToTrace.forEach(line => {
if (line.length < 2) return;
if (showFieldLines) {
ctx.beginPath();
ctx.moveTo(line[0].x, line[0].y);
for (let i = 1; i < line.length; i++) {
ctx.lineTo(line[i].x, line[i].y);
}
// Field line styling: Neon semi-transparent teal-white line style
ctx.strokeStyle = 'rgba(243, 244, 246, 0.16)';
ctx.lineWidth = 1.5;
ctx.stroke();
}
// Show Animated Energy Flow
if (showVectorFlow) {
// We can sample points along the calculated line to draw glowing field markers
// The markers move down the path continuously based on global offset
const speed = flowVelocityMultiplier * 0.5;
const dashSpacing = 70; // gap distance between cascading glow points
// Track path-length spacing to place dots perfectly
let lengthAccumulator = 0;
const segments = [];
for (let i = 0; i < line.length - 1; i++) {
const segmentLen = Math.hypot(line[i+1].x - line[i].x, line[i+1].y - line[i].y);
segments.push({
start: line[i],
end: line[i+1],
len: segmentLen,
accum: lengthAccumulator
});
lengthAccumulator += segmentLen;
}
// Draw moving flow indicators
let flowDist = flowAnimOffset % dashSpacing;
while (flowDist < lengthAccumulator) {
// Find the segment representing this distance
const seg = segments.find(s => flowDist >= s.accum && flowDist <= s.accum + s.len);
if (seg) {
const ratio = (flowDist - seg.accum) / seg.len;
const dotX = seg.start.x + (seg.end.x - seg.start.x) * ratio;
const dotY = seg.start.y + (seg.end.y - seg.start.y) * ratio;
// Draw a small bright field vector dot
ctx.beginPath();
ctx.arc(dotX, dotY, 2, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(255, 255, 255, 0.85)';
ctx.shadowColor = '#00f2ff';
ctx.shadowBlur = 4;
ctx.fill();
ctx.shadowBlur = 0; // reset
}
flowDist += dashSpacing;
}
}
});
}
// Apply Coulomb many-body forces
function updatePhysics() {
if (!physicsEnabled) return;
// 1. Calculate and accumulate net force vector
const forces = particles.map(() => ({ fx: 0, fy: 0 }));
for (let i = 0; i < particles.length; i++) {
// Skip calculations for the charge currently held by user
if (particles[i] === selectedParticle) continue;
for (let j = 0; j < particles.length; j++) {
if (i === j) continue;
const p1 = particles[i];
const p2 = particles[j];
const dx = p1.x - p2.x;
const dy = p1.y - p2.y;
const dSq = dx * dx + dy * dy;
// Compute vector distance
const distance = Math.hypot(dx, dy);
if (distance < 0.1) continue;
// Electrostatic Force (Coulomb's Law): F = k_e * q1 * q2 / r^2
// Softening factor avoids divide-by-zero singularity when particles overlaps
const forceMag = (k_e * p1.charge * p2.charge) / (dSq + softening);
// Direction of forces (like charges repel, opposite charges attract)
forces[i].fx += (dx / distance) * forceMag;
forces[i].fy += (dy / distance) * forceMag;
}
}
// 2. Integration and Update of Position and Velocity Vectors
const { w, h } = getCanvasLogicalSize();
const damping = 1 - dampingCoeff;
for (let i = 0; i < particles.length; i++) {
const p = particles[i];
if (p === selectedParticle) continue; // locked under mouse drag
const f = forces[i];
// F = m * a -> a = F / m
const ax = f.fx / p.mass;
const ay = f.fy / p.mass;
p.vx += ax;
p.vy += ay;
// Apply friction / viscous damping
p.vx *= damping;
p.vy *= damping;
p.x += p.vx;
p.y += p.vy;
// 3. Keep within canvas boundaries (Elastic or non-destructive bounces)
if (bounceBounds) {
const padding = p.radius;
if (p.x < padding) {
p.x = padding;
p.vx *= -0.6; // absorb partial kinetic energy on hit
} else if (p.x > w - padding) {
p.x = w - padding;
p.vx *= -0.6;
}
if (p.y < padding) {
p.y = padding;
p.vy *= -0.6;
} else if (p.y > h - padding) {
p.y = h - padding;
p.vy *= -0.6;
}
}
}
}
// Animation Frame loop handler
function updateLoop() {
// Calculate real-time FPS
const now = performance.now();
const delta = now - lastTime;
lastTime = now;
fps = Math.round(1000 / delta);
document.getElementById('stat-fps').innerText = fps;
// Clear Canvas Frame
const { w, h } = getCanvasLogicalSize();
ctx.clearRect(0, 0, w, h);
// Redraw background space grid
drawBackgroundSpaceGrid(w, h);
// Update physical positions & velocities
updatePhysics();
// Render field lines and flow tracers
flowAnimOffset += flowVelocityMultiplier * 0.4;
drawElectricField();
// Render actual charged particles
particles.forEach(p => p.draw());
requestAnimationFrame(updateLoop);
}
// Premium ambient digital grid background
function drawBackgroundSpaceGrid(w, h) {
ctx.strokeStyle = 'rgba(31, 41, 55, 0.4)';
ctx.lineWidth = 1;
const step = 40;
for (let x = 0; x < w; x += step) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, h);
ctx.stroke();
}
for (let y = 0; y < h; y += step) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(w, y);
ctx.stroke();
}
}
// Launch Application sandbox
window.onload = init;
</script>
</body>
</html>