<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Physics Hourglass</title>
<style>
body {
margin: 0;
padding: 0;
width: 100vw;
height: 100vh;
background-color: #1a1a1d;
color: #e0e0e0;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
overflow: hidden;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
#container {
position: relative;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
canvas {
background: transparent;
cursor: pointer;
/* Ensure it fits but maintains aspect */
max-width: 90vw;
max-height: 90vh;
touch-action: none;
}
.ui-layer {
position: absolute;
top: 5%;
text-align: center;
pointer-events: none;
z-index: 10;
opacity: 0.8;
transition: opacity 1s ease;
}
h1 {
font-size: 1.5rem;
margin: 0 0 0.5rem 0;
font-weight: 300;
letter-spacing: 2px;
text-transform: uppercase;
color: #d4af37;
}
p {
font-size: 0.9rem;
color: #888;
}
.instruction {
margin-top: 8px;
font-size: 0.8rem;
color: #aaa;
background: rgba(0,0,0,0.3);
padding: 6px 12px;
border-radius: 20px;
display: inline-block;
}
</style>
#container {
position: relative;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
canvas {
background: transparent;
cursor: pointer;
/* Ensure it fits but maintains aspect */
max-width: 90vw;
max-height: 90vh;
touch-action: none;
}
.ui-layer {
position: absolute;
top: 5%;
text-align: center;
pointer-events: none;
z-index: 10;
opacity: 0.8;
transition: opacity 1s ease;
}
h1 {
font-size: 1.5rem;
margin: 0 0 0.5rem 0;
font-weight: 300;
letter-spacing: 2px;
text-transform: uppercase;
color: #d4af37;
}
p {
font-size: 0.9rem;
color: #888;
}
.instruction {
margin-top: 8px;
font-size: 0.8rem;
color: #aaa;
background: rgba(0,0,0,0.3);
padding: 6px 12px;
border-radius: 20px;
display: inline-block;
}
</style>
</head>
<body>
<div class="ui-layer" id="ui">
<h1>Hourglass</h1>
<div class="instruction">Click anywhere to rotate</div>
</div>
<div id="container">
<canvas id="simCanvas"></canvas>
</div>
<div class="ui-layer" id="ui">
<h1>Hourglass</h1>
<div class="instruction">Click anywhere to rotate</div>
</div>
<div id="container">
<canvas id="simCanvas"></canvas>
</div>
<script>
/**
* Physics Configuration
*/
const CONFIG = {
particleCount: 900,
particleRadius: 3.5,
gravity: 0.15,
friction: 0.99, // Velocity damping
wallFriction: 0.8,
subSteps: 8, // Physics iterations per frame for stability
glassWidthBase: 15, // Neck width
glassWidthMax: 130, // Bulb width
glassHeight: 180, // Half-height of the glass
rotationSpeed: 0.05 // Lerp speed
};
// Setup Canvas
const canvas = document.getElementById('simCanvas');
const ctx = canvas.getContext('2d', { alpha: true });
let width, height, cx, cy;
let currentRotation = 0;
let targetRotation = 0;
// Particle System
class Particle {
constructor(x, y) {
this.x = x;
this.y = y;
this.oldx = x;
this.oldy = y;
this.r = CONFIG.particleRadius;
// Give slight color variation
const hue = 35 + Math.random() * 10;
const sat = 70 + Math.random() * 20;
const lig = 50 + Math.random() * 20;
this.color = `hsl(${hue}, ${sat}%, ${lig}%)`;
}
update() {
const vx = (this.x - this.oldx) * CONFIG.friction;
const vy = (this.y - this.oldy) * CONFIG.friction;
this.oldx = this.x;
this.oldy = this.y;
this.x += vx;
this.y += vy;
this.y += CONFIG.gravity;
}
}
let particles = [];
// Spatial Hash Grid
let grid = {};
const cellSize = CONFIG.particleRadius * 2.2; // Slightly larger than diameter
function resize() {
// Design reference size
const refWidth = 1512;
const refHeight = 982;
// Determine scale
width = window.innerWidth;
height = window.innerHeight;
canvas.width = width;
canvas.height = height;
cx = width / 2;
cy = height / 2;
// If first run, init particles
if (particles.length === 0) initParticles();
}
function initParticles() {
particles = [];
const startY = -CONFIG.glassHeight + 20;
// Pack particles into the top bulb
let pCount = 0;
let py = startY;
while (pCount < CONFIG.particleCount) {
// Calculate width at this height to ensure we spawn inside
const allowedWidth = getGlassWidth(py) - 10;
const startX = -allowedWidth;
const endX = allowedWidth;
for (let px = startX; px <= endX; px += CONFIG.particleRadius * 2.2) {
// Random jitter to prevent perfect lattice stacking (looks unnatural)
const jx = px + (Math.random() - 0.5) * 2;
const jy = py + (Math.random() - 0.5) * 2;
// Initial rotation is 0, so world cords match local cords
// Transform local (0, y) to screen space (cx, cy + y)
// But we store simulation coordinates relative to center (0,0) in physics
particles.push(new Particle(jx, jy));
pCount++;
if (pCount >= CONFIG.particleCount) break;
}
py += CONFIG.particleRadius * 2.2;
// Safety break
if (py > CONFIG.glassHeight) break;
}
}
/**
* Mathematical definition of the Hourglass walls
* Returns the half-width (radius) of the glass at a given local Y
*/
function getGlassWidth(localY) {
const y = Math.abs(localY);
if (y > CONFIG.glassHeight) return 0; // Cap ends
// Shape function: A neck connected to round bulbs
// Using a power curve for the neck and a circle/ellipse for the body
// Normalized height 0 to 1
const h = y / CONFIG.glassHeight;
// Width equation: Base + Curve
// Using Cosine for smooth S-curve shape
// 1.0 at ends, 0.0 at center
const curve = 1 - Math.cos(h * Math.PI / 2);
// Simple interpolated shape
// x = neck + (max - neck) * curve^power
return CONFIG.glassWidthBase + (CONFIG.glassWidthMax - CONFIG.glassWidthBase) * Math.pow(h, 2.5);
}
/**
* Constraints & Collision Logic
*/
// 1. Rotate particle into local space of the hourglass
// 2. Check bounds
// 3. Push out if needed
// 4. Rotate back to world space
function constrainParticles() {
const cos = Math.cos(-currentRotation);
const sin = Math.sin(-currentRotation);
for (let p of particles) {
// Transform World -> Local
// We assume the hourglass center is (0,0) in world simulation space
let lx = p.x * cos - p.y * sin;
let ly = p.x * sin + p.y * cos;
// 1. Boundary Check (Walls)
// Cap Top/Bottom
const floorLimit = CONFIG.glassHeight - p.r;
// Check Y bounds (caps)
if (ly > floorLimit) {
ly = floorLimit;
// Simple friction on cap
// Transform velocity logic would go here, but Verlet handles it via position adjust
} else if (ly < -floorLimit) {
ly = -floorLimit;
}
// Check X bounds (Side walls)
const maxW = getGlassWidth(ly) - p.r;
if (lx > maxW) {
lx = maxW;
} else if (lx < -maxW) {
lx = -maxW;
}
// Transform Local -> World
// Use inverse rotation (positive currentRotation)
const iCos = Math.cos(currentRotation);
const iSin = Math.sin(currentRotation);
p.x = lx * iCos - ly * iSin;
p.y = lx * iSin + ly * iCos;
}
}
// Spatial Hash for Circle Collisions
function solveCollisions() {
grid = {};
// Populate Grid
for (let p of particles) {
// Hash key: "x:y"
// We use Math.floor to bucket them
const gx = Math.floor(p.x / cellSize);
const gy = Math.floor(p.y / cellSize);
const key = `${gx}:${gy}`;
if (!grid[key]) grid[key] = [];
grid[key].push(p);
}
// Check Collisions
for (let p of particles) {
const gx = Math.floor(p.x / cellSize);
const gy = Math.floor(p.y / cellSize);
// Check surrounding cells (3x3 grid)
for (let x = -1; x <= 1; x++) {
for (let y = -1; y <= 1; y++) {
const key = `${gx+x}:${gy+y}`;
const cell = grid[key];
if (cell) {
for (let other of cell) {
if (p === other) continue;
const dx = p.x - other.x;
const dy = p.y - other.y;
const distSq = dx*dx + dy*dy;
const minDist = p.r + other.r;
if (distSq < minDist * minDist && distSq > 0) {
const dist = Math.sqrt(distSq);
const overlap = minDist - dist;
// Push apart relative to mass (equal mass here)
const nx = dx / dist;
const ny = dy / dist;
const separateX = nx * overlap * 0.5;
const separateY = ny * overlap * 0.5;
p.x += separateX;
p.y += separateY;
other.x -= separateX;
other.y -= separateY;
}
}
}
}
}
}
}
/**
* Rendering
*/
function drawHourglass() {
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(currentRotation);
// Styling the glass
ctx.strokeStyle = "rgba(255, 255, 255, 0.4)";
ctx.lineWidth = 4;
ctx.lineCap = "round";
ctx.lineJoin = "round";
// Create path
ctx.beginPath();
// Resolution of drawing
const steps = 40;
// Right Side
for (let i = 0; i <= steps; i++) {
const t = i / steps; // 0 to 1
// Map t to y range [-glassHeight, glassHeight]
const y = -CONFIG.glassHeight + t * (CONFIG.glassHeight * 2);
const w = getGlassWidth(y);
if (i === 0) ctx.moveTo(w, y);
else ctx.lineTo(w, y);
}
// Bottom Cap (draw across)
const wBottom = getGlassWidth(CONFIG.glassHeight);
ctx.lineTo(-wBottom, CONFIG.glassHeight);
// Left Side (upwards)
for (let i = steps; i >= 0; i--) {
const t = i / steps;
const y = -CONFIG.glassHeight + t * (CONFIG.glassHeight * 2);
const w = getGlassWidth(y);
ctx.lineTo(-w, y);
}
// Top Cap
const wTop = getGlassWidth(-CONFIG.glassHeight);
ctx.lineTo(wTop, -CONFIG.glassHeight);
// Fill Glass Background
ctx.fillStyle = "rgba(255, 255, 255, 0.05)";
ctx.fill();
ctx.stroke();
// Add a shiny reflection
ctx.beginPath();
ctx.strokeStyle = "rgba(255, 255, 255, 0.1)";
ctx.lineWidth = 2;
ctx.moveTo(CONFIG.glassWidthMax * 0.5, -CONFIG.glassHeight * 0.5);
ctx.quadraticCurveTo(CONFIG.glassWidthBase + 10, 0, CONFIG.glassWidthMax * 0.5, CONFIG.glassHeight * 0.5);
ctx.stroke();
ctx.restore();
}
function drawParticles() {
ctx.save();
ctx.translate(cx, cy);
for (let p of particles) {
ctx.beginPath();
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
ctx.fillStyle = p.color;
ctx.fill();
}
ctx.restore();
}
/**
* Main Loop
*/
function loop() {
// Handle Rotation Animation
// Smooth ease towards target
const diff = targetRotation - currentRotation;
if (Math.abs(diff) > 0.001) {
currentRotation += diff * CONFIG.rotationSpeed;
} else {
currentRotation = targetRotation;
}
// Physics Sub-stepping
// We run physics multiple times per frame for stability
// especially for stacking (piling) behavior
for (let i = 0; i < CONFIG.subSteps; i++) {
particles.forEach(p => p.update());
constrainParticles();
solveCollisions();
}
// Draw
ctx.clearRect(0, 0, width, height);
drawHourglass();
drawParticles();
requestAnimationFrame(loop);
}
// Events
window.addEventListener('resize', resize);
window.addEventListener('mousedown', () => {
targetRotation += Math.PI;
document.getElementById('ui').style.opacity = '0'; // Hide UI on first interaction
});
window.addEventListener('touchstart', () => {
targetRotation += Math.PI;
document.getElementById('ui').style.opacity = '0';
});
// Init
resize();
requestAnimationFrame(loop);
</script>
</body>
</html>*