<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>