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> html, body { margin: 0; padding: 0; width: 100%; height: 100%; background: radial-gradient(circle at top, #151b26 0%, #05070b 55%, #010103 100%); font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #f4f7ff; overflow: hidden; }
body {
    display: flex;
    align-items: center;
    justify-content: center;
}

.container {
    width: min(90vw, 1200px);
    height: min(90vh, 800px);
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    position: relative;
}

h1 {
    position: absolute;
    top: 4vh;
    left: 50%;
    transform: translateX(-50%);
    font-size: clamp(2rem, 3vw, 3rem);
    letter-spacing: 0.15em;
    text-transform: uppercase;
    color: #e9edf9;
    text-shadow: 0 0 20px rgba(0,0,0,0.6);
    pointer-events: none;
}

.subtitle {
    position: absolute;
    top: 10vh;
    left: 50%;
    transform: translateX(-50%);
    font-size: clamp(0.8rem, 1vw, 1rem);
    opacity: 0.7;
    letter-spacing: 0.2em;
    text-transform: uppercase;
    pointer-events: none;
}

.hourglass-wrapper {
    width: 100%;
    height: 100%;
    max-width: 900px;
    max-height: 640px;
    display: flex;
    align-items: center;
    justify-content: center;
    transform-style: preserve-3d;
    perspective: 2000px;
}

canvas {
    width: 100%;
    height: 100%;
    max-width: 700px;
    max-height: 700px;
    background: transparent;
    cursor: pointer;
    border-radius: 24px;
    box-shadow:
        0 40px 120px rgba(0,0,0,0.7),
        0 0 0 1px rgba(255,255,255,0.04),
        inset 0 0 80px rgba(255,255,255,0.03);
    transition: transform 0.8s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.4s ease;
}

canvas:hover {
    transform: translateY(-6px) scale(1.01);
    box-shadow:
        0 50px 140px rgba(0,0,0,0.8),
        0 0 0 1px rgba(255,255,255,0.06),
        inset 0 0 90px rgba(255,255,255,0.04);
}

.hint {
    position: absolute;
    bottom: 5vh;
    left: 50%;
    transform: translateX(-50%);
    font-size: clamp(0.7rem, 0.9vw, 0.9rem);
    letter-spacing: 0.18em;
    text-transform: uppercase;
    opacity: 0.7;
    display: flex;
    align-items: center;
    gap: 10px;
    pointer-events: none;
}

.hint-dot {
    width: 8px;
    height: 8px;
    border-radius: 50%;
    background: #ffd166;
    box-shadow: 0 0 12px rgba(255,209,102,0.9);
}

@media (max-width: 768px) {
    h1 {
        top: 2vh;
    }
    .subtitle {
        top: 7vh;
    }
    .hint {
        bottom: 3vh;
    }
}
</style> </head> <body> <div class="container"> <h1>Granular Hourglass</h1> <div class="subtitle">Click to flip • Physics-inspired sand flow</div> <div class="hourglass-wrapper"> <canvas id="hourglass"></canvas> </div> <div class="hint"> <div class="hint-dot"></div> Click anywhere on the hourglass to rotate 180° </div> </div> <script> (function() { const canvas = document.getElementById('hourglass'); const ctx = canvas.getContext('2d'); // Handle retina and 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); initGeometry(); } let w, h; let particles = []; let lastTime = 0; let isFlipping = false; let flipStart = 0; const flipDuration = 800; // ms let angle = 0; // 0 or PI; during animation: 0..PI let worldUp = { x: 0, y: 1 }; // gravity direction in world space (downwards when angle=0) let neckX, neckY; let upperChamber, lowerChamber; let neckRadius, outerPadding; let maxParticles = 900; // adaptive later let baseFriction = 0.94; let wallBounce = -0.3; function initGeometry() { w = canvas.width / (window.devicePixelRatio || 1); h = canvas.height / (window.devicePixelRatio || 1); outerPadding = Math.min(w, h) * 0.08; const bodyTop = outerPadding * 1.8; const bodyBottom = h - outerPadding * 1.4; const bodyHeight = bodyBottom - bodyTop; const neckYLocal = bodyTop + bodyHeight * 0.5; neckY = neckYLocal; neckX = w * 0.5; neckRadius = Math.min(w, h) * 0.012; upperChamber = { top: bodyTop, bottom: neckYLocal - neckRadius * 1.4, minWidth: w * 0.08, maxWidth: w * 0.36 }; lowerChamber = { top: neckYLocal + neckRadius * 1.4, bottom: bodyBottom, minWidth: w * 0.08, maxWidth: w * 0.36 }; // Reinitialize particles proportionally when resized const target = Math.round(maxParticles); if (particles.length === 0) { initParticles(target); } else { if (particles.length > target) { particles.length = target; } else { while (particles.length < target) { particles.push(makeParticle(true)); } } } } function hourglassWidthAtY(y) { // Symmetric logistic-like bulge: narrowest at neck, wider toward ends const cy = neckY; const dy = Math.abs(y - cy); const maxDy = Math.max(neckY - upperChamber.top, lowerChamber.bottom - neckY); const t = dy / maxDy; // 0..1 const baseWidth = w * 0.11; // near neck const extra = w * 0.23 * Math.pow(t, 0.8); return baseWidth + extra; } function hourglassBoundsAtY(y) { const hw = hourglassWidthAtY(y) * 0.5; return { left: neckX - hw, right: neckX + hw }; } function inUpperChamber(y, gDirY) { // chamber whose "down" direction is worldUp rotated by angle // use local sense: upper chamber is where sand moves toward neck from "top" // Simplify: if angle ~0, upper is geometric top; if angle ~PI, inverted. const localAngle = angle % (2 * Math.PI); const inverted = localAngle > Math.PI * 0.5 && localAngle < Math.PI * 1.5; if (!inverted) { return y < neckY; } else { return y > neckY; } } function makeParticle(inUpper) { // Start inside one chamber, distributed by height const margin = neckRadius * 1.6; let yRangeTop, yRangeBottom; if (inUpper) { yRangeTop = upperChamber.top + margin; yRangeBottom = neckY - margin * 1.5; } else { yRangeTop = neckY + margin * 1.5; yRangeBottom = lowerChamber.bottom - margin; } const y = yRangeTop + Math.random() * (yRangeBottom - yRangeTop); const bounds = hourglassBoundsAtY(y); const x = bounds.left + 0.1 * (bounds.right - bounds.left) + Math.random() * 0.8 * (bounds.right - bounds.left); return { x, y, vx: 0, vy: 0, radius: Math.min(w, h) * (0.0045 + Math.random() * 0.0015), settled: false, upper: inUpper }; } function initParticles(n) { particles = []; for (let i = 0; i < n; i++) { particles.push(makeParticle(true)); // start mostly in "upper" chamber } } function rotateVector(vx, vy, ang) { const ca = Math.cos(ang), sa = Math.sin(ang); return { x: vx * ca - vy * sa, y: vx * sa + vy * ca }; } function clamp(v, min, max) { return v < min ? min : v > max ? max : v; } function update(t) { if (!lastTime) lastTime = t; const dt = Math.min(0.035, (t - lastTime) / 1000); lastTime = t; // Flip animation if (isFlipping) { const elapsed = t - flipStart; let k = clamp(elapsed / flipDuration, 0, 1); // smooth: ease in-out k = k * k * (3 - 2 * k); angle = Math.PI * k; if (elapsed >= flipDuration) { isFlipping = false; angle = Math.PI; // finalize new orientation by swapping "upper" labels particles.forEach(p => { p.upper = !p.upper; p.settled = false; p.vx *= 0.1; p.vy *= 0.1; }); worldUp = { x: 0, y: 1 }; angle = 0; // keep scene visually same, but logical "upper" toggled } } // Gravity always downward in screen space; "upper" vs "lower" handled logically const g = 800; // px/s^2 const gx = 0; const gy = g; // Some simple capacity logic: slow when near-empty const upperCount = particles.filter(p => p.upper).length; const lowerCount = particles.length - upperCount; const flowFactor = clamp(upperCount / (particles.length * 0.9), 0.3, 1); // Physics step: integrate, collisions, boundaries granularStep(dt, gx, gy, flowFactor, upperCount, lowerCount); draw(); requestAnimationFrame(update); } function granularStep(dt, gx, gy, flowFactor, upperCount, lowerCount) { // Integration for (let i = 0; i < particles.length; i++) { const p = particles[i]; // Apply gravity only if not completely jammed in neck. p.vx += gx * dt; p.vy += gy * dt; // Add slight random horizontal jitter when near neck to mimic granular flow const distToNeckY = Math.abs(p.y - neckY); if (distToNeckY < 40) { p.vx += (Math.random() - 0.5) * 20 * dt; } // Apply damping p.vx *= baseFriction; p.vy *= baseFriction; p.x += p.vx * dt; p.y += p.vy * dt; } // Boundary collisions and neck logic for (let i = 0; i < particles.length; i++) { const p = particles[i]; // Hourglass body bounds at this y const b = hourglassBoundsAtY(p.y); const left = b.left + p.radius * 0.7; const right = b.right - p.radius * 0.7; // Left/right wall if (p.x < left) { p.x = left; if (p.vx < 0) p.vx *= wallBounce; } else if (p.x > right) { p.x = right; if (p.vx > 0) p.vx *= wallBounce; } // Top and bottom if (p.y - p.radius < upperChamber.top) { p.y = upperChamber.top + p.radius; if (p.vy < 0) p.vy *= wallBounce; } if (p.y + p.radius > lowerChamber.bottom) { p.y = lowerChamber.bottom - p.radius; if (p.vy > 0) p.vy *= wallBounce * 0.3; } // Neck passage: allow only a limited number and require alignment const neckHalf = neckRadius * 0.9; const nearNeck = Math.abs(p.y - neckY) < neckRadius * 4; if (nearNeck) { const inVertical = p.x > neckX - neckHalf && p.x < neckX + neckHalf; const goingDown = (p.upper && p.y < neckY && p.vy > 0) || (!p.upper && p.y > neckY && p.vy < 0); if (!inVertical && Math.abs(p.y - neckY) < neckRadius * 1.4) { // Push back to side to simulate jamming around neck if (p.x < neckX) { p.x = neckX - neckHalf - p.radius * 0.4; p.vx += -30 * dt; } else { p.x = neckX + neckHalf + p.radius * 0.4; p.vx += 30 * dt; } p.vy *= 0.4; } if (inVertical && goingDown) { // Only some particles can pass at a time const speedThresh = 20 * flowFactor; if (Math.abs(p.vy) < speedThresh) { // nudge p.vy += (p.upper ? 1 : -1) * 200 * dt * flowFactor; } } else if (Math.abs(p.y - neckY) < neckRadius) { // Bounce off the neck plane if trying to cross the wrong way if (p.upper && p.y > neckY - p.radius && p.vy > 0) { p.y = neckY - p.radius; p.vy *= -0.2; } else if (!p.upper && p.y < neckY + p.radius && p.vy < 0) { p.y = neckY + p.radius; p.vy *= -0.2; } } // Transfer between chambers when crossing neck if (p.upper && p.y > neckY + p.radius * 1.2 && inVertical) { p.upper = false; p.settled = false; } else if (!p.upper && p.y < neckY - p.radius * 1.2 && inVertical) { p.upper = true; p.settled = false; } } } // Simple particle–particle collision and cone-like settling heuristic const restitution = 0.05; const staticFriction = 0.6; for (let i = 0; i < particles.length; i++) { const p1 = particles[i]; for (let j = i + 1; j < particles.length; j++) { const p2 = particles[j]; const dx = p2.x - p1.x; const dy = p2.y - p1.y; const dist = Math.sqrt(dx * dx + dy * dy) || 0.0001; const minDist = p1.radius + p2.radius; if (dist < minDist * 0.98) { const overlap = (minDist - dist) * 0.5; const nx = dx / dist; const ny = dy / dist; p1.x -= nx * overlap; p1.y -= ny * overlap; p2.x += nx * overlap; p2.y += ny * overlap; const rvx = p2.vx - p1.vx; const rvy = p2.vy - p1.vy; const vn = rvx * nx + rvy * ny; if (vn < 0) { const impulse = -(1 + restitution) * vn * 0.5; const ix = impulse * nx; const iy = impulse * ny; p1.vx -= ix; p1.vy -= iy; p2.vx += ix; p2.vy += iy; } // Tangential friction to promote conical pile const vtX = rvx - vn * nx; const vtY = rvy - vn * ny; const vt = Math.sqrt(vtX * vtX + vtY * vtY); if (vt > 0.0001) { const fx = (vtX / vt) * staticFriction * 4; const fy = (vtY / vt) * staticFriction * 4; p1.vx += fx * dt; p2.vx -= fx * dt; p1.vy += fy * dt; p2.vy -= fy * dt; } // Mark as settled if movement is very small and near bottom pile const bottom = p1.upper ? upperChamber.top : lowerChamber.bottom; if (Math.abs(p1.vx) + Math.abs(p1.vy) < 10 && Math.abs(p1.y - bottom) < (lowerChamber.bottom - neckY) * 0.8) { p1.settled = true; } if (Math.abs(p2.vx) + Math.abs(p2.vy) < 10 && Math.abs(p2.y - bottom) < (lowerChamber.bottom - neckY) * 0.8) { p2.settled = true; } } } } // Artificial settling slope at bottom of each chamber to form cone applyConeConstraint(true); applyConeConstraint(false); } function applyConeConstraint(upperFlag) { const bottomY = upperFlag ? upperChamber.top : lowerChamber.bottom; const neckToBottom = Math.abs(bottomY - neckY); const coneAngle = 0.9; // radians ~ 50° const maxSlope = Math.tan(coneAngle); const sign = upperFlag ? -1 : 1; // cone apex at neck, open towards bottom for (let i = 0; i < particles.length; i++) { const p = particles[i]; if (p.upper !== upperFlag) continue; const dy = (bottomY - p.y) * sign; if (dy < 0) continue; const idealHalfWidth = dy * maxSlope; const allowed = hourglassWidthAtY(p.y) * 0.5; const effective = Math.min(idealHalfWidth, allowed * 0.96); const dx = p.x - neckX; if (Math.abs(dx) > effective && dy > neckToBottom * 0.05) { const targetX = neckX + Math.sign(dx) * effective; p.x += (targetX - p.x) * 0.25; p.vx *= 0.2; p.vy *= 0.6; p.settled = true; } } } function drawGlass() { const bodyTop = upperChamber.top; const bodyBottom = lowerChamber.bottom; // Back glow const gradient = ctx.createRadialGradient( w * 0.5, h * 0.2, Math.min(w, h) * 0.05, w * 0.5, h * 0.5, Math.max(w, h) * 0.6 ); gradient.addColorStop(0, 'rgba(255,255,255,0.07)'); gradient.addColorStop(0.45, 'rgba(81,117,197,0.06)'); gradient.addColorStop(1, 'rgba(0,0,0,0.95)'); ctx.fillStyle = gradient; ctx.fillRect(0, 0, w, h); // Wooden/metal base and top const baseHeight = outerPadding * 0.7; const baseGradient = ctx.createLinearGradient(0, h - baseHeight, 0, h); baseGradient.addColorStop(0, '#15141d'); baseGradient.addColorStop(0.5, '#0d0c12'); baseGradient.addColorStop(1, '#05040a'); ctx.fillStyle = baseGradient; ctx.fillRect(w * 0.18, h - baseHeight, w * 0.64, baseHeight); const topGradient = ctx.createLinearGradient(0, 0, 0, bodyTop); topGradient.addColorStop(0, '#0c0c15'); topGradient.addColorStop(1, '#171623'); ctx.fillStyle = topGradient; ctx.fillRect(w * 0.2, 0, w * 0.6, bodyTop * 0.8); // Glass shape path ctx.save(); ctx.lineWidth = Math.min(w, h) * 0.015; ctx.strokeStyle = 'rgba(210,220,255,0.9)'; ctx.fillStyle = 'rgba(12,18,34,0.85)'; ctx.beginPath(); const steps = 80; // Left side top -> neck for (let i = 0; i <= steps; i++) { const t = i / steps; const y = bodyTop + (neckY - bodyTop) * t; const b = hourglassBoundsAtY(y); const x = b.left; if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); } // Left side neck -> bottom for (let i = 0; i <= steps; i++) { const t = i / steps; const y = neckY + (bodyBottom - neckY) * t; const b = hourglassBoundsAtY(y); const x = b.left; ctx.lineTo(x, y); } // Right side bottom -> neck for (let i = steps; i >= 0; i--) { const t = i / steps; const y = neckY + (bodyBottom - neckY) * t; const b = hourglassBoundsAtY(y); const x = b.right; ctx.lineTo(x, y); } // Right side neck -> top for (let i = steps; i >= 0; i--) { const t = i / steps; const y = bodyTop + (neckY - bodyTop) * t; const b = hourglassBoundsAtY(y); const x = b.right; ctx.lineTo(x, y); } ctx.closePath(); ctx.fill(); // Glass edge highlight ctx.stroke(); // Neck ring ctx.beginPath(); ctx.strokeStyle = 'rgba(244,249,255,0.6)'; ctx.lineWidth = Math.min(w, h) * 0.006; ctx.ellipse(neckX, neckY, neckRadius * 2.4, neckRadius * 0.8, 0, 0, Math.PI * 2); ctx.stroke(); // Inner line at neck ctx.beginPath(); ctx.strokeStyle = 'rgba(255,255,255,0.18)'; ctx.lineWidth = Math.min(w, h) * 0.0025; ctx.moveTo(neckX - neckRadius * 2.1, neckY); ctx.lineTo(neckX + neckRadius * 2.1, neckY); ctx.stroke(); // Inner glass reflections ctx.globalAlpha = 0.18; ctx.beginPath(); ctx.moveTo(neckX - hourglassWidthAtY(bodyTop) * 0.36, bodyTop + (bodyBottom - bodyTop) * 0.1); ctx.quadraticCurveTo( neckX - hourglassWidthAtY(neckY) * 0.8, (bodyTop + bodyBottom) * 0.5, neckX - hourglassWidthAtY(bodyBottom) * 0.18, bodyBottom - (bodyBottom - bodyTop) * 0.25 ); ctx.strokeStyle = '#ffffff'; ctx.lineWidth = Math.min(w, h) * 0.007; ctx.stroke(); ctx.globalAlpha = 0.08; ctx.beginPath(); ctx.moveTo(neckX + hourglassWidthAtY(bodyTop) * 0.22, bodyTop + (bodyBottom - bodyTop) * 0.15); ctx.quadraticCurveTo( neckX + hourglassWidthAtY(neckY) * 0.4, (bodyTop + bodyBottom) * 0.55, neckX + hourglassWidthAtY(bodyBottom) * 0.1, bodyBottom - (bodyBottom - bodyTop) * 0.28 ); ctx.lineWidth = Math.min(w, h) * 0.01; ctx.stroke(); ctx.globalAlpha = 1; ctx.restore(); } function drawSand() { // Draw bottom and top piles with mild depth effect const gradYellow = ctx.createLinearGradient(0, upperChamber.top, 0, lowerChamber.bottom); gradYellow.addColorStop(0, '#ffe9a4'); gradYellow.addColorStop(0.45, '#ffd166'); gradYellow.addColorStop(1, '#e6a548'); const baseColorTop = '#ffecb8'; const baseColorBottom = '#f1b455'; for (let pass = 0; pass < 2; pass++) { const depthAlpha = pass === 0 ? 0.4 : 0.9; for (let i = 0; i < particles.length; i++) { const p = particles[i]; const localAlpha = depthAlpha * (0.4 + 0.6 * (p.settled ? 1 : 0.5)); ctx.beginPath(); ctx.fillStyle = pass === 0 ? `rgba(255,220,150,${localAlpha * 0.35})` : (p.upper ? baseColorTop : baseColorBottom); ctx.globalAlpha = localAlpha; ctx.arc(p.x, p.y, p.radius, 0, Math.PI * 2); ctx.fill(); ctx.globalAlpha = 1; } } // Falling stream emphasis near neck const streamRadius = neckRadius * 0.6; ctx.save(); const gradStream = ctx.createRadialGradient(neckX, neckY + neckRadius * 2.2, 1, neckX, neckY + neckRadius * 2.2, neckRadius * 4); gradStream.addColorStop(0, 'rgba(255,235,180,0.9)'); gradStream.addColorStop(0.3, 'rgba(255,219,135,0.7)'); gradStream.addColorStop(1, 'rgba(255,219,135,0)'); ctx.fillStyle = gradStream; ctx.beginPath(); ctx.ellipse(neckX, neckY + neckRadius * 2.4, streamRadius, neckRadius * 4, 0, 0, Math.PI * 2); ctx.fill(); ctx.restore(); } function draw() { ctx.clearRect(0, 0, w, h); drawGlass(); drawSand(); // Small glint at neck ctx.save(); ctx.globalAlpha = 0.7; const glintGrad = ctx.createRadialGradient( neckX - neckRadius * 1.2, neckY - neckRadius * 0.4, 1, neckX - neckRadius * 1.2, neckY - neckRadius * 0.4, neckRadius * 4 ); glintGrad.addColorStop(0, 'rgba(255,255,255,0.85)'); glintGrad.addColorStop(0.25, 'rgba(255,255,255,0.3)'); glintGrad.addColorStop(1, 'rgba(255,255,255,0)'); ctx.fillStyle = glintGrad; ctx.beginPath(); ctx.ellipse( neckX - neckRadius * 1.3, neckY - neckRadius * 0.4, neckRadius * 2.2, neckRadius * 1.8, -0.3, 0, Math.PI * 2 ); ctx.fill(); ctx.restore(); } function startFlip() { if (isFlipping) return; isFlipping = true; flipStart = performance.now(); } canvas.addEventListener('click', startFlip); window.addEventListener('resize', resize); resize(); requestAnimationFrame(update); })(); </script> </body> </html>
Updated files
v1