<div class="simulation-area">
<canvas id="hourglassCanvas"></canvas>
</div>
<div class="stats">
<div class="stat-item">
<span class="stat-value" id="particleCount">0</span>
<span class="stat-label">Particles</span>
</div>
<div class="stat-item">
<span class="stat-value" id="fpsCounter">0</span>
<span class="stat-label">FPS</span>
</div>
<div class="stat-item">
<span class="stat-value" id="stateDisplay">Ready</span>
<span class="stat-label">Status</span>
</div>
</div>
</div>
<script>
class HourglassSimulation {
constructor() {
this.canvas = document.getElementById('hourglassCanvas');
this.ctx = this.canvas.getContext('2d');
this.particles = [];
this.isFlipping = false;
this.flipProgress = 0;
this.rotationAngle = 0;
this.lastTime = 0;
this.fps = 0;
this.frameCount = 0;
this.lastFpsUpdate = 0;
this.particleCountElement = document.getElementById('particleCount');
this.fpsCounterElement = document.getElementById('fpsCounter');
this.stateDisplayElement = document.getElementById('stateDisplay');
this.init();
this.setupEventListeners();
this.animate();
}
init() {
this.resizeCanvas();
this.createParticles();
}
resizeCanvas() {
const container = this.canvas.parentElement;
this.canvas.width = container.clientWidth;
this.canvas.height = container.clientHeight;
}
createParticles() {
this.particles = [];
const particleCount = 800;
const centerX = this.canvas.width / 2;
const topChamberHeight = this.canvas.height * 0.4;
for (let i = 0; i < particleCount; i++) {
const radius = Math.random() * 3 + 2;
const angle = Math.random() * Math.PI * 2;
const distance = Math.random() * (this.canvas.width * 0.2);
this.particles.push({
x: centerX + Math.cos(angle) * distance,
y: topChamberHeight * 0.3 + Math.random() * topChamberHeight * 0.4,
radius: radius,
vx: 0,
vy: 0,
color: this.getSandColor(),
inTop: true,
settled: false
});
}
}
getSandColor() {
const colors = [
'#d4a574', '#c19a6b', '#b08d5f',
'#e5b887', '#deab79', '#d6a066'
];
return colors[Math.floor(Math.random() * colors.length)];
}
setupEventListeners() {
this.canvas.addEventListener('click', () => this.flip());
window.addEventListener('resize', () => this.resizeCanvas());
}
flip() {
if (!this.isFlipping) {
this.isFlipping = true;
this.flipProgress = 0;
this.stateDisplayElement.textContent = 'Flipping...';
}
}
updateParticles(deltaTime) {
const gravity = 0.2;
const friction = 0.98;
const centerX = this.canvas.width / 2;
const neckWidth = this.canvas.width * 0.08;
const topChamberHeight = this.canvas.height * 0.4;
const bottomChamberHeight = this.canvas.height * 0.4;
const neckY = this.canvas.height * 0.4;
let activeParticles = 0;
for (let particle of this.particles) {
if (particle.settled) {
activeParticles++;
continue;
}
// Apply gravity (direction depends on orientation)
const gravityDirection = this.rotationAngle < Math.PI ? 1 : -1;
particle.vy += gravity * gravityDirection * deltaTime;
// Update position
particle.x += particle.vx * deltaTime;
particle.y += particle.vy * deltaTime;
// Boundary constraints for top chamber
if (particle.inTop && this.rotationAngle < Math.PI) {
const topRadius = this.canvas.width * 0.3;
const distFromCenter = Math.abs(particle.x - centerX);
// Chamber walls
if (distFromCenter + particle.radius > topRadius) {
particle.x = centerX + Math.sign(particle.x - centerX) * (topRadius - particle.radius);
particle.vx *= -0.5;
}
// Chamber bottom (neck entrance)
if (particle.y > neckY - particle.radius) {
const neckDist = Math.abs(particle.x - centerX);
if (neckDist < neckWidth / 2) {
// Pass through neck
particle.inTop = false;
} else {
particle.y = neckY - particle.radius;
particle.vy *= -0.3;
}
}
// Chamber top
if (particle.y < particle.radius) {
particle.y = particle.radius;
particle.vy *= -0.3;
}
}
// Boundary constraints for bottom chamber
else if (!particle.inTop && this.rotationAngle < Math.PI) {
const bottomRadius = this.canvas.width * 0.3;
const bottomChamberTop = neckY;
const bottomChamberBottom = this.canvas.height - this.canvas.height * 0.1;
const distFromCenter = Math.abs(particle.x - centerX);
// Chamber walls
if (distFromCenter + particle.radius > bottomRadius) {
particle.x = centerX + Math.sign(particle.x - centerX) * (bottomRadius - particle.radius);
particle.vx *= -0.5;
}
// Chamber bottom
if (particle.y > bottomChamberBottom - particle.radius) {
particle.y = bottomChamberBottom - particle.radius;
particle.vy *= -0.2;
particle.vx *= 0.8;
// Check if particle should settle
if (Math.abs(particle.vy) < 0.5 && Math.abs(particle.vx) < 0.5) {
particle.settled = true;
}
}
// Chamber top (neck)
if (particle.y < bottomChamberTop + particle.radius) {
const neckDist = Math.abs(particle.x - centerX);
if (neckDist < neckWidth / 2) {
// Can go back up through neck
particle.inTop = true;
} else {
particle.y = bottomChamberTop + particle.radius;
particle.vy *= -0.3;
}
}
}
// Apply friction
particle.vx *= friction;
particle.vy *= friction;
// Particle interactions (simple collision)
for (let other of this.particles) {
if (particle === other || other.settled) continue;
const dx = other.x - particle.x;
const dy = other.y - particle.y;
const distance = Math.sqrt(dx * dx + dy * dy);
const minDistance = particle.radius + other.radius;
if (distance < minDistance && distance > 0) {
// Simple elastic collision response
const angle = Math.atan2(dy, dx);
const targetX = particle.x + Math.cos(angle) * minDistance;
const targetY = particle.y + Math.sin(angle) * minDistance;
const ax = (targetX - other.x) * 0.05;
const ay = (targetY - other.y) * 0.05;
particle.vx -= ax;
particle.vy -= ay;
other.vx += ax;
other.vy += ay;
}
}
activeParticles++;
}
this.particleCountElement.textContent = activeParticles;
}
drawHourglass() {
const ctx = this.ctx;
const centerX = this.canvas.width / 2;
const centerY = this.canvas.height / 2;
const chamberWidth = this.canvas.width * 0.3;
const neckWidth = this.canvas.width * 0.08;
const chamberHeight = this.canvas.height * 0.4;
ctx.save();
ctx.translate(centerX, centerY);
ctx.rotate(this.rotationAngle);
ctx.translate(-centerX, -centerY);
// Draw hourglass frame
ctx.strokeStyle = '#ffd166';
ctx.lineWidth = 3;
ctx.fillStyle = 'rgba(255, 255, 255, 0.1)';
// Top chamber
ctx.beginPath();
ctx.moveTo(centerX - chamberWidth, centerY - chamberHeight);
ctx.lineTo(centerX + chamberWidth, centerY - chamberHeight);
ctx.lineTo(centerX + neckWidth, centerY);
ctx.lineTo(centerX - neckWidth, centerY);
ctx.closePath();
ctx.stroke();
ctx.fill();
// Bottom chamber
ctx.beginPath();
ctx.moveTo(centerX - neckWidth, centerY);
ctx.lineTo(centerX + neckWidth, centerY);
ctx.lineTo(centerX + chamberWidth, centerY + chamberHeight);
ctx.lineTo(centerX - chamberWidth, centerY + chamberHeight);
ctx.closePath();
ctx.stroke();
ctx.fill();
// Neck connector
ctx.beginPath();
ctx.moveTo(centerX - neckWidth, centerY);
ctx.lineTo(centerX + neckWidth, centerY);
ctx.stroke();
ctx.restore();
}
drawParticles() {
const ctx = this.ctx;
const centerX = this.canvas.width / 2;
const centerY = this.canvas.height / 2;
ctx.save();
ctx.translate(centerX, centerY);
ctx.rotate(this.rotationAngle);
ctx.translate(-centerX, -centerY);
for (let particle of this.particles) {
ctx.beginPath();
ctx.arc(particle.x, particle.y, particle.radius, 0, Math.PI * 2);
ctx.fillStyle = particle.color;
ctx.fill();
// Add subtle shadow for depth
ctx.beginPath();
ctx.arc(particle.x - particle.radius * 0.3, particle.y - particle.radius * 0.3,
particle.radius * 0.6, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(255, 255, 255, 0.2)';
ctx.fill();
}
ctx.restore();
}
update(deltaTime) {
// Handle flip animation
if (this.isFlipping) {
this.flipProgress += deltaTime * 0.003;
if (this.flipProgress >= 1) {
this.flipProgress = 1;
this.isFlipping = false;
this.rotationAngle += Math.PI;
if (this.rotationAngle >= Math.PI * 2) {
this.rotationAngle = 0;
}
this.stateDisplayElement.textContent = 'Flowing';
} else {
this.rotationAngle += Math.PI * deltaTime * 0.003;
}
}
this.updateParticles(deltaTime);
}
render() {
// Clear canvas with gradient background
const gradient = this.ctx.createLinearGradient(0, 0, 0, this.canvas.height);
gradient.addColorStop(0, '#1a1a2e');
gradient.addColorStop(1, '#16213e');
this.ctx.fillStyle = gradient;
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
this.drawHourglass();
this.drawParticles();
}
animate(currentTime = 0) {
const deltaTime = Math.min(currentTime - this.lastTime, 100) / 16;
this.lastTime = currentTime;
this.frameCount++;
if (currentTime - this.lastFpsUpdate >= 1000) {
this.fps = Math.round((this.frameCount * 1000) / (currentTime - this.lastFpsUpdate));
this.fpsCounterElement.textContent = this.fps;
this.frameCount = 0;
this.lastFpsUpdate = currentTime;
}
this.update(deltaTime);
this.render();
requestAnimationFrame((time) => this.animate(time));
}
}
// Initialize simulation when page loads
window.addEventListener('load', () => {
new HourglassSimulation();
});
</script>
<div class="simulation-area">
<canvas id="hourglassCanvas"></canvas>
</div>
<div class="stats">
<div class="stat-item">
<span class="stat-value" id="particleCount">0</span>
<span class="stat-label">Particles</span>
</div>
<div class="stat-item">
<span class="stat-value" id="fpsCounter">0</span>
<span class="stat-label">FPS</span>
</div>
<div class="stat-item">
<span class="stat-value" id="stateDisplay">Ready</span>
<span class="stat-label">Status</span>
</div>
</div>
</div>
<script>
class HourglassSimulation {
constructor() {
this.canvas = document.getElementById('hourglassCanvas');
this.ctx = this.canvas.getContext('2d');
this.particles = [];
this.isFlipping = false;
this.flipProgress = 0;
this.rotationAngle = 0;
this.lastTime = 0;
this.fps = 0;
this.frameCount = 0;
this.lastFpsUpdate = 0;
this.particleCountElement = document.getElementById('particleCount');
this.fpsCounterElement = document.getElementById('fpsCounter');
this.stateDisplayElement = document.getElementById('stateDisplay');
this.init();
this.setupEventListeners();
this.animate();
}
init() {
this.resizeCanvas();
this.createParticles();
}
resizeCanvas() {
const container = this.canvas.parentElement;
this.canvas.width = container.clientWidth;
this.canvas.height = container.clientHeight;
}
createParticles() {
this.particles = [];
const particleCount = 800;
const centerX = this.canvas.width / 2;
const topChamberHeight = this.canvas.height * 0.4;
for (let i = 0; i < particleCount; i++) {
const radius = Math.random() * 3 + 2;
const angle = Math.random() * Math.PI * 2;
const distance = Math.random() * (this.canvas.width * 0.2);
this.particles.push({
x: centerX + Math.cos(angle) * distance,
y: topChamberHeight * 0.3 + Math.random() * topChamberHeight * 0.4,
radius: radius,
vx: 0,
vy: 0,
color: this.getSandColor(),
inTop: true,
settled: false
});
}
}
getSandColor() {
const colors = [
'#d4a574', '#c19a6b', '#b08d5f',
'#e5b887', '#deab79', '#d6a066'
];
return colors[Math.floor(Math.random() * colors.length)];
}
setupEventListeners() {
this.canvas.addEventListener('click', () => this.flip());
window.addEventListener('resize', () => this.resizeCanvas());
}
flip() {
if (!this.isFlipping) {
this.isFlipping = true;
this.flipProgress = 0;
this.stateDisplayElement.textContent = 'Flipping...';
}
}
updateParticles(deltaTime) {
const gravity = 0.2;
const friction = 0.98;
const centerX = this.canvas.width / 2;
const neckWidth = this.canvas.width * 0.08;
const topChamberHeight = this.canvas.height * 0.4;
const bottomChamberHeight = this.canvas.height * 0.4;
const neckY = this.canvas.height * 0.4;
let activeParticles = 0;
for (let particle of this.particles) {
if (particle.settled) {
activeParticles++;
continue;
}
// Apply gravity (direction depends on orientation)
const gravityDirection = this.rotationAngle < Math.PI ? 1 : -1;
particle.vy += gravity * gravityDirection * deltaTime;
// Update position
particle.x += particle.vx * deltaTime;
particle.y += particle.vy * deltaTime;
// Boundary constraints for top chamber
if (particle.inTop && this.rotationAngle < Math.PI) {
const topRadius = this.canvas.width * 0.3;
const distFromCenter = Math.abs(particle.x - centerX);
// Chamber walls
if (distFromCenter + particle.radius > topRadius) {
particle.x = centerX + Math.sign(particle.x - centerX) * (topRadius - particle.radius);
particle.vx *= -0.5;
}
// Chamber bottom (neck entrance)
if (particle.y > neckY - particle.radius) {
const neckDist = Math.abs(particle.x - centerX);
if (neckDist < neckWidth / 2) {
// Pass through neck
particle.inTop = false;
} else {
particle.y = neckY - particle.radius;
particle.vy *= -0.3;
}
}
// Chamber top
if (particle.y < particle.radius) {
particle.y = particle.radius;
particle.vy *= -0.3;
}
}
// Boundary constraints for bottom chamber
else if (!particle.inTop && this.rotationAngle < Math.PI) {
const bottomRadius = this.canvas.width * 0.3;
const bottomChamberTop = neckY;
const bottomChamberBottom = this.canvas.height - this.canvas.height * 0.1;
const distFromCenter = Math.abs(particle.x - centerX);
// Chamber walls
if (distFromCenter + particle.radius > bottomRadius) {
particle.x = centerX + Math.sign(particle.x - centerX) * (bottomRadius - particle.radius);
particle.vx *= -0.5;
}
// Chamber bottom
if (particle.y > bottomChamberBottom - particle.radius) {
particle.y = bottomChamberBottom - particle.radius;
particle.vy *= -0.2;
particle.vx *= 0.8;
// Check if particle should settle
if (Math.abs(particle.vy) < 0.5 && Math.abs(particle.vx) < 0.5) {
particle.settled = true;
}
}
// Chamber top (neck)
if (particle.y < bottomChamberTop + particle.radius) {
const neckDist = Math.abs(particle.x - centerX);
if (neckDist < neckWidth / 2) {
// Can go back up through neck
particle.inTop = true;
} else {
particle.y = bottomChamberTop + particle.radius;
particle.vy *= -0.3;
}
}
}
// Apply friction
particle.vx *= friction;
particle.vy *= friction;
// Particle interactions (simple collision)
for (let other of this.particles) {
if (particle === other || other.settled) continue;
const dx = other.x - particle.x;
const dy = other.y - particle.y;
const distance = Math.sqrt(dx * dx + dy * dy);
const minDistance = particle.radius + other.radius;
if (distance < minDistance && distance > 0) {
// Simple elastic collision response
const angle = Math.atan2(dy, dx);
const targetX = particle.x + Math.cos(angle) * minDistance;
const targetY = particle.y + Math.sin(angle) * minDistance;
const ax = (targetX - other.x) * 0.05;
const ay = (targetY - other.y) * 0.05;
particle.vx -= ax;
particle.vy -= ay;
other.vx += ax;
other.vy += ay;
}
}
activeParticles++;
}
this.particleCountElement.textContent = activeParticles;
}
drawHourglass() {
const ctx = this.ctx;
const centerX = this.canvas.width / 2;
const centerY = this.canvas.height / 2;
const chamberWidth = this.canvas.width * 0.3;
const neckWidth = this.canvas.width * 0.08;
const chamberHeight = this.canvas.height * 0.4;
ctx.save();
ctx.translate(centerX, centerY);
ctx.rotate(this.rotationAngle);
ctx.translate(-centerX, -centerY);
// Draw hourglass frame
ctx.strokeStyle = '#ffd166';
ctx.lineWidth = 3;
ctx.fillStyle = 'rgba(255, 255, 255, 0.1)';
// Top chamber
ctx.beginPath();
ctx.moveTo(centerX - chamberWidth, centerY - chamberHeight);
ctx.lineTo(centerX + chamberWidth, centerY - chamberHeight);
ctx.lineTo(centerX + neckWidth, centerY);
ctx.lineTo(centerX - neckWidth, centerY);
ctx.closePath();
ctx.stroke();
ctx.fill();
// Bottom chamber
ctx.beginPath();
ctx.moveTo(centerX - neckWidth, centerY);
ctx.lineTo(centerX + neckWidth, centerY);
ctx.lineTo(centerX + chamberWidth, centerY + chamberHeight);
ctx.lineTo(centerX - chamberWidth, centerY + chamberHeight);
ctx.closePath();
ctx.stroke();
ctx.fill();
// Neck connector
ctx.beginPath();
ctx.moveTo(centerX - neckWidth, centerY);
ctx.lineTo(centerX + neckWidth, centerY);
ctx.stroke();
ctx.restore();
}
drawParticles() {
const ctx = this.ctx;
const centerX = this.canvas.width / 2;
const centerY = this.canvas.height / 2;
ctx.save();
ctx.translate(centerX, centerY);
ctx.rotate(this.rotationAngle);
ctx.translate(-centerX, -centerY);
for (let particle of this.particles) {
ctx.beginPath();
ctx.arc(particle.x, particle.y, particle.radius, 0, Math.PI * 2);
ctx.fillStyle = particle.color;
ctx.fill();
// Add subtle shadow for depth
ctx.beginPath();
ctx.arc(particle.x - particle.radius * 0.3, particle.y - particle.radius * 0.3,
particle.radius * 0.6, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(255, 255, 255, 0.2)';
ctx.fill();
}
ctx.restore();
}
update(deltaTime) {
// Handle flip animation
if (this.isFlipping) {
this.flipProgress += deltaTime * 0.003;
if (this.flipProgress >= 1) {
this.flipProgress = 1;
this.isFlipping = false;
this.rotationAngle += Math.PI;
if (this.rotationAngle >= Math.PI * 2) {
this.rotationAngle = 0;
}
this.stateDisplayElement.textContent = 'Flowing';
} else {
this.rotationAngle += Math.PI * deltaTime * 0.003;
}
}
this.updateParticles(deltaTime);
}
render() {
// Clear canvas with gradient background
const gradient = this.ctx.createLinearGradient(0, 0, 0, this.canvas.height);
gradient.addColorStop(0, '#1a1a2e');
gradient.addColorStop(1, '#16213e');
this.ctx.fillStyle = gradient;
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
this.drawHourglass();
this.drawParticles();
}
animate(currentTime = 0) {
const deltaTime = Math.min(currentTime - this.lastTime, 100) / 16;
this.lastTime = currentTime;
this.frameCount++;
if (currentTime - this.lastFpsUpdate >= 1000) {
this.fps = Math.round((this.frameCount * 1000) / (currentTime - this.lastFpsUpdate));
this.fpsCounterElement.textContent = this.fps;
this.frameCount = 0;
this.lastFpsUpdate = currentTime;
}
this.update(deltaTime);
this.render();
requestAnimationFrame((time) => this.animate(time));
}
}
// Initialize simulation when page loads
window.addEventListener('load', () => {
new HourglassSimulation();
});
</script>