← back to Model Arena
data/artifacts/0e969e848172/grok.html
339 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>2D Gravity Sandbox</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; overflow: hidden; background: #0a0a12; font-family: system-ui, sans-serif; }
canvas { display: block; width: 100%; height: 100%; cursor: crosshair; }
#ui {
position: fixed; top: 12px; left: 12px; z-index: 10;
display: flex; flex-wrap: wrap; gap: 8px; align-items: center;
}
#ui button, #ui label {
background: rgba(20, 24, 40, 0.85);
border: 1px solid rgba(120, 140, 200, 0.35);
color: #c8d0e8;
padding: 8px 14px;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
backdrop-filter: blur(6px);
user-select: none;
}
#ui button:hover { background: rgba(40, 50, 80, 0.9); border-color: rgba(150, 170, 230, 0.5); }
#ui label { display: flex; align-items: center; gap: 6px; cursor: pointer; }
#ui input[type="checkbox"] { accent-color: #6af; cursor: pointer; }
#hint {
position: fixed; bottom: 12px; left: 50%; transform: translateX(-50%);
color: rgba(180, 190, 220, 0.5); font-size: 12px; pointer-events: none;
text-align: center; white-space: nowrap;
}
#mass {
position: fixed; top: 12px; right: 12px; z-index: 10;
background: rgba(20, 24, 40, 0.85);
border: 1px solid rgba(120, 140, 200, 0.35);
color: #c8d0e8;
padding: 8px 14px;
border-radius: 6px;
font-size: 13px;
backdrop-filter: blur(6px);
display: flex; align-items: center; gap: 8px;
}
#mass input[type="range"] { width: 100px; accent-color: #6af; cursor: pointer; }
</style>
</head>
<body>
<div id="ui">
<button id="clearBtn">Clear</button>
<label><input type="checkbox" id="trailToggle" checked> Trails</label>
<label><input type="checkbox" id="softToggle" checked> Soften</label>
</div>
<div id="mass">
<span>Mass</span>
<input type="range" id="massSlider" min="1" max="100" value="20">
<span id="massVal">20</span>
</div>
<div id="hint">Click to place bodies · Drag slightly to impart velocity</div>
<canvas id="c"></canvas>
<script>
(function () {
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const G = 0.4;
const MIN_DIST = 4;
const TRAIL_MAX = 180;
const DT = 1;
let W, H, dpr;
let bodies = [];
let showTrails = true;
let soften = true;
let hueCounter = 0;
// Drag-to-velocity state
let placing = false;
let placeX = 0, placeY = 0;
let placeStartX = 0, placeStartY = 0;
function resize() {
dpr = window.devicePixelRatio || 1;
W = window.innerWidth;
H = window.innerHeight;
canvas.width = W * dpr;
canvas.height = H * dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
window.addEventListener('resize', resize);
resize();
function radiusFromMass(m) {
return Math.max(3, Math.sqrt(m) * 2.2);
}
function nextHue() {
hueCounter = (hueCounter + 47) % 360;
return hueCounter;
}
function addBody(x, y, vx, vy, mass) {
const hue = nextHue();
bodies.push({
x, y, vx, vy,
mass,
r: radiusFromMass(mass),
hue,
trail: [],
color: `hsl(${hue}, 75%, 60%)`,
glow: `hsla(${hue}, 80%, 55%, 0.35)`
});
}
// Seed a few bodies for immediate interest
function seed() {
const cx = W / 2, cy = H / 2;
addBody(cx, cy, 0, 0, 80);
addBody(cx + 160, cy, 0, 1.6, 12);
addBody(cx - 220, cy + 40, 0, -1.2, 8);
addBody(cx + 60, cy - 200, 1.4, 0.3, 5);
}
seed();
document.getElementById('clearBtn').addEventListener('click', () => {
bodies = [];
});
document.getElementById('trailToggle').addEventListener('change', (e) => {
showTrails = e.target.checked;
if (!showTrails) bodies.forEach(b => { b.trail = []; });
});
document.getElementById('softToggle').addEventListener('change', (e) => {
soften = e.target.checked;
});
const massSlider = document.getElementById('massSlider');
const massVal = document.getElementById('massVal');
massSlider.addEventListener('input', () => { massVal.textContent = massSlider.value; });
canvas.addEventListener('pointerdown', (e) => {
placing = true;
placeX = placeStartX = e.clientX;
placeY = placeStartY = e.clientY;
});
canvas.addEventListener('pointermove', (e) => {
if (!placing) return;
placeX = e.clientX;
placeY = e.clientY;
});
function finishPlace(e) {
if (!placing) return;
placing = false;
const x = placeStartX;
const y = placeStartY;
const dx = (e.clientX !== undefined ? e.clientX : placeX) - placeStartX;
const dy = (e.clientY !== undefined ? e.clientY : placeY) - placeStartY;
// Velocity from drag (scale down)
const vx = dx * 0.035;
const vy = dy * 0.035;
const mass = parseFloat(massSlider.value) || 20;
addBody(x, y, vx, vy, mass);
}
canvas.addEventListener('pointerup', finishPlace);
canvas.addEventListener('pointercancel', () => { placing = false; });
canvas.addEventListener('pointerleave', (e) => {
if (placing) finishPlace(e);
});
function step() {
const n = bodies.length;
// Accelerations
const ax = new Float64Array(n);
const ay = new Float64Array(n);
for (let i = 0; i < n; i++) {
for (let j = i + 1; j < n; j++) {
const a = bodies[i], b = bodies[j];
let dx = b.x - a.x;
let dy = b.y - a.y;
let distSq = dx * dx + dy * dy;
if (soften) distSq += MIN_DIST * MIN_DIST;
else if (distSq < 1e-6) continue;
const dist = Math.sqrt(distSq);
const force = G / (distSq * dist); // G * m1*m2 / r^2, then /m later → G*other / r^3 * r_vec
const fx = force * dx;
const fy = force * dy;
ax[i] += fx * b.mass;
ay[i] += fy * b.mass;
ax[j] -= fx * a.mass;
ay[j] -= fy * a.mass;
}
}
// Integrate + merge on collision
const merged = new Set();
for (let i = 0; i < n; i++) {
if (merged.has(i)) continue;
const b = bodies[i];
b.vx += ax[i] * DT;
b.vy += ay[i] * DT;
b.x += b.vx * DT;
b.y += b.vy * DT;
if (showTrails) {
b.trail.push(b.x, b.y);
if (b.trail.length > TRAIL_MAX * 2) {
b.trail.splice(0, b.trail.length - TRAIL_MAX * 2);
}
}
}
// Simple inelastic merge when overlapping
for (let i = 0; i < bodies.length; i++) {
if (merged.has(i)) continue;
for (let j = i + 1; j < bodies.length; j++) {
if (merged.has(j)) continue;
const a = bodies[i], b = bodies[j];
const dx = b.x - a.x, dy = b.y - a.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < a.r + b.r * 0.5) {
// Merge j into i
const total = a.mass + b.mass;
a.vx = (a.vx * a.mass + b.vx * b.mass) / total;
a.vy = (a.vy * a.mass + b.vy * b.mass) / total;
a.x = (a.x * a.mass + b.x * b.mass) / total;
a.y = (a.y * a.mass + b.y * b.mass) / total;
a.mass = total;
a.r = radiusFromMass(total);
// Keep heavier color influence
if (b.mass > a.mass - b.mass) {
a.hue = b.hue;
a.color = b.color;
a.glow = b.glow;
}
a.trail = [];
merged.add(j);
}
}
}
if (merged.size) {
bodies = bodies.filter((_, idx) => !merged.has(idx));
}
}
function draw() {
ctx.fillStyle = 'rgba(10, 10, 18, 0.35)';
ctx.fillRect(0, 0, W, H);
// Soft starfield residual: faint vignette
// Trails
if (showTrails) {
for (const b of bodies) {
const t = b.trail;
if (t.length < 4) continue;
ctx.beginPath();
ctx.moveTo(t[0], t[1]);
for (let i = 2; i < t.length; i += 2) {
ctx.lineTo(t[i], t[i + 1]);
}
const len = t.length / 2;
ctx.strokeStyle = b.color;
ctx.globalAlpha = 0.55;
ctx.lineWidth = Math.max(1, b.r * 0.35);
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.stroke();
ctx.globalAlpha = 1;
}
}
// Bodies
for (const b of bodies) {
// Glow
const grd = ctx.createRadialGradient(b.x, b.y, 0, b.x, b.y, b.r * 3.5);
grd.addColorStop(0, b.glow);
grd.addColorStop(1, 'transparent');
ctx.fillStyle = grd;
ctx.beginPath();
ctx.arc(b.x, b.y, b.r * 3.5, 0, Math.PI * 2);
ctx.fill();
// Core
const core = ctx.createRadialGradient(
b.x - b.r * 0.3, b.y - b.r * 0.3, b.r * 0.1,
b.x, b.y, b.r
);
core.addColorStop(0, `hsl(${b.hue}, 60%, 88%)`);
core.addColorStop(0.5, b.color);
core.addColorStop(1, `hsl(${b.hue}, 80%, 35%)`);
ctx.fillStyle = core;
ctx.beginPath();
ctx.arc(b.x, b.y, b.r, 0, Math.PI * 2);
ctx.fill();
}
// Placement preview
if (placing) {
const mass = parseFloat(massSlider.value) || 20;
const r = radiusFromMass(mass);
ctx.beginPath();
ctx.arc(placeStartX, placeStartY, r, 0, Math.PI * 2);
ctx.strokeStyle = 'rgba(200, 220, 255, 0.7)';
ctx.lineWidth = 1.5;
ctx.setLineDash([4, 4]);
ctx.stroke();
ctx.setLineDash([]);
// Velocity arrow
ctx.beginPath();
ctx.moveTo(placeStartX, placeStartY);
ctx.lineTo(placeX, placeY);
ctx.strokeStyle = 'rgba(150, 200, 255, 0.8)';
ctx.lineWidth = 2;
ctx.stroke();
// Arrow head
const ang = Math.atan2(placeY - placeStartY, placeX - placeStartX);
const ah = 8;
ctx.beginPath();
ctx.moveTo(placeX, placeY);
ctx.lineTo(placeX - ah * Math.cos(ang - 0.4), placeY - ah * Math.sin(ang - 0.4));
ctx.lineTo(placeX - ah * Math.cos(ang + 0.4), placeY - ah * Math.sin(ang + 0.4));
ctx.closePath();
ctx.fillStyle = 'rgba(150, 200, 255, 0.8)';
ctx.fill();
}
}
function loop() {
// Substeps for stability with many bodies
const sub = bodies.length > 25 ? 2 : 1;
for (let s = 0; s < sub; s++) step();
draw();
requestAnimationFrame(loop);
}
// Initial clear
ctx.fillStyle = '#0a0a12';
ctx.fillRect(0, 0, W, H);
requestAnimationFrame(loop);
})();
</script>
</body>
</html>