physics-collision-lab / index.html
engerl's picture
Add 2 files
112228c verified
<!DOCTYPE html>
<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>