Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Physics Collision Lab</title> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> | |
| <style> | |
| canvas { | |
| background-color: #f0f4f8; | |
| border-radius: 0.5rem; | |
| box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); | |
| } | |
| .slider-container { | |
| position: relative; | |
| height: 24px; | |
| } | |
| .slider-track { | |
| position: absolute; | |
| top: 50%; | |
| transform: translateY(-50%); | |
| width: 100%; | |
| height: 4px; | |
| background-color: #e2e8f0; | |
| border-radius: 2px; | |
| } | |
| .slider-thumb { | |
| position: absolute; | |
| top: 50%; | |
| transform: translate(-50%, -50%); | |
| width: 16px; | |
| height: 16px; | |
| background-color: #4f46e5; | |
| border-radius: 50%; | |
| cursor: pointer; | |
| z-index: 2; | |
| } | |
| .slider-fill { | |
| position: absolute; | |
| top: 50%; | |
| transform: translateY(-50%); | |
| height: 4px; | |
| background-color: #4f46e5; | |
| border-radius: 2px; | |
| z-index: 1; | |
| } | |
| .particle { | |
| position: absolute; | |
| border-radius: 50%; | |
| cursor: move; | |
| user-select: none; | |
| } | |
| .energy-meter { | |
| height: 8px; | |
| background: linear-gradient(to right, #ef4444, #f59e0b, #10b981); | |
| border-radius: 4px; | |
| margin-top: 4px; | |
| } | |
| .property-card { | |
| transition: all 0.2s ease; | |
| } | |
| .property-card:hover { | |
| transform: translateY(-2px); | |
| box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); | |
| } | |
| .particle-counter { | |
| position: absolute; | |
| top: 10px; | |
| left: 10px; | |
| background-color: rgba(255, 255, 255, 0.8); | |
| padding: 5px 10px; | |
| border-radius: 20px; | |
| font-size: 14px; | |
| font-weight: bold; | |
| color: #4f46e5; | |
| } | |
| </style> | |
| </head> | |
| <body class="bg-gray-50 min-h-screen"> | |
| <div class="container mx-auto px-4 py-8"> | |
| <header class="mb-8 text-center"> | |
| <h1 class="text-4xl font-bold text-indigo-700 mb-2">Physics Collision Lab</h1> | |
| <p class="text-gray-600 max-w-2xl mx-auto">Interactive simulation of elastic and inelastic collisions with customizable parameters</p> | |
| </header> | |
| <div class="grid grid-cols-1 lg:grid-cols-3 gap-6"> | |
| <!-- Simulation Canvas --> | |
| <div class="lg:col-span-2 bg-white p-4 rounded-xl shadow-md"> | |
| <div class="flex justify-between items-center mb-4"> | |
| <h2 class="text-xl font-semibold text-gray-800">Simulation Area</h2> | |
| <div class="flex space-x-2"> | |
| <button id="playPauseBtn" class="px-3 py-1 bg-indigo-600 text-white rounded-md hover:bg-indigo-700 transition"> | |
| <i class="fas fa-play"></i> Play | |
| </button> | |
| <button id="resetBtn" class="px-3 py-1 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 transition"> | |
| <i class="fas fa-redo"></i> Reset | |
| </button> | |
| </div> | |
| </div> | |
| <div class="relative"> | |
| <canvas id="simulationCanvas" width="800" height="500" class="w-full"></canvas> | |
| <div class="particle-counter" id="particleCounter">0 particles</div> | |
| <div id="particleCreator" class="absolute top-4 right-4 bg-white p-3 rounded-lg shadow-lg hidden"> | |
| <div class="flex items-center mb-2"> | |
| <span class="text-sm font-medium text-gray-700 mr-2">Quantity:</span> | |
| <input type="number" min="1" max="20" value="1" class="w-20 px-2 py-1 border rounded" id="creatorQuantity"> | |
| </div> | |
| <div class="flex items-center mb-2"> | |
| <span class="text-sm font-medium text-gray-700 mr-2">Radius:</span> | |
| <input type="range" min="10" max="50" value="20" class="w-24" id="creatorRadius"> | |
| <span id="creatorRadiusValue" class="ml-2 text-sm w-8 text-center">20</span> | |
| </div> | |
| <div class="flex items-center mb-2"> | |
| <span class="text-sm font-medium text-gray-700 mr-2">Mass:</span> | |
| <input type="range" min="1" max="20" value="5" class="w-24" id="creatorMass"> | |
| <span id="creatorMassValue" class="ml-2 text-sm w-8 text-center">5</span> | |
| </div> | |
| <div class="flex items-center mb-3"> | |
| <span class="text-sm font-medium text-gray-700 mr-2">Color:</span> | |
| <input type="color" value="#4f46e5" id="creatorColor" class="h-6 w-6 cursor-pointer"> | |
| </div> | |
| <div class="flex items-center mb-3"> | |
| <span class="text-sm font-medium text-gray-700 mr-2">Randomize:</span> | |
| <input type="checkbox" id="randomizeProps" class="h-4 w-4" checked> | |
| </div> | |
| <div class="flex justify-between"> | |
| <button id="cancelCreate" class="px-2 py-1 bg-gray-200 text-gray-700 rounded text-sm hover:bg-gray-300 mr-2"> | |
| Cancel | |
| </button> | |
| <button id="confirmCreate" class="px-2 py-1 bg-indigo-600 text-white rounded text-sm hover:bg-indigo-700"> | |
| Create | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="mt-4 grid grid-cols-2 gap-4"> | |
| <div class="bg-indigo-50 p-3 rounded-lg"> | |
| <div class="flex justify-between items-center mb-1"> | |
| <span class="text-sm font-medium text-indigo-700">System Momentum</span> | |
| <span id="momentumValue" class="text-sm font-mono">0.00 kg·m/s</span> | |
| </div> | |
| <div class="energy-meter" id="momentumMeter"></div> | |
| </div> | |
| <div class="bg-green-50 p-3 rounded-lg"> | |
| <div class="flex justify-between items-center mb-1"> | |
| <span class="text-sm font-medium text-green-700">System Kinetic Energy</span> | |
| <span id="energyValue" class="text-sm font-mono">0.00 J</span> | |
| </div> | |
| <div class="energy-meter" id="energyMeter"></div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Control Panel --> | |
| <div class="bg-white p-6 rounded-xl shadow-md"> | |
| <h2 class="text-xl font-semibold text-gray-800 mb-4">Control Panel</h2> | |
| <div class="mb-6"> | |
| <h3 class="text-lg font-medium text-gray-700 mb-3">Environment Settings</h3> | |
| <div class="space-y-4"> | |
| <div> | |
| <label class="block text-sm font-medium text-gray-700 mb-1">Gravity</label> | |
| <div class="slider-container"> | |
| <div class="slider-track"></div> | |
| <div class="slider-fill" id="gravityFill"></div> | |
| <div class="slider-thumb" id="gravityThumb"></div> | |
| </div> | |
| <div class="flex justify-between mt-1"> | |
| <span class="text-xs text-gray-500">0</span> | |
| <span id="gravityValue" class="text-xs font-mono">5.0 m/s²</span> | |
| <span class="text-xs text-gray-500">20</span> | |
| </div> | |
| </div> | |
| <div> | |
| <label class="block text-sm font-medium text-gray-700 mb-1">Restitution (Bounciness)</label> | |
| <div class="slider-container"> | |
| <div class="slider-track"></div> | |
| <div class="slider-fill" id="restitutionFill"></div> | |
| <div class="slider-thumb" id="restitutionThumb"></div> | |
| </div> | |
| <div class="flex justify-between mt-1"> | |
| <span class="text-xs text-gray-500">0%</span> | |
| <span id="restitutionValue" class="text-xs font-mono">80%</span> | |
| <span class="text-xs text-gray-500">100%</span> | |
| </div> | |
| </div> | |
| <div> | |
| <label class="block text-sm font-medium text-gray-700 mb-1">Friction</label> | |
| <div class="slider-container"> | |
| <div class="slider-track"></div> | |
| <div class="slider-fill" id="frictionFill"></div> | |
| <div class="slider-thumb" id="frictionThumb"></div> | |
| </div> | |
| <div class="flex justify-between mt-1"> | |
| <span class="text-xs text-gray-500">0%</span> | |
| <span id="frictionValue" class="text-xs font-mono">10%</span> | |
| <span class="text-xs text-gray-500">100%</span> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="mb-6"> | |
| <h3 class="text-lg font-medium text-gray-700 mb-3">Particle Tools</h3> | |
| <div class="grid grid-cols-2 gap-3"> | |
| <button id="addParticleBtn" class="px-3 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700 transition flex items-center justify-center"> | |
| <i class="fas fa-plus mr-2"></i> Add Particles | |
| </button> | |
| <button id="removeParticleBtn" class="px-3 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 transition flex items-center justify-center"> | |
| <i class="fas fa-trash mr-2"></i> Remove All | |
| </button> | |
| </div> | |
| <div class="mt-3 grid grid-cols-2 gap-3"> | |
| <button id="randomizeBtn" class="px-3 py-2 bg-blue-100 text-blue-700 rounded-md hover:bg-blue-200 transition"> | |
| <i class="fas fa-random mr-1"></i> Randomize | |
| </button> | |
| <button id="freezeAllBtn" class="px-3 py-2 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200 transition"> | |
| <i class="fas fa-snowflake mr-1"></i> Freeze All | |
| </button> | |
| </div> | |
| </div> | |
| <div> | |
| <h3 class="text-lg font-medium text-gray-700 mb-3">Preset Scenarios</h3> | |
| <div class="grid grid-cols-2 gap-3 mb-3"> | |
| <button id="elasticCollisionBtn" class="px-3 py-2 bg-blue-100 text-blue-700 rounded-md hover:bg-blue-200 transition"> | |
| Elastic (2) | |
| </button> | |
| <button id="inelasticCollisionBtn" class="px-3 py-2 bg-purple-100 text-purple-700 rounded-md hover:bg-purple-200 transition"> | |
| Inelastic (2) | |
| </button> | |
| </div> | |
| <div class="grid grid-cols-2 gap-3"> | |
| <button id="newtonsCradleBtn" class="px-3 py-2 bg-green-100 text-green-700 rounded-md hover:bg-green-200 transition"> | |
| Newton's Cradle (5) | |
| </button> | |
| <button id="particleExplosionBtn" class="px-3 py-2 bg-yellow-100 text-yellow-700 rounded-md hover:bg-yellow-200 transition"> | |
| Explosion (10) | |
| </button> | |
| </div> | |
| </div> | |
| <div class="mt-6"> | |
| <h3 class="text-lg font-medium text-gray-700 mb-3">Particles Manager</h3> | |
| <div id="particleProperties" class="space-y-3 max-h-64 overflow-y-auto pr-2"> | |
| <div class="text-center text-gray-500 py-4"> | |
| Select particles to manage them | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="mt-8 bg-white p-6 rounded-xl shadow-md"> | |
| <h2 class="text-xl font-semibold text-gray-800 mb-4">Collision Data</h2> | |
| <div class="overflow-x-auto"> | |
| <table class="min-w-full divide-y divide-gray-200"> | |
| <thead class="bg-gray-50"> | |
| <tr> | |
| <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Time</th> | |
| <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Particles</th> | |
| <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Velocity Before</th> | |
| <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Velocity After</th> | |
| <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Momentum Change</th> | |
| <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Energy Change</th> | |
| </tr> | |
| </thead> | |
| <tbody id="collisionData" class="bg-white divide-y divide-gray-200"> | |
| <tr> | |
| <td colspan="6" class="px-6 py-4 text-center text-sm text-gray-500">No collision data yet</td> | |
| </tr> | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| // Physics Constants | |
| const PHYSICS = { | |
| GRAVITY: 9.8, | |
| PIXELS_PER_METER: 50, | |
| MAX_VELOCITY: 20 | |
| }; | |
| // Simulation State | |
| let state = { | |
| running: false, | |
| particles: [], | |
| selectedParticles: [], | |
| creatingParticles: false, | |
| gravity: 5, | |
| restitution: 0.8, | |
| friction: 0.1, | |
| collisionHistory: [], | |
| lastTime: 0, | |
| systemMomentum: 0, | |
| systemEnergy: 0, | |
| maxMomentum: 1, | |
| maxEnergy: 1 | |
| }; | |
| // DOM Elements | |
| const canvas = document.getElementById('simulationCanvas'); | |
| const ctx = canvas.getContext('2d'); | |
| const playPauseBtn = document.getElementById('playPauseBtn'); | |
| const resetBtn = document.getElementById('resetBtn'); | |
| const addParticleBtn = document.getElementById('addParticleBtn'); | |
| const removeParticleBtn = document.getElementById('removeParticleBtn'); | |
| const randomizeBtn = document.getElementById('randomizeBtn'); | |
| const freezeAllBtn = document.getElementById('freezeAllBtn'); | |
| const particleCreator = document.getElementById('particleCreator'); | |
| const cancelCreate = document.getElementById('cancelCreate'); | |
| const confirmCreate = document.getElementById('confirmCreate'); | |
| const creatorQuantity = document.getElementById('creatorQuantity'); | |
| const creatorRadius = document.getElementById('creatorRadius'); | |
| const creatorRadiusValue = document.getElementById('creatorRadiusValue'); | |
| const creatorMass = document.getElementById('creatorMass'); | |
| const creatorMassValue = document.getElementById('creatorMassValue'); | |
| const creatorColor = document.getElementById('creatorColor'); | |
| const randomizeProps = document.getElementById('randomizeProps'); | |
| const particleProperties = document.getElementById('particleProperties'); | |
| const collisionData = document.getElementById('collisionData'); | |
| const momentumValue = document.getElementById('momentumValue'); | |
| const energyValue = document.getElementById('energyValue'); | |
| const momentumMeter = document.getElementById('momentumMeter'); | |
| const energyMeter = document.getElementById('energyMeter'); | |
| const particleCounter = document.getElementById('particleCounter'); | |
| // Preset buttons | |
| const elasticCollisionBtn = document.getElementById('elasticCollisionBtn'); | |
| const inelasticCollisionBtn = document.getElementById('inelasticCollisionBtn'); | |
| const newtonsCradleBtn = document.getElementById('newtonsCradleBtn'); | |
| const particleExplosionBtn = document.getElementById('particleExplosionBtn'); | |
| // Slider elements | |
| const gravityThumb = document.getElementById('gravityThumb'); | |
| const gravityFill = document.getElementById('gravityFill'); | |
| const gravityValue = document.getElementById('gravityValue'); | |
| const restitutionThumb = document.getElementById('restitutionThumb'); | |
| const restitutionFill = document.getElementById('restitutionFill'); | |
| const restitutionValue = document.getElementById('restitutionValue'); | |
| const frictionThumb = document.getElementById('frictionThumb'); | |
| const frictionFill = document.getElementById('frictionFill'); | |
| const frictionValue = document.getElementById('frictionValue'); | |
| // Initialize sliders | |
| initSlider(gravityThumb, gravityFill, 0, 20, state.gravity, value => { | |
| state.gravity = value; | |
| gravityValue.textContent = `${value.toFixed(1)} m/s²`; | |
| // Update gravity for all particles | |
| state.particles.forEach(p => p.ay = state.gravity); | |
| }); | |
| initSlider(restitutionThumb, restitutionFill, 0, 1, state.restitution, value => { | |
| state.restitution = value; | |
| restitutionValue.textContent = `${Math.round(value * 100)}%`; | |
| }); | |
| initSlider(frictionThumb, frictionFill, 0, 1, state.friction, value => { | |
| state.friction = value; | |
| frictionValue.textContent = `${Math.round(value * 100)}%`; | |
| }); | |
| // Particle class | |
| class Particle { | |
| constructor(x, y, radius, mass, color, vx = 0, vy = 0) { | |
| this.x = x; | |
| this.y = y; | |
| this.radius = radius; | |
| this.mass = mass; | |
| this.color = color; | |
| this.vx = vx; | |
| this.vy = vy; | |
| this.ax = 0; | |
| this.ay = state.gravity; | |
| this.selected = false; | |
| this.dragging = false; | |
| this.dragOffsetX = 0; | |
| this.dragOffsetY = 0; | |
| this.id = Math.random().toString(36).substr(2, 9); | |
| this.collisions = 0; | |
| this.frozen = false; | |
| } | |
| update(dt) { | |
| if (this.frozen) return; | |
| // Apply friction | |
| this.vx *= (1 - state.friction * 0.1); | |
| this.vy *= (1 - state.friction * 0.1); | |
| // Update velocity | |
| this.vx += this.ax * dt; | |
| this.vy += this.ay * dt; | |
| // Limit velocity | |
| const speed = Math.sqrt(this.vx * this.vx + this.vy * this.vy); | |
| if (speed > PHYSICS.MAX_VELOCITY) { | |
| const ratio = PHYSICS.MAX_VELOCITY / speed; | |
| this.vx *= ratio; | |
| this.vy *= ratio; | |
| } | |
| // Update position | |
| this.x += this.vx * dt * PHYSICS.PIXELS_PER_METER; | |
| this.y += this.vy * dt * PHYSICS.PIXELS_PER_METER; | |
| // Boundary collision | |
| this.handleBoundaryCollision(); | |
| } | |
| handleBoundaryCollision() { | |
| // Left wall | |
| if (this.x - this.radius < 0) { | |
| this.x = this.radius; | |
| this.vx = -this.vx * state.restitution; | |
| } | |
| // Right wall | |
| if (this.x + this.radius > canvas.width) { | |
| this.x = canvas.width - this.radius; | |
| this.vx = -this.vx * state.restitution; | |
| } | |
| // Top wall | |
| if (this.y - this.radius < 0) { | |
| this.y = this.radius; | |
| this.vy = -this.vy * state.restitution; | |
| } | |
| // Bottom wall | |
| if (this.y + this.radius > canvas.height) { | |
| this.y = canvas.height - this.radius; | |
| this.vy = -this.vy * state.restitution; | |
| } | |
| } | |
| draw() { | |
| ctx.beginPath(); | |
| ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2); | |
| ctx.fillStyle = this.selected ? this.lightenColor(this.color, 20) : this.color; | |
| ctx.fill(); | |
| if (this.selected) { | |
| ctx.strokeStyle = '#000'; | |
| ctx.lineWidth = 2; | |
| ctx.stroke(); | |
| // Draw velocity vector | |
| if (this.vx !== 0 || this.vy !== 0) { | |
| ctx.beginPath(); | |
| ctx.moveTo(this.x, this.y); | |
| ctx.lineTo( | |
| this.x + this.vx * 10, | |
| this.y + this.vy * 10 | |
| ); | |
| ctx.strokeStyle = '#f00'; | |
| ctx.lineWidth = 2; | |
| ctx.stroke(); | |
| // Arrow head | |
| const angle = Math.atan2(this.vy, this.vx); | |
| ctx.beginPath(); | |
| ctx.moveTo( | |
| this.x + this.vx * 10, | |
| this.y + this.vy * 10 | |
| ); | |
| ctx.lineTo( | |
| this.x + this.vx * 10 - 8 * Math.cos(angle - Math.PI/6), | |
| this.y + this.vy * 10 - 8 * Math.sin(angle - Math.PI/6) | |
| ); | |
| ctx.lineTo( | |
| this.x + this.vx * 10 - 8 * Math.cos(angle + Math.PI/6), | |
| this.y + this.vy * 10 - 8 * Math.sin(angle + Math.PI/6) | |
| ); | |
| ctx.closePath(); | |
| ctx.fillStyle = '#f00'; | |
| ctx.fill(); | |
| } | |
| } | |
| // Draw frozen indicator | |
| if (this.frozen) { | |
| ctx.beginPath(); | |
| ctx.arc(this.x, this.y, this.radius * 0.7, 0, Math.PI * 2); | |
| ctx.strokeStyle = 'rgba(0, 0, 0, 0.3)'; | |
| ctx.lineWidth = 2; | |
| ctx.stroke(); | |
| } | |
| } | |
| lightenColor(color, percent) { | |
| const num = parseInt(color.replace("#", ""), 16); | |
| const amt = Math.round(2.55 * percent); | |
| const R = (num >> 16) + amt; | |
| const B = (num >> 8 & 0x00FF) + amt; | |
| const G = (num & 0x0000FF) + amt; | |
| return `#${(0x1000000 + (R < 255 ? R < 1 ? 0 : R : 255) * 0x10000 + | |
| (B < 255 ? B < 1 ? 0 : B : 255) * 0x100 + | |
| (G < 255 ? G < 1 ? 0 : G : 255)).toString(16).slice(1)}`; | |
| } | |
| isPointInside(px, py) { | |
| const dx = px - this.x; | |
| const dy = py - this.y; | |
| return dx * dx + dy * dy <= this.radius * this.radius; | |
| } | |
| get momentum() { | |
| const speed = Math.sqrt(this.vx * this.vx + this.vy * this.vy); | |
| return this.mass * speed; | |
| } | |
| get kineticEnergy() { | |
| const speed = Math.sqrt(this.vx * this.vx + this.vy * this.vy); | |
| return 0.5 * this.mass * speed * speed; | |
| } | |
| } | |
| // Initialize slider | |
| function initSlider(thumb, fill, min, max, initialValue, callback) { | |
| const container = thumb.parentElement; | |
| const track = container.querySelector('.slider-track'); | |
| const range = max - min; | |
| let isDragging = false; | |
| // Set initial position | |
| const initialPos = ((initialValue - min) / range) * track.offsetWidth; | |
| thumb.style.left = `${initialPos}px`; | |
| fill.style.width = `${initialPos}px`; | |
| // Mouse down event | |
| thumb.addEventListener('mousedown', (e) => { | |
| isDragging = true; | |
| document.addEventListener('mousemove', handleMove); | |
| document.addEventListener('mouseup', () => { | |
| isDragging = false; | |
| document.removeEventListener('mousemove', handleMove); | |
| }); | |
| e.preventDefault(); | |
| }); | |
| // Track click event | |
| track.addEventListener('click', (e) => { | |
| const rect = track.getBoundingClientRect(); | |
| const pos = e.clientX - rect.left; | |
| updateSlider(pos); | |
| }); | |
| function handleMove(e) { | |
| const rect = track.getBoundingClientRect(); | |
| let pos = e.clientX - rect.left; | |
| pos = Math.max(0, Math.min(pos, track.offsetWidth)); | |
| updateSlider(pos); | |
| } | |
| function updateSlider(pos) { | |
| const value = min + (pos / track.offsetWidth) * range; | |
| thumb.style.left = `${pos}px`; | |
| fill.style.width = `${pos}px`; | |
| callback(value); | |
| } | |
| } | |
| // Check collision between two particles | |
| function checkCollision(p1, p2) { | |
| const dx = p2.x - p1.x; | |
| const dy = p2.y - p1.y; | |
| const distance = Math.sqrt(dx * dx + dy * dy); | |
| return distance < p1.radius + p2.radius; | |
| } | |
| // Resolve collision between two particles | |
| function resolveCollision(p1, p2) { | |
| // Store pre-collision velocities for history | |
| const p1vBefore = { x: p1.vx, y: p1.vy }; | |
| const p2vBefore = { x: p2.vx, y: p2.vy }; | |
| // Calculate collision normal | |
| const dx = p2.x - p1.x; | |
| const dy = p2.y - p1.y; | |
| const distance = Math.sqrt(dx * dx + dy * dy); | |
| const nx = dx / distance; | |
| const ny = dy / distance; | |
| // Calculate relative velocity | |
| const vx = p2.vx - p1.vx; | |
| const vy = p2.vy - p1.vy; | |
| const relativeVelocity = vx * nx + vy * ny; | |
| // Do not resolve if particles are moving away from each other | |
| if (relativeVelocity > 0) return; | |
| // Calculate impulse scalar | |
| const restitution = state.restitution; | |
| const impulse = -(1 + restitution) * relativeVelocity / | |
| (1/p1.mass + 1/p2.mass); | |
| // Apply impulse | |
| p1.vx -= (impulse * nx) / p1.mass; | |
| p1.vy -= (impulse * ny) / p1.mass; | |
| p2.vx += (impulse * nx) / p2.mass; | |
| p2.vy += (impulse * ny) / p2.mass; | |
| // Separate particles to prevent sticking | |
| const overlap = (p1.radius + p2.radius - distance) / 2; | |
| p1.x -= overlap * nx; | |
| p1.y -= overlap * ny; | |
| p2.x += overlap * nx; | |
| p2.y += overlap * ny; | |
| // Increment collision counters | |
| p1.collisions++; | |
| p2.collisions++; | |
| // Record collision | |
| recordCollision(p1, p2, p1vBefore, p2vBefore); | |
| } | |
| // Record collision data | |
| function recordCollision(p1, p2, p1vBefore, p2vBefore) { | |
| const now = new Date(); | |
| const timeStr = now.toLocaleTimeString(); | |
| // Calculate momentum and energy changes | |
| const p1MomentumBefore = Math.sqrt(p1vBefore.x*p1vBefore.x + p1vBefore.y*p1vBefore.y) * p1.mass; | |
| const p2MomentumBefore = Math.sqrt(p2vBefore.x*p2vBefore.x + p2vBefore.y*p2vBefore.y) * p2.mass; | |
| const p1MomentumAfter = Math.sqrt(p1.vx*p1.vx + p1.vy*p1.vy) * p1.mass; | |
| const p2MomentumAfter = Math.sqrt(p2.vx*p2.vx + p2.vy*p2.vy) * p2.mass; | |
| const p1EnergyBefore = 0.5 * p1.mass * (p1vBefore.x*p1vBefore.x + p1vBefore.y*p1vBefore.y); | |
| const p2EnergyBefore = 0.5 * p2.mass * (p2vBefore.x*p2vBefore.x + p2vBefore.y*p2vBefore.y); | |
| const p1EnergyAfter = 0.5 * p1.mass * (p1.vx*p1.vx + p1.vy*p1.vy); | |
| const p2EnergyAfter = 0.5 * p2.mass * (p2.vx*p2.vx + p2.vy*p2.vy); | |
| const totalMomentumBefore = p1MomentumBefore + p2MomentumBefore; | |
| const totalMomentumAfter = p1MomentumAfter + p2MomentumAfter; | |
| const momentumChange = totalMomentumAfter - totalMomentumBefore; | |
| const totalEnergyBefore = p1EnergyBefore + p2EnergyBefore; | |
| const totalEnergyAfter = p1EnergyAfter + p2EnergyAfter; | |
| const energyChange = totalEnergyAfter - totalEnergyBefore; | |
| // Add to history | |
| state.collisionHistory.unshift({ | |
| time: timeStr, | |
| particles: [p1.id, p2.id], | |
| velocityBefore: [ | |
| { x: p1vBefore.x.toFixed(2), y: p1vBefore.y.toFixed(2) }, | |
| { x: p2vBefore.x.toFixed(2), y: p2vBefore.y.toFixed(2) } | |
| ], | |
| velocityAfter: [ | |
| { x: p1.vx.toFixed(2), y: p1.vy.toFixed(2) }, | |
| { x: p2.vx.toFixed(2), y: p2.vy.toFixed(2) } | |
| ], | |
| momentumChange: momentumChange.toFixed(2), | |
| energyChange: energyChange.toFixed(2) | |
| }); | |
| // Keep only last 10 collisions | |
| if (state.collisionHistory.length > 10) { | |
| state.collisionHistory.pop(); | |
| } | |
| // Update collision table | |
| updateCollisionTable(); | |
| } | |
| // Update collision table | |
| function updateCollisionTable() { | |
| collisionData.innerHTML = ''; | |
| if (state.collisionHistory.length === 0) { | |
| collisionData.innerHTML = ` | |
| <tr> | |
| <td colspan="6" class="px-6 py-4 text-center text-sm text-gray-500">No collision data yet</td> | |
| </tr> | |
| `; | |
| return; | |
| } | |
| state.collisionHistory.forEach(collision => { | |
| const row = document.createElement('tr'); | |
| row.className = 'hover:bg-gray-50'; | |
| row.innerHTML = ` | |
| <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${collision.time}</td> | |
| <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${collision.particles[0]} & ${collision.particles[1]}</td> | |
| <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500"> | |
| (${collision.velocityBefore[0].x}, ${collision.velocityBefore[0].y})<br> | |
| (${collision.velocityBefore[1].x}, ${collision.velocityBefore[1].y}) | |
| </td> | |
| <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500"> | |
| (${collision.velocityAfter[0].x}, ${collision.velocityAfter[0].y})<br> | |
| (${collision.velocityAfter[1].x}, ${collision.velocityAfter[1].y}) | |
| </td> | |
| <td class="px-6 py-4 whitespace-nowrap text-sm ${collision.momentumChange == 0 ? 'text-green-600' : 'text-red-600'}"> | |
| ${collision.momentumChange} | |
| </td> | |
| <td class="px-6 py-4 whitespace-nowrap text-sm ${collision.energyChange <= 0 ? 'text-green-600' : 'text-red-600'}"> | |
| ${collision.energyChange} | |
| </td> | |
| `; | |
| collisionData.appendChild(row); | |
| }); | |
| } | |
| // Update particle counter | |
| function updateParticleCounter() { | |
| particleCounter.textContent = `${state.particles.length} particle${state.particles.length !== 1 ? 's' : ''}`; | |
| } | |
| // Update particle properties panel | |
| function updateParticleProperties() { | |
| particleProperties.innerHTML = ''; | |
| if (state.selectedParticles.length === 0) { | |
| particleProperties.innerHTML = ` | |
| <div class="text-center text-gray-500 py-4"> | |
| Select particles to manage them | |
| </div> | |
| `; | |
| return; | |
| } | |
| if (state.selectedParticles.length > 1) { | |
| const card = document.createElement('div'); | |
| card.className = 'property-card bg-gray-50 p-4 rounded-lg border border-gray-200'; | |
| card.innerHTML = ` | |
| <div class="flex justify-between items-center mb-3"> | |
| <h4 class="font-medium text-gray-700">${state.selectedParticles.length} Particles Selected</h4> | |
| <div class="flex space-x-1"> | |
| <button class="px-2 py-1 bg-blue-100 text-blue-700 rounded text-xs" id="freezeSelectedBtn"> | |
| <i class="fas fa-snowflake mr-1"></i> Freeze | |
| </button> | |
| <button class="px-2 py-1 bg-red-100 text-red-700 rounded text-xs" id="removeSelectedBtn"> | |
| <i class="fas fa-trash mr-1"></i> Remove | |
| </button> | |
| </div> | |
| </div> | |
| <button class="w-full px-2 py-1 bg-indigo-100 text-indigo-700 rounded text-sm mt-2" id="randomizeVelocitiesBtn"> | |
| <i class="fas fa-random mr-1"></i> Randomize Velocities | |
| </button> | |
| <button class="w-full px-2 py-1 bg-gray-100 text-gray-700 rounded text-sm mt-1" id="zeroVelocitiesBtn"> | |
| <i class="fas fa-ban mr-1"></i> Zero Velocities | |
| </button> | |
| `; | |
| // Add event listeners | |
| card.querySelector('#freezeSelectedBtn').addEventListener('click', () => { | |
| state.selectedParticles.forEach(p => p.frozen = !p.frozen); | |
| updateParticleProperties(); | |
| }); | |
| card.querySelector('#removeSelectedBtn').addEventListener('click', () => { | |
| state.particles = state.particles.filter(p => !state.selectedParticles.includes(p)); | |
| state.selectedParticles = []; | |
| updateParticleProperties(); | |
| updateParticleCounter(); | |
| }); | |
| card.querySelector('#randomizeVelocitiesBtn').addEventListener('click', () => { | |
| state.selectedParticles.forEach(p => { | |
| p.vx = (Math.random() - 0.5) * 10; | |
| p.vy = (Math.random() - 0.5) * 10; | |
| }); | |
| }); | |
| card.querySelector('#zeroVelocitiesBtn').addEventListener('click', () => { | |
| state.selectedParticles.forEach(p => { | |
| p.vx = 0; | |
| p.vy = 0; | |
| }); | |
| }); | |
| particleProperties.appendChild(card); | |
| return; | |
| } | |
| const p = state.selectedParticles[0]; | |
| const card = document.createElement('div'); | |
| card.className = 'property-card bg-gray-50 p-4 rounded-lg border border-gray-200'; | |
| card.innerHTML = ` | |
| <div class="flex justify-between items-center mb-3"> | |
| <h4 class="font-medium text-gray-700">Particle ${p.id.substr(0, 6)}</h4> | |
| <div class="flex items-center"> | |
| <span class="inline-block w-4 h-4 rounded-full mr-2" style="background-color: ${p.color};"></span> | |
| <button class="px-2 py-1 bg-blue-100 text-blue-700 rounded text-xs" id="freezeBtn"> | |
| <i class="fas ${p.frozen ? 'fa-fire' : 'fa-snowflake'} mr-1"></i> ${p.frozen ? 'Unfreeze' : 'Freeze'} | |
| </button> | |
| </div> | |
| </div> | |
| <div class="grid grid-cols-2 gap-3 mb-3"> | |
| <div> | |
| <label class="block text-xs text-gray-500 mb-1">Position X</label> | |
| <input type="number" value="${p.x.toFixed(1)}" class="w-full px-2 py-1 text-sm border rounded" data-property="x"> | |
| </div> | |
| <div> | |
| <label class="block text-xs text-gray-500 mb-1">Position Y</label> | |
| <input type="number" value="${p.y.toFixed(1)}" class="w-full px-2 py-1 text-sm border rounded" data-property="y"> | |
| </div> | |
| </div> | |
| <div class="grid grid-cols-2 gap-3 mb-3"> | |
| <div> | |
| <label class="block text-xs text-gray-500 mb-1">Velocity X</label> | |
| <input type="number" value="${p.vx.toFixed(1)}" class="w-full px-2 py-1 text-sm border rounded" data-property="vx"> | |
| </div> | |
| <div> | |
| <label class="block text-xs text-gray-500 mb-1">Velocity Y</label> | |
| <input type="number" value="${p.vy.toFixed(1)}" class="w-full px-2 py-1 text-sm border rounded" data-property="vy"> | |
| </div> | |
| </div> | |
| <div class="grid grid-cols-2 gap-3 mb-3"> | |
| <div> | |
| <label class="block text-xs text-gray-500 mb-1">Radius</label> | |
| <input type="number" value="${p.radius}" class="w-full px-2 py-1 text-sm border rounded" data-property="radius"> | |
| </div> | |
| <div> | |
| <label class="block text-xs text-gray-500 mb-1">Mass</label> | |
| <input type="number" value="${p.mass}" class="w-full px-2 py-1 text-sm border rounded" data-property="mass"> | |
| </div> | |
| </div> | |
| <div class="mb-3"> | |
| <label class="block text-xs text-gray-500 mb-1">Color</label> | |
| <input type="color" value="${p.color}" class="w-full h-8" data-property="color"> | |
| </div> | |
| <div class="text-xs text-gray-500"> | |
| <div class="flex justify-between mb-1"> | |
| <span>Momentum:</span> | |
| <span>${p.momentum.toFixed(2)} kg·m/s</span> | |
| </div> | |
| <div class="flex justify-between"> | |
| <span>Kinetic Energy:</span> | |
| <span>${p.kineticEnergy.toFixed(2)} J</span> | |
| </div> | |
| <div class="flex justify-between mt-2"> | |
| <span>Collisions:</span> | |
| <span>${p.collisions}</span> | |
| </div> | |
| </div> | |
| `; | |
| // Add freeze button event | |
| card.querySelector('#freezeBtn').addEventListener('click', () => { | |
| p.frozen = !p.frozen; | |
| updateParticleProperties(); | |
| }); | |
| // Add event listeners to inputs | |
| const inputs = card.querySelectorAll('input'); | |
| inputs.forEach(input => { | |
| input.addEventListener('change', (e) => { | |
| const property = e.target.dataset.property; | |
| let value = e.target.value; | |
| if (property === 'color') { | |
| p[property] = value; | |
| } else { | |
| value = parseFloat(value); | |
| if (!isNaN(value)) { | |
| // Special handling for radius to prevent negative values | |
| if (property === 'radius') { | |
| p[property] = Math.max(5, value); | |
| } else { | |
| p[property] = value; | |
| } | |
| } | |
| } | |
| // If position changed, make sure particle stays within bounds | |
| if (property === 'x' || property === 'y' || property === 'radius') { | |
| p.x = Math.max(p.radius, Math.min(p.x, canvas.width - p.radius)); | |
| p.y = Math.max(p.radius, Math.min(p.y, canvas.height - p.radius)); | |
| } | |
| }); | |
| }); | |
| particleProperties.appendChild(card); | |
| } | |
| // Calculate system momentum and energy | |
| function calculateSystemMetrics() { | |
| let totalMomentum = 0; | |
| let totalEnergy = 0; | |
| state.particles.forEach(p => { | |
| totalMomentum += p.momentum; | |
| totalEnergy += p.kineticEnergy; | |
| }); | |
| state.systemMomentum = totalMomentum; | |
| state.systemEnergy = totalEnergy; | |
| // Update max values for meter scaling | |
| state.maxMomentum = Math.max(state.maxMomentum, totalMomentum); | |
| state.maxEnergy = Math.max(state.maxEnergy, totalEnergy); | |
| // Update UI | |
| momentumValue.textContent = `${totalMomentum.toFixed(2)} kg·m/s`; | |
| energyValue.textContent = `${totalEnergy.toFixed(2)} J`; | |
| // Update meters | |
| const momentumPercent = Math.min(100, (totalMomentum / (state.maxMomentum || 1)) * 100); | |
| const energyPercent = Math.min(100, (totalEnergy / (state.maxEnergy || 1)) * 100); | |
| momentumMeter.style.width = `${momentumPercent}%`; | |
| energyMeter.style.width = `${energyPercent}%`; | |
| } | |
| // Main animation loop | |
| function animate(timestamp) { | |
| if (!state.lastTime) state.lastTime = timestamp; | |
| const dt = (timestamp - state.lastTime) / 1000; // delta time in seconds | |
| state.lastTime = timestamp; | |
| // Clear canvas | |
| ctx.clearRect(0, 0, canvas.width, canvas.height); | |
| // Update and draw particles | |
| if (state.running) { | |
| // Update particles | |
| state.particles.forEach(p => p.update(dt)); | |
| // Check collisions | |
| for (let i = 0; i < state.particles.length; i++) { | |
| for (let j = i + 1; j < state.particles.length; j++) { | |
| if (checkCollision(state.particles[i], state.particles[j])) { | |
| resolveCollision(state.particles[i], state.particles[j]); | |
| } | |
| } | |
| } | |
| // Calculate system metrics | |
| calculateSystemMetrics(); | |
| } | |
| // Draw particles | |
| state.particles.forEach(p => p.draw()); | |
| // Continue animation loop | |
| if (state.running || state.selectedParticles.length > 0) { | |
| requestAnimationFrame(animate); | |
| } | |
| } | |
| // Generate random color | |
| function getRandomColor() { | |
| const letters = '0123456789ABCDEF'; | |
| let color = '#'; | |
| for (let i = 0; i < 6; i++) { | |
| color += letters[Math.floor(Math.random() * 16)]; | |
| } | |
| return color; | |
| } | |
| // Create multiple particles | |
| function createParticles(count, radius, mass, color, randomize = true) { | |
| const newParticles = []; | |
| for (let i = 0; i < count; i++) { | |
| // Randomize properties if enabled | |
| const partRadius = randomize ? radius * (0.8 + Math.random() * 0.4) : radius; | |
| const partMass = randomize ? mass * (0.8 + Math.random() * 0.4) : mass; | |
| const partColor = randomize ? getRandomColor() : color; | |
| // Random position that doesn't collide with existing particles | |
| let x, y, attempts = 0; | |
| do { | |
| x = partRadius + Math.random() * (canvas.width - 2 * partRadius); | |
| y = partRadius + Math.random() * (canvas.height - 2 * partRadius); | |
| attempts++; | |
| // Give up after 100 attempts (probably not enough space) | |
| if (attempts > 100) break; | |
| } while (state.particles.some(p => { | |
| const dx = p.x - x; | |
| const dy = p.y - y; | |
| const distance = Math.sqrt(dx * dx + dy * dy); | |
| return distance < p.radius + partRadius; | |
| })); | |
| // Random velocity if randomize is enabled | |
| const vx = randomize ? (Math.random() - 0.5) * 5 : 0; | |
| const vy = randomize ? (Math.random() - 0.5) * 5 : 0; | |
| const particle = new Particle( | |
| x, y, | |
| partRadius, | |
| partMass, | |
| partColor, | |
| vx, vy | |
| ); | |
| newParticles.push(particle); | |
| } | |
| return newParticles; | |
| } | |
| // Event listeners | |
| playPauseBtn.addEventListener('click', () => { | |
| state.running = !state.running; | |
| playPauseBtn.innerHTML = state.running ? | |
| '<i class="fas fa-pause"></i> Pause' : | |
| '<i class="fas fa-play"></i> Play'; | |
| if (state.running) { | |
| state.lastTime = 0; | |
| requestAnimationFrame(animate); | |
| } | |
| }); | |
| resetBtn.addEventListener('click', () => { | |
| state.particles = []; | |
| state.selectedParticles = []; | |
| state.running = false; | |
| state.collisionHistory = []; | |
| state.systemMomentum = 0; | |
| state.systemEnergy = 0; | |
| state.maxMomentum = 1; | |
| state.maxEnergy = 1; | |
| playPauseBtn.innerHTML = '<i class="fas fa-play"></i> Play'; | |
| momentumValue.textContent = '0.00 kg·m/s'; | |
| energyValue.textContent = '0.00 J'; | |
| momentumMeter.style.width = '0%'; | |
| energyMeter.style.width = '0%'; | |
| updateParticleProperties(); | |
| updateParticleCounter(); | |
| updateCollisionTable(); | |
| ctx.clearRect(0, 0, canvas.width, canvas.height); | |
| }); | |
| addParticleBtn.addEventListener('click', () => { | |
| state.creatingParticles = true; | |
| particleCreator.classList.remove('hidden'); | |
| }); | |
| cancelCreate.addEventListener('click', () => { | |
| state.creatingParticles = false; | |
| particleCreator.classList.add('hidden'); | |
| }); | |
| confirmCreate.addEventListener('click', () => { | |
| const count = parseInt(creatorQuantity.value) || 1; | |
| const radius = parseInt(creatorRadius.value) || 20; | |
| const mass = parseInt(creatorMass.value) || 5; | |
| const color = creatorColor.value; | |
| const randomize = randomizeProps.checked; | |
| const newParticles = createParticles( | |
| count, radius, mass, color, randomize | |
| ); | |
| state.particles.push(...newParticles); | |
| state.creatingParticles = false; | |
| particleCreator.classList.add('hidden'); | |
| updateParticleCounter(); | |
| if (!state.running) { | |
| animate(performance.now()); | |
| } | |
| }); | |
| removeParticleBtn.addEventListener('click', () => { | |
| state.particles = []; | |
| state.selectedParticles = []; | |
| updateParticleProperties(); | |
| updateParticleCounter(); | |
| if (!state.running) { | |
| ctx.clearRect(0, 0, canvas.width, canvas.height); | |
| } | |
| }); | |
| randomizeBtn.addEventListener('click', () => { | |
| state.particles.forEach(p => { | |
| p.vx = (Math.random() - 0.5) * 10; | |
| p.vy = (Math.random() - 0.5) * 10; | |
| p.frozen = false; | |
| }); | |
| updateParticleProperties(); | |
| }); | |
| freezeAllBtn.addEventListener('click', () => { | |
| const allFrozen = state.particles.every(p => p.frozen); | |
| state.particles.forEach(p => p.frozen = !allFrozen); | |
| updateParticleProperties(); | |
| }); | |
| // Preset scenarios | |
| elasticCollisionBtn.addEventListener('click', () => { | |
| state.particles = []; | |
| state.restitution = 1.0; | |
| restitutionThumb.style.left = '100%'; | |
| restitutionFill.style.width = '100%'; | |
| restitutionValue.textContent = '100%'; | |
| // Create two particles moving toward each other | |
| const p1 = new Particle( | |
| canvas.width * 0.3, canvas.height / 2, | |
| 30, 5, '#4f46e5', | |
| 200, 0 | |
| ); | |
| const p2 = new Particle( | |
| canvas.width * 0.7, canvas.height / 2, | |
| 30, 5, '#10b981', | |
| -200, 0 | |
| ); | |
| state.particles.push(p1, p2); | |
| updateParticleCounter(); | |
| if (!state.running) { | |
| animate(performance.now()); | |
| } | |
| }); | |
| inelasticCollisionBtn.addEventListener('click', () => { | |
| state.particles = []; | |
| state.restitution = 0.2; | |
| restitutionThumb.style.left = '20%'; | |
| restitutionFill.style.width = '20%'; | |
| restitutionValue.textContent = '20%'; | |
| // Create two particles moving toward each other | |
| const p1 = new Particle( | |
| canvas.width * 0.3, canvas.height / 2, | |
| 30, 5, '#4f46e5', | |
| 200, 0 | |
| ); | |
| const p2 = new Particle( | |
| canvas.width * 0.7, canvas.height / 2, | |
| 30, 5, '#10b981', | |
| -200, 0 | |
| ); | |
| state.particles.push(p1, p2); | |
| updateParticleCounter(); | |
| if (!state.running) { | |
| animate(performance.now()); | |
| } | |
| }); | |
| newtonsCradleBtn.addEventListener('click', () => { | |
| state.particles = []; | |
| state.restitution = 0.95; | |
| restitutionThumb.style.left = '95%'; | |
| restitutionFill.style.width = '95%'; | |
| restitutionValue.textContent = '95%'; | |
| // Create Newton's Cradle with 5 balls | |
| const radius = 25; | |
| const startX = canvas.width / 2 - radius * 5; | |
| const startY = canvas.height / 2; | |
| for (let i = 0; i < 5; i++) { | |
| const p = new Particle( | |
| startX + i * radius * 2, startY, | |
| radius, 5, '#3b82f6', | |
| 0, 0 | |
| ); | |
| state.particles.push(p); | |
| } | |
| // Pull first ball and release | |
| state.particles[0].vx = 300; | |
| updateParticleCounter(); | |
| if (!state.running) { | |
| animate(performance.now()); | |
| } | |
| }); | |
| particleExplosionBtn.addEventListener('click', () => { | |
| state.particles = []; | |
| state.restitution = 0.8; | |
| restitutionThumb.style.left = '80%'; | |
| restitutionFill.style.width = '80%'; | |
| restitutionValue.textContent = '80%'; | |
| // Create explosion at center | |
| const centerX = canvas.width / 2; | |
| const centerY = canvas.height / 2; | |
| const count = 10; | |
| for (let i = 0; i < count; i++) { | |
| const angle = (i / count) * Math.PI * 2; | |
| const speed = 100 + Math.random() * 100; | |
| const p = new Particle( | |
| centerX, centerY, | |
| 15 + Math.random() * 10, | |
| 2 + Math.random() * 5, | |
| getRandomColor(), | |
| Math.cos(angle) * speed, | |
| Math.sin(angle) * speed | |
| ); | |
| state.particles.push(p); | |
| } | |
| updateParticleCounter(); | |
| if (!state.running) { | |
| animate(performance.now()); | |
| } | |
| }); | |
| // Slider value displays | |
| creatorRadius.addEventListener('input', () => { | |
| creatorRadiusValue.textContent = creatorRadius.value; | |
| }); | |
| creatorMass.addEventListener('input', () => { | |
| creatorMassValue.textContent = creatorMass.value; | |
| }); | |
| // Canvas interaction | |
| canvas.addEventListener('mousedown', (e) => { | |
| if (state.creatingParticles) return; | |
| const rect = canvas.getBoundingClientRect(); | |
| const x = e.clientX - rect.left; | |
| const y = e.clientY - rect.top; | |
| // Check if clicking on a particle | |
| let clickedParticle = null; | |
| for (let i = state.particles.length - 1; i >= 0; i--) { | |
| if (state.particles[i].isPointInside(x, y)) { | |
| clickedParticle = state.particles[i]; | |
| break; | |
| } | |
| } | |
| // Handle shift key for multi-selection | |
| if (e.shiftKey && clickedParticle) { | |
| clickedParticle.selected = !clickedParticle.selected; | |
| if (clickedParticle.selected) { | |
| state.selectedParticles.push(clickedParticle); | |
| } else { | |
| state.selectedParticles = state.selectedParticles.filter(p => p !== clickedParticle); | |
| } | |
| } else { | |
| // Single selection or drag start | |
| if (clickedParticle) { | |
| // Toggle selection if clicking the same particle | |
| if (state.selectedParticles.length === 1 && state.selectedParticles[0] === clickedParticle) { | |
| clickedParticle.selected = false; | |
| state.selectedParticles = []; | |
| } else { | |
| // Deselect all and select clicked particle | |
| state.particles.forEach(p => p.selected = false); | |
| state.selectedParticles = [clickedParticle]; | |
| clickedParticle.selected = true; | |
| // Prepare for dragging | |
| clickedParticle.dragging = true; | |
| clickedParticle.dragOffsetX = x - clickedParticle.x; | |
| clickedParticle.dragOffsetY = y - clickedParticle.y; | |
| } | |
| } else { | |
| // Clicked empty space - deselect all | |
| state.particles.forEach(p => p.selected = false); | |
| state.selectedParticles = []; | |
| } | |
| } | |
| updateParticleProperties(); | |
| if (!state.running && (state.particles.length > 0 || state.selectedParticles.length > 0)) { | |
| animate(performance.now()); | |
| } | |
| }); | |
| canvas.addEventListener('mousemove', (e) => { | |
| if (state.selectedParticles.length === 1 && state.selectedParticles[0].dragging) { | |
| const rect = canvas.getBoundingClientRect(); | |
| const x = e.clientX - rect.left; | |
| const y = e.clientY - rect.top; | |
| const p = state.selectedParticles[0]; | |
| p.x = x - p.dragOffsetX; | |
| p.y = y - p.dragOffsetY; | |
| // Keep particle within bounds | |
| p.x = Math.max(p.radius, Math.min(p.x, canvas.width - p.radius)); | |
| p.y = Math.max(p.radius, Math.min(p.y, canvas.height - p.radius)); | |
| if (!state.running) { | |
| animate(performance.now()); | |
| } | |
| } | |
| }); | |
| canvas.addEventListener('mouseup', () => { | |
| if (state.selectedParticles.length === 1) { | |
| state.selectedParticles[0].dragging = false; | |
| } | |
| }); | |
| canvas.addEventListener('mouseleave', () => { | |
| if (state.selectedParticles.length === 1) { | |
| state.selectedParticles[0].dragging = false; | |
| } | |
| }); | |
| // Initial setup | |
| updateParticleCounter(); | |
| </script> | |
| <p style="border-radius: 8px; text-align: center; font-size: 12px; color: #fff; margin-top: 16px;position: fixed; left: 8px; bottom: 8px; z-index: 10; background: rgba(0, 0, 0, 0.8); padding: 4px 8px;">Made with <img src="https://enzostvs-deepsite.hf.space/logo.svg" alt="DeepSite Logo" style="width: 16px; height: 16px; vertical-align: middle;display:inline-block;margin-right:3px;filter:brightness(0) invert(1);"><a href="https://enzostvs-deepsite.hf.space" style="color: #fff;text-decoration: underline;" target="_blank" >DeepSite</a> - <a href="https://enzostvs-deepsite.hf.space?remix=engerl/physics-collision-lab" style="color: #fff;text-decoration: underline;" target="_blank" >🧬 Remix</a></p></body> | |
| </html> |