Spaces:
Running
Running
| <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">−</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> | |