JIT
Grid - Untitled
Generate a single-file HTML simulation of a physics-based hourglass. Sand particles must fall through a narrow neck and pile up realistically in a cone shape (granular physics). Clicking the canvas triggers a smooth 180-degree rotation, reversing the flow of sand.
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Physics-Based Hourglass</title> <style> * { margin: 0; padding: 0; box-sizing: border-box; }

body { width: 100vw; height: 100vh; overflow: hidden; background: linear-gradient(135deg, #1e3c72 0%, #2a5298 50%, #7e8ba3 100%); display: flex; justify-content: center; align-items: center; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; }

#canvas { cursor: pointer; box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); border-radius: 10px; background: #f5f5f5; max-width: 95vw; max-height: 95vh; }

.info { position: absolute; top: 20px; left: 50%; transform: translateX(-50%); color: white; font-size: clamp(16px, 2vw, 24px); text-align: center; text-shadow: 0 2px 10px rgba(0, 0, 0, 0.5); pointer-events: none; z-index: 10; } </style>

</head> <body> <div class="info">Click to flip the hourglass</div> <canvas id="canvas"></canvas> <script> const canvas = document.getElementById('canvas'); const ctx = canvas.getContext('2d'); // Canvas sizing const baseWidth = 600; const baseHeight = 800; const scale = Math.min( (window.innerWidth * 0.95) / baseWidth, (window.innerHeight * 0.95) / baseHeight ); canvas.width = baseWidth; canvas.height = baseHeight; canvas.style.width = (baseWidth * scale) + 'px'; canvas.style.height = (baseHeight * scale) + 'px'; // Physics constants const GRAVITY = 0.3; const FRICTION = 0.98; const PARTICLE_RADIUS = 2.5; const NECK_WIDTH = 20; const RESTITUTION = 0.1; // Hourglass geometry const centerX = canvas.width / 2; const centerY = canvas.height / 2; const bulbWidth = 220; const bulbHeight = 320; const neckHeight = 60; // Particles let particles = []; let rotation = 0; let targetRotation = 0; let isRotating = false; // Particle class class Particle { constructor(x, y) { this.x = x; this.y = y; this.vx = (Math.random() - 0.5) * 0.5; this.vy = 0; this.radius = PARTICLE_RADIUS; this.color = `hsl(${30 + Math.random() * 20}, 70%, ${50 + Math.random() * 15}%)`; } update(gravityDir) { // Apply gravity this.vy += GRAVITY * gravityDir; // Apply velocity this.x += this.vx; this.y += this.vy; // Friction this.vx *= FRICTION; this.vy *= FRICTION; } draw() { ctx.fillStyle = this.color; ctx.beginPath(); ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2); ctx.fill(); } } // Initialize particles in top bulb function initParticles() { particles = []; const particleCount = 2000; for (let i = 0; i < particleCount; i++) { const angle = Math.random() * Math.PI * 2; const distance = Math.random() * (bulbWidth / 2 - 20); const x = centerX + Math.cos(angle) * distance; const y = 100 + Math.random() * (bulbHeight - 100); particles.push(new Particle(x, y)); } } // Get hourglass boundaries at current rotation function getHourglassBounds(px, py, rot) { // Transform point to hourglass space const dx = px - centerX; const dy = py - centerY; const cos = Math.cos(rot); const sin = Math.sin(rot); const localX = dx * cos + dy * sin; const localY = -dx * sin + dy * cos; // Top bulb if (localY < -neckHeight / 2) { const distFromTop = Math.abs(localY + bulbHeight / 2); const maxWidth = bulbWidth / 2 * (1 - distFromTop / bulbHeight); return Math.abs(localX) <= maxWidth; } // Bottom bulb else if (localY > neckHeight / 2) { const distFromBottom = Math.abs(localY - bulbHeight / 2); const maxWidth = bulbWidth / 2 * (1 - distFromBottom / bulbHeight); return Math.abs(localX) <= maxWidth; } // Neck else { return Math.abs(localX) <= NECK_WIDTH / 2; } } // Get the constraint position for a particle function constrainParticle(p, rot) { const dx = p.x - centerX; const dy = p.y - centerY; const cos = Math.cos(rot); const sin = Math.sin(rot); const localX = dx * cos + dy * sin; const localY = -dx * sin + dy * cos; let newLocalX = localX; let newLocalY = localY; let constrained = false; // Top bulb if (localY < -neckHeight / 2) { const distFromTop = Math.abs(localY + bulbHeight / 2); const maxWidth = bulbWidth / 2 * (1 - distFromTop / bulbHeight) - p.radius; if (Math.abs(localX) > maxWidth) { newLocalX = Math.sign(localX) * maxWidth; constrained = true; } if (localY < -bulbHeight / 2 + p.radius) { newLocalY = -bulbHeight / 2 + p.radius; constrained = true; } } // Bottom bulb else if (localY > neckHeight / 2) { const distFromBottom = Math.abs(localY - bulbHeight / 2); const maxWidth = bulbWidth / 2 * (1 - distFromBottom / bulbHeight) - p.radius; if (Math.abs(localX) > maxWidth) { newLocalX = Math.sign(localX) * maxWidth; constrained = true; } if (localY > bulbHeight / 2 - p.radius) { newLocalY = bulbHeight / 2 - p.radius; constrained = true; } } // Neck else { const maxWidth = NECK_WIDTH / 2 - p.radius; if (Math.abs(localX) > maxWidth) { newLocalX = Math.sign(localX) * maxWidth; constrained = true; } } if (constrained) { // Transform back to world space const newDx = newLocalX * cos - newLocalY * sin; const newDy = newLocalX * sin + newLocalY * cos; p.x = centerX + newDx; p.y = centerY + newDy; // Dampen velocity on collision p.vx *= RESTITUTION; p.vy *= RESTITUTION; } } // Particle collision detection function handleParticleCollisions() { for (let i = 0; i < particles.length; i++) { for (let j = i + 1; j < particles.length; j++) { const p1 = particles[i]; const p2 = particles[j]; const dx = p2.x - p1.x; const dy = p2.y - p1.y; const dist = Math.sqrt(dx * dx + dy * dy); const minDist = p1.radius + p2.radius; if (dist < minDist && dist > 0) { // Separate particles const overlap = minDist - dist; const nx = dx / dist; const ny = dy / dist; p1.x -= nx * overlap / 2; p1.y -= ny * overlap / 2; p2.x += nx * overlap / 2; p2.y += ny * overlap / 2; // Exchange velocities (simplified elastic collision) const dvx = p2.vx - p1.vx; const dvy = p2.vy - p1.vy; const dvn = dvx * nx + dvy * ny; if (dvn < 0) { p1.vx += dvn * nx * 0.5; p1.vy += dvn * ny * 0.5; p2.vx -= dvn * nx * 0.5; p2.vy -= dvn * ny * 0.5; } } } } } // Draw hourglass frame function drawHourglass() { ctx.save(); ctx.translate(centerX, centerY); ctx.rotate(rotation); // Outer frame ctx.strokeStyle = '#8B4513'; ctx.lineWidth = 8; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; // Top bulb outline ctx.beginPath(); ctx.moveTo(-bulbWidth / 2, -bulbHeight / 2); ctx.lineTo(-NECK_WIDTH / 2, -neckHeight / 2); ctx.stroke(); ctx.beginPath(); ctx.moveTo(bulbWidth / 2, -bulbHeight / 2); ctx.lineTo(NECK_WIDTH / 2, -neckHeight / 2); ctx.stroke(); // Bottom bulb outline ctx.beginPath(); ctx.moveTo(-NECK_WIDTH / 2, neckHeight / 2); ctx.lineTo(-bulbWidth / 2, bulbHeight / 2); ctx.stroke(); ctx.beginPath(); ctx.moveTo(NECK_WIDTH / 2, neckHeight / 2); ctx.lineTo(bulbWidth / 2, bulbHeight / 2); ctx.stroke(); // Top and bottom caps ctx.beginPath(); ctx.moveTo(-bulbWidth / 2 - 10, -bulbHeight / 2); ctx.lineTo(bulbWidth / 2 + 10, -bulbHeight / 2); ctx.stroke(); ctx.beginPath(); ctx.moveTo(-bulbWidth / 2 - 10, bulbHeight / 2); ctx.lineTo(bulbWidth / 2 + 10, bulbHeight / 2); ctx.stroke(); // Decorative wood grain ctx.strokeStyle = '#654321'; ctx.lineWidth = 2; for (let i = 0; i < 3; i++) { ctx.beginPath(); ctx.arc(0, -bulbHeight / 2, 15 + i * 5, 0, Math.PI, true); ctx.stroke(); ctx.beginPath(); ctx.arc(0, bulbHeight / 2, 15 + i * 5, 0, Math.PI); ctx.stroke(); } ctx.restore(); } // Animation loop function animate() { // Clear canvas ctx.fillStyle = '#f5f5f5'; ctx.fillRect(0, 0, canvas.width, canvas.height); // Update rotation if (isRotating) { const diff = targetRotation - rotation; rotation += diff * 0.05; if (Math.abs(diff) < 0.01) { rotation = targetRotation; isRotating = false; } } // Determine gravity direction const gravityDir = Math.cos(rotation) > 0 ? 1 : -1; // Update particles for (let i = 0; i < particles.length; i++) { particles[i].update(gravityDir); constrainParticle(particles[i], rotation); } // Collision detection (spatial optimization with grid) const iterations = 3; for (let iter = 0; iter < iterations; iter++) { handleParticleCollisions(); } // Draw particles for (let i = 0; i < particles.length; i++) { particles[i].draw(); } // Draw hourglass frame on top drawHourglass(); requestAnimationFrame(animate); } // Click handler canvas.addEventListener('click', () => { if (!isRotating) { targetRotation += Math.PI; isRotating = true; } }); // Initialize and start initParticles(); animate(); // Handle window resize window.addEventListener('resize', () => { const newScale = Math.min( (window.innerWidth * 0.95) / baseWidth, (window.innerHeight * 0.95) / baseHeight ); canvas.style.width = (baseWidth * newScale) + 'px'; canvas.style.height = (baseHeight * newScale) + 'px'; }); </script> </body> </html>*_
Updated files
v1