Simulation
Presets
Parameters
Add Body
Click canvas to add body. Drag to set velocity.
Energy
Kinetic
0.00
Potential
0.00
Total
0.00
Δ Energy
0.00%
Statistics
Bodies
0
FPS
0
Developer Reference
Core Algorithm & Standalone Script
Standalone, zero-dependency JavaScript implementation powering this tool. Free to inspect, copy, and build upon.
// ========== Constants ==========
const G_BASE = 1.0;
// ========== State ==========
const state = {
bodies: [],
paused: false,
selectedBodyIndex: null,
gravity: 1.0,
softening: 2.0,
timeStep: 0.5,
trailMaxLength: 200,
addingMass: 10,
showCOM: false,
initialEnergy: null,
fps: 0,
lastFrameTime: Date.now(),
frameCount: 0
};
// ========== DOM References ==========
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const pausePlayBtn = document.getElementById('pausePlayBtn');
const clearBtn = document.getElementById('clearBtn');
const gravitySlider = document.getElementById('gravitySlider');
const softeningSlider = document.getElementById('softeningSlider');
const timeStepSlider = document.getElementById('timeStepSlider');
const trailSlider = document.getElementById('trailSlider');
const massSlider = document.getElementById('massSlider');
const comToggle = document.getElementById('comToggle');
const comMarker = document.getElementById('comMarker');
const presetBtns = {
solar: document.getElementById('presetSolar'),
binary: document.getElementById('presetBinary'),
galaxy: document.getElementById('presetGalaxy'),
figure8: document.getElementById('presetFigure8'),
random: document.getElementById('presetRandom'),
slingshot: document.getElementById('presetSlingshot')
};
// ========== Canvas Setup ==========
function resizeCanvas() {
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width;
canvas.height = rect.height;
}
resizeCanvas();
window.addEventListener('resize', resizeCanvas);
// ========== Body Class ==========
class Body {
constructor(x, y, vx, vy, mass) {
this.x = x;
this.y = y;
this.vx = vx;
this.vy = vy;
this.mass = mass;
this.ax = 0;
this.ay = 0;
this.trail = [];
this.color = this.getColorByMass(mass);
}
getColorByMass(mass) {
const normalized = Math.min(mass / 50, 1);
if (normalized < 0.3) return `hsl(200, 100%, ${60 + normalized * 20}%)`;
if (normalized < 0.6) return `hsl(50, 100%, ${60 + (normalized - 0.3) * 20}%)`;
return `hsl(20, 100%, ${50 + (normalized - 0.6) * 30}%)`;
}
get radius() {
return Math.cbrt(this.mass) * 0.8;
}
get ke() {
const v2 = this.vx * this.vx + this.vy * this.vy;
return 0.5 * this.mass * v2;
}
}
// ========== Event Listeners ==========
pausePlayBtn.addEventListener('click', () => {
state.paused = !state.paused;
pausePlayBtn.textContent = state.paused ? '▶ Play' : '⏸ Pause';
});
clearBtn.addEventListener('click', () => {
state.bodies = [];
state.selectedBodyIndex = null;
state.initialEnergy = null;
updateSelectionUI();
});
gravitySlider.addEventListener('input', (e) => {
state.gravity = parseFloat(e.target.value);
document.getElementById('gravityValue').textContent = state.gravity.toFixed(1);
});
softeningSlider.addEventListener('input', (e) => {
state.softening = parseFloat(e.target.value);
document.getElementById('softeningValue').textContent = state.softening.toFixed(1);
});
timeStepSlider.addEventListener('input', (e) => {
state.timeStep = parseFloat(e.target.value);
document.getElementById('timeStepValue').textContent = state.timeStep.toFixed(1) + 'x';
});
trailSlider.addEventListener('input', (e) => {
state.trailMaxLength = parseInt(e.target.value);
document.getElementById('trailValue').textContent = state.trailMaxLength;
});
massSlider.addEventListener('input', (e) => {
state.addingMass = parseFloat(e.target.value);
document.getElementById('massValue').textContent = state.addingMass.toFixed(1);
});
comToggle.addEventListener('change', (e) => {
state.showCOM = e.target.checked;
});
document.getElementById('deleteBodyBtn').addEventListener('click', () => {
if (state.selectedBodyIndex !== null) {
state.bodies.splice(state.selectedBodyIndex, 1);
state.selectedBodyIndex = null;
updateSelectionUI();
}
});
// ========== Preset Scenarios ==========
presetBtns.solar.addEventListener('click', createSolarSystem);
presetBtns.binary.addEventListener('click', createBinaryStar);
presetBtns.galaxy.addEventListener('click', createGalaxyCollision);
presetBtns.figure8.addEventListener('click', createFigure8);
presetBtns.random.addEventListener('click', () => createRandom(20));
presetBtns.slingshot.addEventListener('click', createSlingshot);
function createSolarSystem() {
state.bodies = [];
const sun = new Body(canvas.width / 2, canvas.height / 2, 0, 0, 200);
state.bodies.push(sun);
const distances = [80, 130, 160, 200, 240, 280];
const masses = [0.5, 0.8, 1, 0.6, 100, 0.4];
for (let i = 0; i < 6; i++) {
const d = distances[i];
const m = masses[i];
const angle = Math.random() * Math.PI * 2;
const x = canvas.width / 2 + Math.cos(angle) * d;
const y = canvas.height / 2 + Math.sin(angle) * d;
const v = Math.sqrt(state.gravity * sun.mass / d);
const vx = -Math.sin(angle) * v * 0.9;
const vy = Math.cos(angle) * v * 0.9;
state.bodies.push(new Body(x, y, vx, vy, m));
}
state.initialEnergy = null;
state.selectedBodyIndex = null;
updateSelectionUI();
}
function createBinaryStar() {
state.bodies = [];
const cx = canvas.width / 2;
const cy = canvas.height / 2;
const d = 100;
const m = 80;
const v = Math.sqrt(state.gravity * m / d) * 0.5;
state.bodies.push(new Body(cx - d/2, cy, 0, v, m));
state.bodies.push(new Body(cx + d/2, cy, 0, -v, m));
state.initialEnergy = null;
state.selectedBodyIndex = null;
updateSelectionUI();
}
function createGalaxyCollision() {
state.bodies = [];
// Disk 1
const cx1 = canvas.width * 0.3;
const cy1 = canvas.height / 2;
const central1 = new Body(cx1, cy1, 4, 0, 150);
state.bodies.push(central1);
for (let i = 0; i < 30; i++) {
const r = 50 + Math.random() * 60;
const angle = Math.random() * Math.PI * 2;
const x = cx1 + Math.cos(angle) * r;
const y = cy1 + Math.sin(angle) * r;
const v = Math.sqrt(state.gravity * central1.mass / r) * 0.8;
const vx = -Math.sin(angle) * v + 4;
const vy = Math.cos(angle) * v;
state.bodies.push(new Body(x, y, vx, vy, 0.5));
}
// Disk 2
const cx2 = canvas.width * 0.7;
const cy2 = canvas.height / 2;
const central2 = new Body(cx2, cy2, -4, 0, 150);
state.bodies.push(central2);
for (let i = 0; i < 30; i++) {
const r = 50 + Math.random() * 60;
const angle = Math.random() * Math.PI * 2;
const x = cx2 + Math.cos(angle) * r;
const y = cy2 + Math.sin(angle) * r;
const v = Math.sqrt(state.gravity * central2.mass / r) * 0.8;
const vx = -Math.sin(angle) * v - 4;
const vy = Math.cos(angle) * v;
state.bodies.push(new Body(x, y, vx, vy, 0.5));
}
state.initialEnergy = null;
state.selectedBodyIndex = null;
updateSelectionUI();
}
function createFigure8() {
state.bodies = [];
const m = 1;
const scale = 100;
state.bodies.push(new Body(canvas.width / 2 - scale, canvas.height / 2, 0.3063 * scale, 0.3063 * scale, m));
state.bodies.push(new Body(canvas.width / 2 + scale, canvas.height / 2, 0.3063 * scale, 0.3063 * scale, m));
state.bodies.push(new Body(canvas.width / 2, canvas.height / 2 + scale * 0.4, -0.3063 * scale * 2, 0, m));
state.initialEnergy = null;
state.selectedBodyIndex = null;
updateSelectionUI();
}
function createRandom(count) {
state.bodies = [];
for (let i = 0; i < count; i++) {
const x = Math.random() * canvas.width * 0.8 + canvas.width * 0.1;
const y = Math.random() * canvas.height * 0.8 + canvas.height * 0.1;
const vx = (Math.random() - 0.5) * 4;
const vy = (Math.random() - 0.5) * 4;
const mass = Math.random() * 15 + 1;
state.bodies.push(new Body(x, y, vx, vy, mass));
}
state.initialEnergy = null;
state.selectedBodyIndex = null;
updateSelectionUI();
}
function createSlingshot() {
state.bodies = [];
const star = new Body(canvas.width / 2, canvas.height / 2, 0, 0, 200);
state.bodies.push(star);
const probe = new Body(canvas.width / 2 + 300, canvas.height / 2, 0, 3, 0.1);
state.bodies.push(probe);
state.initialEnergy = null;
state.selectedBodyIndex = null;
updateSelectionUI();
}
// ========== Physics ==========
function computeAccelerations() {
for (let body of state.bodies) {
body.ax = 0;
body.ay = 0;
}
for (let i = 0; i < state.bodies.length; i++) {
for (let j = i + 1; j < state.bodies.length; j++) {
const b1 = state.bodies[i];
const b2 = state.bodies[j];
const dx = b2.x - b1.x;
const dy = b2.y - b1.y;
const r2 = dx * dx + dy * dy + state.softening * state.softening;
const r = Math.sqrt(r2);
const F = (state.gravity * G_BASE * b1.mass * b2.mass) / r2;
const ax = (F / b1.mass) * (dx / r);
const ay = (F / b1.mass) * (dy / r);
b1.ax += ax;
b1.ay += ay;
b2.ax -= (F / b2.mass) * (dx / r);
b2.ay -= (F / b2.mass) * (dy / r);
}
}
}
function leapfrogStep(dt) {
computeAccelerations();
for (let body of state.bodies) {
body.vx += body.ax * dt / 2;
body.vy += body.ay * dt / 2;
body.x += body.vx * dt;
body.y += body.vy * dt;
}
computeAccelerations();
for (let body of state.bodies) {
body.vx += body.ax * dt / 2;
body.vy += body.ay * dt / 2;
}
}
function updatePhysics() {
if (state.paused) return;
const dt = state.timeStep * 0.016;
leapfrogStep(dt);
for (let body of state.bodies) {
body.trail.push([body.x, body.y]);
if (body.trail.length > state.trailMaxLength) {
body.trail.shift();
}
}
}
// ========== Energy Calculation ==========
function calculateEnergy() {
let ke = 0, pe = 0;
for (let body of state.bodies) {
ke += body.ke;
}
for (let i = 0; i < state.bodies.length; i++) {
for (let j = i + 1; j < state.bodies.length; j++) {
const b1 = state.bodies[i];
const b2 = state.bodies[j];
const dx = b2.x - b1.x;
const dy = b2.y - b1.y;
const r = Math.sqrt(dx * dx + dy * dy);
pe -= (state.gravity * G_BASE * b1.mass * b2.mass) / r;
}
}
return { ke, pe, total: ke + pe };
}
// ========== COM & Statistics ==========
function calculateCOM() {
let totalMass = 0, comX = 0, comY = 0;
for (let body of state.bodies) {
totalMass += body.mass;
comX += body.x * body.mass;
comY += body.y * body.mass;
}
if (totalMass === 0) return { x: canvas.width / 2, y: canvas.height / 2 };
return { x: comX / totalMass, y: comY / totalMass };
}
function updateStats() {
const energy = calculateEnergy();
document.getElementById('statKE').textContent = energy.ke.toFixed(2);
document.getElementById('statPE').textContent = energy.pe.toFixed(2);
document.getElementById('statTE').textContent = energy.total.toFixed(2);
if (state.initialEnergy === null && state.bodies.length > 0) {
state.initialEnergy = energy.total;
}
if (state.initialEnergy !== null) {
const deltaE = ((energy.total - state.initialEnergy) / Math.abs(state.initialEnergy)) * 100;
const deltaClass = Math.abs(deltaE) > 5 ? 'error' : (Math.abs(deltaE) > 2 ? 'warning' : '');
const elem = document.getElementById('statDeltaE');
elem.textContent = deltaE.toFixed(2) + '%';
elem.className = 'stat-value ' + deltaClass;
}
document.getElementById('statBodies').textContent = state.bodies.length;
document.getElementById('statFPS').textContent = state.fps;
const com = calculateCOM();
if (state.showCOM) {
comMarker.style.display = 'block';
comMarker.style.left = (com.x + canvas.getBoundingClientRect().left) + 'px';
comMarker.style.top = (com.y + canvas.getBoundingClientRect().top + 58) + 'px';
} else {
comMarker.style.display = 'none';
}
}
// ========== Rendering ==========
function render() {
ctx.fillStyle = '#0a0a0a';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Draw trails
for (let i = 0; i < state.bodies.length; i++) {
const body = state.bodies[i];
if (body.trail.length < 2) continue;
ctx.strokeStyle = body.color;
ctx.globalAlpha = 0.2;
ctx.lineWidth = 0.5;
ctx.beginPath();
ctx.moveTo(body.trail[0][0], body.trail[0][1]);
for (let j = 1; j < body.trail.length; j++) {
ctx.lineTo(body.trail[j][0], body.trail[j][1]);
}
ctx.stroke();
ctx.globalAlpha = 1;
}
// Draw bodies
for (let i = 0; i < state.bodies.length; i++) {
const body = state.bodies[i];
const r = body.radius;
// Glow
ctx.shadowBlur = 20;
ctx.shadowColor = body.color;
ctx.fillStyle = body.color;
ctx.globalAlpha = 0.3;
ctx.beginPath();
ctx.arc(body.x, body.y, r * 1.8, 0, Math.PI * 2);
ctx.fill();
ctx.shadowBlur = 0;
ctx.globalAlpha = 1;
ctx.fillStyle = body.color;
ctx.beginPath();
ctx.arc(body.x, body.y, r, 0, Math.PI * 2);
ctx.fill();
// Selection ring
if (i === state.selectedBodyIndex) {
ctx.strokeStyle = '#ff2200';
ctx.lineWidth = 2;
ctx.globalAlpha = 0.8;
ctx.beginPath();
ctx.arc(body.x, body.y, r + 6, 0, Math.PI * 2);
ctx.stroke();
ctx.globalAlpha = 1;
}
// Velocity vector
const speed = Math.sqrt(body.vx * body.vx + body.vy * body.vy);
if (speed > 0.1) {
const vlen = Math.min(speed * 5, 30);
const vx = (body.vx / speed) * vlen;
const vy = (body.vy / speed) * vlen;
ctx.strokeStyle = '#555555';
ctx.lineWidth = 1;
ctx.globalAlpha = 0.5;
ctx.beginPath();
ctx.moveTo(body.x, body.y);
ctx.lineTo(body.x + vx, body.y + vy);
ctx.stroke();
ctx.globalAlpha = 1;
}
}
}
// ========== Selection & Interaction ==========
function updateSelectionUI() {
const section = document.getElementById('selectionSection');
if (state.selectedBodyIndex === null) {
section.style.display = 'none';
} else {
const body = state.bodies[state.selectedBodyIndex];
const info = document.getElementById('selectionInfo');
const speed = Math.sqrt(body.vx * body.vx + body.vy * body.vy);
info.innerHTML = `
<div class="selection-info-line">Mass: ${body.mass.toFixed(2)}</div>
<div class="selection-info-line">Speed: ${speed.toFixed(2)}</div>
<div class="selection-info-line">Vx: ${body.vx.toFixed(2)}</div>
<div class="selection-info-line">Vy: ${body.vy.toFixed(2)}</div>
<div class="selection-info-line">KE: ${body.ke.toFixed(2)}</div>
`;
section.style.display = 'block';
}
}
canvas.addEventListener('click', (e) => {
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
let clicked = null;
for (let i = 0; i < state.bodies.length; i++) {
const body = state.bodies[i];
const dist = Math.sqrt((body.x - x) ** 2 + (body.y - y) ** 2);
if (dist < body.radius + 10) {
clicked = i;
break;
}
}
if (clicked !== null) {
state.selectedBodyIndex = clicked;
updateSelectionUI();
} else {
state.selectedBodyIndex = null;
updateSelectionUI();
}
});
canvas.addEventListener('contextmenu', (e) => {
e.preventDefault();
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
for (let i = 0; i < state.bodies.length; i++) {
const body = state.bodies[i];
const dist = Math.sqrt((body.x - x) ** 2 + (body.y - y) ** 2);
if (dist < body.radius + 10) {
state.bodies.splice(i, 1);
state.selectedBodyIndex = null;
updateSelectionUI();
break;
}
}
});
let dragStart = null;
canvas.addEventListener('mousedown', (e) => {
if (e.button === 2) return;
const rect = canvas.getBoundingClientRect();
dragStart = { x: e.clientX - rect.left, y: e.clientY - rect.top };
});
canvas.addEventListener('mousemove', (e) => {
if (!dragStart) return;
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
// Draw velocity preview line if dragging
if (state.dragPreview) {
// Will be drawn in render
}
});
canvas.addEventListener('mouseup', (e) => {
if (!dragStart) return;
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const clicked = state.selectedBodyIndex;
if (clicked === null) {
const vx = (x - dragStart.x) * 0.1;
const vy = (y - dragStart.y) * 0.1;
const body = new Body(dragStart.x, dragStart.y, vx, vy, state.addingMass);
state.bodies.push(body);
state.initialEnergy = null;
}
dragStart = null;
});
canvas.addEventListener('mouseleave', () => {
dragStart = null;
});
// ========== FPS Counter ==========
function updateFPS() {
state.frameCount++;
const now = Date.now();
if (now - state.lastFrameTime >= 1000) {
state.fps = state.frameCount;
state.frameCount = 0;
state.lastFrameTime = now;
}
}
// ========== Main Loop ==========
function animate() {
updatePhysics();
render();
updateStats();
updateFPS();
requestAnimationFrame(animate);
}
animate();