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.
<html> <head> <meta charset="UTF-8" > <title>Physics Hourglass Simulation</title> <style> * { box-sizing: border-box; margin: 0; padding: 0; } html, body { width: 100%; height: 100%; overflow: hidden; background: radial-gradient(circle at top, #202637 0%, #05060b 70%); color: #f5f5f5; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } body { display: flex; align-items: center; justify-content: center; } .app { width: min(90vw, 900px); height: min(90vh, 900px); display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 1.5vh; } h1 { font-size: clamp(1.6rem, 2.4vw, 2.4rem); letter-spacing: 0.08em; text-transform: uppercase; font-weight: 600; color: #f9fafb; text-align: center; } .subtitle { font-size: clamp(0.8rem, 1.2vw, 1rem); opacity: 0.75; text-align: center; max-width: 40rem; } .canvas-wrapper { position: relative; width: 100%; flex: 1; display: flex; align-items: center; justify-content: center; } canvas { width: 100%; height: 100%; max-height: 75vh; border-radius: 24px; background: radial-gradient(circle at 30% 0%, #28344a 0%, #05060b 70%); box-shadow: 0 30px 80px rgba(0,0,0,0.7), 0 0 0 1px rgba(255,255,255,0.05); cursor: pointer; touch-action: manipulation; } .hint { font-size: clamp(0.7rem, 1vw, 0.9rem); opacity: 0.7; text-align: center; letter-spacing: 0.12em; text-transform: uppercase; } </style> </head> <body> <div class="app"> <h1>Interactive Hourglass Simulation</h1> <p class="subtitle">Sand grains fall through a narrow neck and pile up into a cone. Click or tap the glass to flip it 180° and reverse the flow.</p> <div class="canvas-wrapper"> <canvas id="hourglass"></canvas> </div> <p class="hint">Click or tap anywhere on the glass to rotate</p> </div> <script> (function () { const canvas = document.getElementById("hourglass"); const ctx = canvas.getContext("2d"); // Responsive sizing function resize() { const rect = canvas.getBoundingClientRect(); const dpr = window.devicePixelRatio || 1; canvas.width = rect.width * dpr; canvas.height = rect.height * dpr; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); world.initGeometry(rect.width, rect.height); } window.addEventListener("resize", resize); // Basic world + granular "good-enough" physics const world = { w: 0, h: 0, particles: [], gravity: 1000, // px/s^2 damping: 0.35, neckWidth: 16, neckY: 0, neckX: 0, wallThickness: 8, bottleMargin: 50, topFullHeight: 0, bottomFullHeight: 0, sourceRegion: "top", // "top" or "bottom" where sand initially is rotation: 0, targetRotation: 0, rotationSpeed: Math.PI, // rad/s flipping: false, flipStartTime: 0, flipDuration: 0.9, maxParticles: 1200, spawnRate: 300, // per second lastSpawn: 0, boundaries: { topLeft: null, topRight: null, bottomLeft: null, bottomRight: null }, initGeometry(width, height) { this.w = width; this.h = height; const margin = this.bottleMargin; const waistWidth = this.neckWidth * 3; const topWidth = width * 0.45; const bottomWidth = topWidth; const midY = height * 0.5; const neckHeight = 30; this.neckX = width / 2; this.neckY = midY; this.boundaries.topLeft = { x1: width / 2 - waistWidth / 2, y1: midY - neckHeight, x2: width / 2 - topWidth / 2, y2: margin }; this.boundaries.topRight = { x1: width / 2 + waistWidth / 2, y1: midY - neckHeight, x2: width / 2 + topWidth / 2, y2: margin }; this.boundaries.bottomLeft = { x1: width / 2 - waistWidth / 2, y1: midY + neckHeight, x2: width / 2 - bottomWidth / 2, y2: height - margin }; this.boundaries.bottomRight = { x1: width / 2 + waistWidth / 2, y1: midY + neckHeight, x2: width / 2 + bottomWidth / 2, y2: height - margin }; this.topFullHeight = midY - neckHeight - margin; this.bottomFullHeight = height - margin - (midY + neckHeight); if (this.particles.length === 0) { this.seedParticles(); } }, seedParticles() { this.particles.length = 0; const rect = { w: this.w, h: this.h }; const count = Math.min(this.maxParticles, 900); for (let i = 0; i < count; i++) { const r = 2 + Math.random() * 1.5; const spread = (this.boundaries.topLeft.x2 - this.boundaries.topRight.x2) * 0.85 || this.w * 0.25; const baseX = this.w / 2; const x = baseX + (Math.random() - 0.5) * spread; const y = this.boundaries.topLeft.y2 + 5 + Math.random() * this.topFullHeight * 0.9; this.particles.push({ x, y, vx: 0, vy: 0, r, region: "top" // top/bottom depending on which half }); } this.sourceRegion = "top"; }, flip() { if (this.flipping) return; this.flipping = true; this.flipStartTime = performance.now() / 1000; this.targetRotation += Math.PI; // Swap which side is "source" and which is "sink" this.sourceRegion = this.sourceRegion === "top" ? "bottom" : "top"; // Reflect particle positions around center const cx = this.w / 2; const cy = this.h / 2; for (const p of this.particles) { const dx = p.x - cx; const dy = p.y - cy; p.x = cx - dx; p.y = cy - dy; p.vx = -p.vx; p.vy = -p.vy; p.region = p.region === "top" ? "bottom" : "top"; } }, update(dt, time) { // Rotate smoothly if (this.flipping) { const t = (time - this.flipStartTime) / this.flipDuration; if (t >= 1) { this.rotation = this.targetRotation; this.flipping = false; } else { const smooth = t * t * (3 - 2 * t); this.rotation = this.targetRotation - Math.PI * (1 - smooth); } } else { this.rotation = this.targetRotation; } // Spawn particles at source region neck this.spawnSand(dt, time); const g = this.gravity; const damping = this.damping; const neckHalf = this.neckWidth / 2; const neckY = this.neckY; const neckX = this.neckX; const neckEpsilon = 1.5; // Simple grid for local packing const gridSize = 8; const cols = Math.ceil(this.w / gridSize); const rows = Math.ceil(this.h / gridSize); const grid = new Array(cols * rows); for (let i = 0; i < grid.length; i++) grid[i] = []; function cellIndex(x, y) { const cx = Math.max(0, Math.min(cols - 1, (x / gridSize) | 0)); const cy = Math.max(0, Math.min(rows - 1, (y / gridSize) | 0)); return cy * cols + cx; } for (let i = 0; i < this.particles.length; i++) { const p = this.particles[i]; // Gravity (always "down" in screen space; we already flipped particle coordinates on rotation) p.vy += g * dt; // Integrate p.x += p.vx * dt; p.y += p.vy * dt; // Hourglass interior collision this.collideWalls(p); // Neck constraint with crude "flow limit" const dyToNeck = p.y - neckY; if (this.sourceRegion === "top" && p.region === "top") { // top is source, particles fall downward through neck if (dyToNeck > -10 && dyToNeck < 25 && Math.abs(p.x - neckX) < neckHalf - neckEpsilon) { // allow; once below neck center, region becomes bottom if (p.y > neckY + 8) p.region = "bottom"; } else if (Math.abs(p.x - neckX) < neckHalf && p.y > neckY - 8 && p.y < neckY + 10) { // subtle repel from neck boundary if (p.x < neckX) p.x = neckX - neckHalf - 0.5; else p.x = neckX + neckHalf + 0.5; p.vx *= -0.15; } } else if (this.sourceRegion === "bottom" && p.region === "bottom") { // bottom is source, particles fall upward in our coordinate system if (dyToNeck < 10 && dyToNeck > -25 && Math.abs(p.x - neckX) < neckHalf - neckEpsilon) { if (p.y < neckY - 8) p.region = "top"; } else if (Math.abs(p.x - neckX) < neckHalf && p.y < neckY + 8 && p.y > neckY - 10) { if (p.x < neckX) p.x = neckX - neckHalf - 0.5; else p.x = neckX + neckHalf + 0.5; p.vx *= -0.15; } } // Damper to keep things stable p.vx *= 1 - damping * dt * 0.5; p.vy *= 1 - damping * dt * 0.5; // Put in grid grid[cellIndex(p.x, p.y)].push(i); } // Particle–particle collisions (local, to encourage cone piling) const maxNeighbors = 8; const idealRestitution = 0.2; for (let i = 0; i < this.particles.length; i++) { const p = this.particles[i]; const cx = Math.max(0, Math.min(cols - 1, (p.x / gridSize) | 0)); const cy = Math.max(0, Math.min(rows - 1, (p.y / gridSize) | 0)); let count = 0; for (let gy = cy - 1; gy <= cy + 1; gy++) { if (gy < 0 || gy >= rows) continue; for (let gx = cx - 1; gx <= cx + 1; gx++) { if (gx < 0 || gx >= cols) continue; const cell = grid[gy * cols + gx]; for (let idx of cell) { if (idx <= i) continue; const q = this.particles[idx]; const dx = q.x - p.x; const dy = q.y - p.y; const distSq = dx * dx + dy * dy; const minDist = p.r + q.r; if (distSq > minDist * minDist || distSq === 0) continue; const dist = Math.sqrt(distSq); const overlap = (minDist - dist) * 0.5; // Push apart const nx = dx / dist; const ny = dy / dist; p.x -= nx * overlap; p.y -= ny * overlap; q.x += nx * overlap; q.y += ny * overlap; // Velocity along normal const relVx = q.vx - p.vx; const relVy = q.vy - p.vy; const relNormal = relVx * nx + relVy * ny; if (relNormal < 0) { const impulse = -(1 + idealRestitution) * relNormal * 0.5; p.vx -= impulse * nx; p.vy -= impulse * ny; q.vx += impulse * nx; q.vy += impulse * ny; } if (++count > maxNeighbors) break; } if (count > maxNeighbors) break; } if (count > maxNeighbors) break; } } }, collideWalls(p) { const b = this.boundaries; if (!b.topLeft) return; const margin = this.bottleMargin; const r = p.r; const w = this.w; const h = this.h; // Helper to push out of a line segment function collideLine(px, py, vx, vy, line, isTop) { const { x1, y1, x2, y2 } = line; const dx = x2 - x1; const dy = y2 - y1; const len = Math.sqrt(dx * dx + dy * dy); const nx = dy / len; // outward normal const ny = -dx / len; const tx = px - x1; const ty = py - y1; const dist = tx * nx + ty * ny; const proj = (tx * dx + ty * dy) / (len * len); if (proj < 0 || proj > 1) return { px, py, vx, vy }; const sideSign = isTop ? -1 : 1; if (dist * sideSign < r) { const pen = r - dist * sideSign; px += nx * pen * sideSign; py += ny * pen * sideSign; const vn = vx * nx + vy * ny; if (vn * sideSign < 0) { vx -= vn * nx * 1.4; vy -= vn * ny * 1.4; } } return { px, py, vx, vy }; } // Top chamber walls if (p.y < this.neckY) { let res = collideLine(p.x, p.y, p.vx, p.vy, b.topLeft, true); p.x = res.px; p.y = res.py; p.vx = res.vx; p.vy = res.vy; res = collideLine(p.x, p.y, p.vx, p.vy, b.topRight, true); p.x = res.px; p.y = res.py; p.vx = res.vx; p.vy = res.vy; } else { // Bottom chamber let res = collideLine(p.x, p.y, p.vx, p.vy, b.bottomLeft, false); p.x = res.px; p.y = res.py; p.vx = res.vx; p.vy = res.vy; res = collideLine(p.x, p.y, p.vx, p.vy, b.bottomRight, false); p.x = res.px; p.y = res.py; p.vx = res.vx; p.vy = res.vy; } // Floor & ceiling if (p.y > h - margin - r) { p.y = h - margin - r; if (p.vy > 0) p.vy *= -0.2; } if (p.y < margin + r) { p.y = margin + r; if (p.vy < 0) p.vy *= -0.2; } // Left/right glass boundaries const maxX = w - margin - r; const minX = margin + r; if (p.x < minX) { p.x = minX; if (p.vx < 0) p.vx *= -0.2; } if (p.x > maxX) { p.x = maxX; if (p.vx > 0) p.vx *= -0.2; } }, spawnSand(dt, time) { if (this.particles.length >= this.maxParticles) return; const desired = this.spawnRate; this.lastSpawn += dt * desired; const count = this.lastSpawn | 0; this.lastSpawn -= count; if (count <= 0) return; const neckX = this.neckX; const neckY = this.neckY; const neckHalf = this.neckWidth * 0.4; for (let i = 0; i < count; i++) { const r = 2 + Math.random() * 1.5; const x = neckX + (Math.random() - 0.5) * neckHalf; let y, region; if (this.sourceRegion === "top") { y = neckY - 8 - Math.random() * 6; region = "top"; } else { y = neckY + 8 + Math.random() * 6; region = "bottom"; } this.particles.push({ x, y, vx: (Math.random() - 0.5) * 30, vy: (this.sourceRegion === "top" ? 30 : -30), r, region }); if (this.particles.length >= this.maxParticles) break; } } }; function drawGlass() { const w = world.w; const h = world.h; const b = world.boundaries; if (!b.topLeft) return; ctx.save(); // Base shadows ctx.fillStyle = "rgba(0,0,0,0.45)"; const baseY = h - world.bottleMargin + 8; ctx.beginPath(); ctx.ellipse(w / 2, baseY, w * 0.18, 20, 0, 0, Math.PI * 2); ctx.fill(); // Glass path ctx.lineWidth = 3; ctx.strokeStyle = "rgba(230,241,255,0.82)"; ctx.beginPath(); ctx.moveTo(b.topLeft.x2, b.topLeft.y2); ctx.lineTo(b.topLeft.x1, b.topLeft.y1); ctx.lineTo(b.bottomLeft.x1, b.bottomLeft.y1); ctx.lineTo(b.bottomLeft.x2, b.bottomLeft.y2); ctx.moveTo(b.topRight.x2, b.topRight.y2); ctx.lineTo(b.topRight.x1, b.topRight.y1); ctx.lineTo(b.bottomRight.x1, b.bottomRight.y1); ctx.lineTo(b.bottomRight.x2, b.bottomRight.y2); ctx.stroke(); // Neck ctx.beginPath(); ctx.moveTo(b.topLeft.x1, b.topLeft.y1); ctx.lineTo(b.topRight.x1, b.topRight.y1); ctx.moveTo(b.bottomLeft.x1, b.bottomLeft.y1); ctx.lineTo(b.bottomRight.x1, b.bottomRight.y1); ctx.stroke(); // Glass highlights ctx.strokeStyle = "rgba(255,255,255,0.25)"; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(b.topLeft.x2 + 8, b.topLeft.y2 + 20); ctx.lineTo(b.topLeft.x1 + 4, b.topLeft.y1 + 10); ctx.lineTo(b.bottomLeft.x1 + 4, b.bottomLeft.y1 - 10); ctx.stroke(); ctx.beginPath(); ctx.moveTo(b.bottomRight.x2 - 8, b.bottomRight.y2 - 20); ctx.lineTo(b.bottomRight.x1 - 4, b.bottomRight.y1 - 10); ctx.stroke(); ctx.restore(); } function drawSand() { const particles = world.particles; if (!particles.length) return; // Draw as gradiented disks for (let i = 0; i < particles.length; i++) { const p = particles[i]; const r = p.r; const gradient = ctx.createRadialGradient( p.x - r * 0.5, p.y - r * 0.7, r * 0.1, p.x, p.y, r ); gradient.addColorStop(0, "rgba(255,255,255,0.95)"); gradient.addColorStop(0.4, "rgba(241,220,170,0.95)"); gradient.addColorStop(1, "rgba(180,150,90,0.95)"); ctx.fillStyle = gradient; ctx.beginPath(); ctx.arc(p.x, p.y, r, 0, Math.PI * 2); ctx.fill(); } // Soft pile glow to emphasize cone ctx.save(); const w = world.w; const h = world.h; const g = ctx.createRadialGradient( w / 2, h * 0.78, h * 0.02, w / 2, h * 0.9, h * 0.12 ); g.addColorStop(0, "rgba(250,230,190,0.18)"); g.addColorStop(1, "rgba(0,0,0,0)"); ctx.fillStyle = g; ctx.beginPath(); ctx.ellipse(w / 2, h * 0.82, w * 0.22, h * 0.18, 0, 0, Math.PI * 2); ctx.fill(); ctx.restore(); } let lastTime = performance.now() / 1000; function loop() { const now = performance.now() / 1000; let dt = now - lastTime; lastTime = now; // Clamp dt to avoid instability dt = Math.min(dt, 0.033); world.update(dt, now); // Clear ctx.clearRect(0, 0, canvas.width, canvas.height); // Apply rotation around center for visual effect ctx.save(); const w = world.w; const h = world.h; ctx.translate(w / 2, h / 2); ctx.rotate(world.rotation); ctx.translate(-w / 2, -h / 2); // Background glow const bgGrad = ctx.createRadialGradient( w / 2, h * 0.25, 20, w / 2, h * 0.4, h * 0.6 ); bgGrad.addColorStop(0, "rgba(120,150,255,0.18)"); bgGrad.addColorStop(1, "rgba(0,0,10,0.9)"); ctx.fillStyle = bgGrad; ctx.fillRect(0, 0, w, h); drawGlass(); drawSand(); ctx.restore(); requestAnimationFrame(loop); } canvas.addEventListener("click", () => { world.flip(); }); canvas.addEventListener("touchstart", (e) => { e.preventDefault(); world.flip(); }, { passive: false }); resize(); lastTime = performance.now() / 1000; requestAnimationFrame(loop); })(); </script> </body> </html>
Updated files
v1