<script>
class Particle {
constructor(x, y, radius, color) {
this.x = x;
this.y = y;
this.radius = radius;
this.color = color;
this.velocity = { x: 0, y: 0 };
this.gravity = 0.2;
this.friction = 0.95;
this.mass = 1;
this.stuck = false;
}
update(particles, neckX, neckY, neckWidth, neckHeight, isUpsideDown) {
if (this.stuck) return;
// Apply gravity (direction depends on orientation)
this.velocity.y += isUpsideDown ? -this.gravity : this.gravity;
// Apply friction
this.velocity.x *= this.friction;
this.velocity.y *= this.friction;
// Update position
this.x += this.velocity.x;
this.y += this.velocity.y;
// Hourglass boundary collision
const hourglassWidth = 300;
const hourglassHeight = 400;
const centerX = canvas.width / 2;
const centerY = canvas.height / 2;
// Top chamber (when upright)
if (!isUpsideDown && this.y < centerY - neckHeight/2) {
const distFromCenter = Math.abs(this.x - centerX);
const maxWidth = hourglassWidth * (1 - Math.pow((this.y - (centerY - hourglassHeight/2)) / (hourglassHeight/2 - neckHeight/2), 2));
if (distFromCenter + this.radius > maxWidth/2) {
const angle = Math.atan2(this.y - (centerY - hourglassHeight/2), this.x - centerX);
this.x = centerX + Math.cos(angle) * (maxWidth/2 - this.radius);
this.velocity.x = -this.velocity.x * 0.8;
}
}
// Bottom chamber (when upright)
else if (!isUpsideDown && this.y > centerY + neckHeight/2) {
const distFromCenter = Math.abs(this.x - centerX);
const maxWidth = hourglassWidth * (1 - Math.pow((this.y - (centerY + hourglassHeight/2)) / (hourglassHeight/2 - neckHeight/2), 2));
if (distFromCenter + this.radius > maxWidth/2) {
const angle = Math.atan2(this.y - (centerY + hourglassHeight/2), this.x - centerX);
this.x = centerX + Math.cos(angle) * (maxWidth/2 - this.radius);
this.velocity.x = -this.velocity.x * 0.8;
}
}
// Top chamber (when upside down)
else if (isUpsideDown && this.y > centerY + neckHeight/2) {
const distFromCenter = Math.abs(this.x - centerX);
const maxWidth = hourglassWidth * (1 - Math.pow((this.y - (centerY + hourglassHeight/2)) / (hourglassHeight/2 - neckHeight/2), 2));
if (distFromCenter + this.radius > maxWidth/2) {
const angle = Math.atan2(this.y - (centerY + hourglassHeight/2), this.x - centerX);
this.x = centerX + Math.cos(angle) * (maxWidth/2 - this.radius);
this.velocity.x = -this.velocity.x * 0.8;
}
}
// Bottom chamber (when upside down)
else if (isUpsideDown && this.y < centerY - neckHeight/2) {
const distFromCenter = Math.abs(this.x - centerX);
const maxWidth = hourglassWidth * (1 - Math.pow((this.y - (centerY - hourglassHeight/2)) / (hourglassHeight/2 - neckHeight/2), 2));
if (distFromCenter + this.radius > maxWidth/2) {
const angle = Math.atan2(this.y - (centerY - hourglassHeight/2), this.x - centerX);
this.x = centerX + Math.cos(angle) * (maxWidth/2 - this.radius);
this.velocity.x = -this.velocity.x * 0.8;
}
}
// Neck passage
const neckLeft = neckX - neckWidth/2;
const neckRight = neckX + neckWidth/2;
const neckTop = neckY - neckHeight/2;
const neckBottom = neckY + neckHeight/2;
if (this.x > neckLeft && this.x < neckRight &&
this.y > neckTop && this.y < neckBottom) {
// Allow particles to pass through neck
} else if (Math.abs(this.x - neckX) < neckWidth/2 + this.radius &&
Math.abs(this.y - neckY) < neckHeight/2 + this.radius) {
// Collision with neck edges
if (this.x < neckX) {
this.x = neckLeft - this.radius;
this.velocity.x = Math.abs(this.velocity.x) * 0.5;
} else {
this.x = neckRight + this.radius;
this.velocity.x = -Math.abs(this.velocity.x) * 0.5;
}
}
// Particle-particle collisions
for (let other of particles) {
if (other === this || other.stuck) continue;
const dx = other.x - this.x;
const dy = other.y - this.y;
const distance = Math.sqrt(dx * dx + dy * dy);
const minDistance = this.radius + other.radius;
if (distance < minDistance) {
// Collision response
const angle = Math.atan2(dy, dx);
const targetX = this.x + Math.cos(angle) * minDistance;
const targetY = this.y + Math.sin(angle) * minDistance;
const ax = (targetX - other.x) * 0.05;
const ay = (targetY - other.y) * 0.05;
this.velocity.x -= ax;
this.velocity.y -= ay;
other.velocity.x += ax;
other.velocity.y += ay;
// Granular physics - particles stick when slow
if (Math.abs(this.velocity.x) < 0.1 && Math.abs(this.velocity.y) < 0.3) {
if (!isUpsideDown && this.y > centerY + 50) {
this.stuck = true;
} else if (isUpsideDown && this.y < centerY - 50) {
this.stuck = true;
}
}
}
}
}
draw(ctx) {
ctx.beginPath();
ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2);
ctx.fillStyle = this.color;
ctx.fill();
ctx.closePath();
}
}
class Hourglass {
constructor() {
this.canvas = document.getElementById('hourglassCanvas');
this.ctx = this.canvas.getContext('2d');
this.particles = [];
this.isUpsideDown = false;
this.isRotating = false;
this.rotationProgress = 0;
this.rotationDuration = 1500; // ms
this.resize();
window.addEventListener('resize', () => this.resize());
this.canvas.addEventListener('click', () => this.flip());
this.createParticles();
this.animate();
}
resize() {
this.canvas.width = Math.min(500, window.innerWidth * 0.8);
this.canvas.height = Math.min(600, window.innerHeight * 0.7);
}
createParticles() {
this.particles = [];
const particleCount = 200;
const centerX = this.canvas.width / 2;
const centerY = this.canvas.height / 2;
for (let i = 0; i < particleCount; i++) {
const radius = 3 + Math.random() * 2;
const angle = Math.random() * Math.PI * 2;
const distance = Math.random() * 80;
const x = centerX + Math.cos(angle) * distance;
const y = centerY - 120 + Math.random() * 40;
const color = `hsl(${40 + Math.random() * 10}, 70%, ${60 + Math.random() * 10}%)`;
this.particles.push(new Particle(x, y, radius, color));
}
}
flip() {
if (!this.isRotating) {
this.isRotating = true;
this.rotationProgress = 0;
this.isUpsideDown = !this.isUpsideDown;
// Unstick all particles when flipping
this.particles.forEach(particle => {
particle.stuck = false;
// Add some random velocity to break up piles
particle.velocity.x += (Math.random() - 0.5) * 2;
particle.velocity.y += (Math.random() - 0.5) * 2;
});
}
}
drawHourglass() {
const ctx = this.ctx;
const width = this.canvas.width;
const height = this.canvas.height;
const centerX = width / 2;
const centerY = height / 2;
const hourglassWidth = width * 0.6;
const hourglassHeight = height * 0.7;
const neckWidth = 15;
const neckHeight = 20;
ctx.save();
// Apply rotation during flip animation
if (this.isRotating) {
const rotationAngle = (this.rotationProgress / this.rotationDuration) * Math.PI;
ctx.translate(centerX, centerY);
ctx.rotate(rotationAngle);
ctx.translate(-centerX, -centerY);
}
// Draw hourglass outline
ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)';
ctx.lineWidth = 3;
ctx.beginPath();
// Top curve
ctx.moveTo(centerX - hourglassWidth/2, centerY - hourglassHeight/2);
ctx.bezierCurveTo(
centerX - hourglassWidth/2, centerY - hourglassHeight/4,
centerX - neckWidth/2, centerY - neckHeight/2,
centerX - neckWidth/2, centerY - neckHeight/2
);
ctx.bezierCurveTo(
centerX - neckWidth/2, centerY - neckHeight/2,
centerX - neckWidth/2, centerY + neckHeight/2,
centerX - hourglassWidth/2, centerY + hourglassHeight/2
);
// Bottom curve
ctx.moveTo(centerX + hourglassWidth/2, centerY - hourglassHeight/2);
ctx.bezierCurveTo(
centerX + hourglassWidth/2, centerY - hourglassHeight/4,
centerX + neckWidth/2, centerY - neckHeight/2,
centerX + neckWidth/2, centerY - neckHeight/2
);
ctx.bezierCurveTo(
centerX + neckWidth/2, centerY - neckHeight/2,
centerX + neckWidth/2, centerY + neckHeight/2,
centerX + hourglassWidth/2, centerY + hourglassHeight/2
);
ctx.stroke();
ctx.restore();
return {
neckX: centerX,
neckY: centerY,
neckWidth,
neckHeight
};
}
animate() {
requestAnimationFrame(() => this.animate());
// Update rotation progress
if (this.isRotating) {
this.rotationProgress += 16; // ~60fps
if (this.rotationProgress >= this.rotationDuration) {
this.isRotating = false;
}
}
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
const neck = this.drawHourglass();
// Update and draw particles
for (let particle of this.particles) {
particle.update(this.particles, neck.neckX, neck.neckY, neck.neckWidth, neck.neckHeight, this.isUpsideDown);
particle.draw(this.ctx);
}
}
}
// Initialize hourglass when page loads
window.addEventListener('load', () => {
new Hourglass();
});
</script>
<script>
class Particle {
constructor(x, y, radius, color) {
this.x = x;
this.y = y;
this.radius = radius;
this.color = color;
this.velocity = { x: 0, y: 0 };
this.gravity = 0.2;
this.friction = 0.95;
this.mass = 1;
this.stuck = false;
}
update(particles, neckX, neckY, neckWidth, neckHeight, isUpsideDown) {
if (this.stuck) return;
// Apply gravity (direction depends on orientation)
this.velocity.y += isUpsideDown ? -this.gravity : this.gravity;
// Apply friction
this.velocity.x *= this.friction;
this.velocity.y *= this.friction;
// Update position
this.x += this.velocity.x;
this.y += this.velocity.y;
// Hourglass boundary collision
const hourglassWidth = 300;
const hourglassHeight = 400;
const centerX = canvas.width / 2;
const centerY = canvas.height / 2;
// Top chamber (when upright)
if (!isUpsideDown && this.y < centerY - neckHeight/2) {
const distFromCenter = Math.abs(this.x - centerX);
const maxWidth = hourglassWidth * (1 - Math.pow((this.y - (centerY - hourglassHeight/2)) / (hourglassHeight/2 - neckHeight/2), 2));
if (distFromCenter + this.radius > maxWidth/2) {
const angle = Math.atan2(this.y - (centerY - hourglassHeight/2), this.x - centerX);
this.x = centerX + Math.cos(angle) * (maxWidth/2 - this.radius);
this.velocity.x = -this.velocity.x * 0.8;
}
}
// Bottom chamber (when upright)
else if (!isUpsideDown && this.y > centerY + neckHeight/2) {
const distFromCenter = Math.abs(this.x - centerX);
const maxWidth = hourglassWidth * (1 - Math.pow((this.y - (centerY + hourglassHeight/2)) / (hourglassHeight/2 - neckHeight/2), 2));
if (distFromCenter + this.radius > maxWidth/2) {
const angle = Math.atan2(this.y - (centerY + hourglassHeight/2), this.x - centerX);
this.x = centerX + Math.cos(angle) * (maxWidth/2 - this.radius);
this.velocity.x = -this.velocity.x * 0.8;
}
}
// Top chamber (when upside down)
else if (isUpsideDown && this.y > centerY + neckHeight/2) {
const distFromCenter = Math.abs(this.x - centerX);
const maxWidth = hourglassWidth * (1 - Math.pow((this.y - (centerY + hourglassHeight/2)) / (hourglassHeight/2 - neckHeight/2), 2));
if (distFromCenter + this.radius > maxWidth/2) {
const angle = Math.atan2(this.y - (centerY + hourglassHeight/2), this.x - centerX);
this.x = centerX + Math.cos(angle) * (maxWidth/2 - this.radius);
this.velocity.x = -this.velocity.x * 0.8;
}
}
// Bottom chamber (when upside down)
else if (isUpsideDown && this.y < centerY - neckHeight/2) {
const distFromCenter = Math.abs(this.x - centerX);
const maxWidth = hourglassWidth * (1 - Math.pow((this.y - (centerY - hourglassHeight/2)) / (hourglassHeight/2 - neckHeight/2), 2));
if (distFromCenter + this.radius > maxWidth/2) {
const angle = Math.atan2(this.y - (centerY - hourglassHeight/2), this.x - centerX);
this.x = centerX + Math.cos(angle) * (maxWidth/2 - this.radius);
this.velocity.x = -this.velocity.x * 0.8;
}
}
// Neck passage
const neckLeft = neckX - neckWidth/2;
const neckRight = neckX + neckWidth/2;
const neckTop = neckY - neckHeight/2;
const neckBottom = neckY + neckHeight/2;
if (this.x > neckLeft && this.x < neckRight &&
this.y > neckTop && this.y < neckBottom) {
// Allow particles to pass through neck
} else if (Math.abs(this.x - neckX) < neckWidth/2 + this.radius &&
Math.abs(this.y - neckY) < neckHeight/2 + this.radius) {
// Collision with neck edges
if (this.x < neckX) {
this.x = neckLeft - this.radius;
this.velocity.x = Math.abs(this.velocity.x) * 0.5;
} else {
this.x = neckRight + this.radius;
this.velocity.x = -Math.abs(this.velocity.x) * 0.5;
}
}
// Particle-particle collisions
for (let other of particles) {
if (other === this || other.stuck) continue;
const dx = other.x - this.x;
const dy = other.y - this.y;
const distance = Math.sqrt(dx * dx + dy * dy);
const minDistance = this.radius + other.radius;
if (distance < minDistance) {
// Collision response
const angle = Math.atan2(dy, dx);
const targetX = this.x + Math.cos(angle) * minDistance;
const targetY = this.y + Math.sin(angle) * minDistance;
const ax = (targetX - other.x) * 0.05;
const ay = (targetY - other.y) * 0.05;
this.velocity.x -= ax;
this.velocity.y -= ay;
other.velocity.x += ax;
other.velocity.y += ay;
// Granular physics - particles stick when slow
if (Math.abs(this.velocity.x) < 0.1 && Math.abs(this.velocity.y) < 0.3) {
if (!isUpsideDown && this.y > centerY + 50) {
this.stuck = true;
} else if (isUpsideDown && this.y < centerY - 50) {
this.stuck = true;
}
}
}
}
}
draw(ctx) {
ctx.beginPath();
ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2);
ctx.fillStyle = this.color;
ctx.fill();
ctx.closePath();
}
}
class Hourglass {
constructor() {
this.canvas = document.getElementById('hourglassCanvas');
this.ctx = this.canvas.getContext('2d');
this.particles = [];
this.isUpsideDown = false;
this.isRotating = false;
this.rotationProgress = 0;
this.rotationDuration = 1500; // ms
this.resize();
window.addEventListener('resize', () => this.resize());
this.canvas.addEventListener('click', () => this.flip());
this.createParticles();
this.animate();
}
resize() {
this.canvas.width = Math.min(500, window.innerWidth * 0.8);
this.canvas.height = Math.min(600, window.innerHeight * 0.7);
}
createParticles() {
this.particles = [];
const particleCount = 200;
const centerX = this.canvas.width / 2;
const centerY = this.canvas.height / 2;
for (let i = 0; i < particleCount; i++) {
const radius = 3 + Math.random() * 2;
const angle = Math.random() * Math.PI * 2;
const distance = Math.random() * 80;
const x = centerX + Math.cos(angle) * distance;
const y = centerY - 120 + Math.random() * 40;
const color = `hsl(${40 + Math.random() * 10}, 70%, ${60 + Math.random() * 10}%)`;
this.particles.push(new Particle(x, y, radius, color));
}
}
flip() {
if (!this.isRotating) {
this.isRotating = true;
this.rotationProgress = 0;
this.isUpsideDown = !this.isUpsideDown;
// Unstick all particles when flipping
this.particles.forEach(particle => {
particle.stuck = false;
// Add some random velocity to break up piles
particle.velocity.x += (Math.random() - 0.5) * 2;
particle.velocity.y += (Math.random() - 0.5) * 2;
});
}
}
drawHourglass() {
const ctx = this.ctx;
const width = this.canvas.width;
const height = this.canvas.height;
const centerX = width / 2;
const centerY = height / 2;
const hourglassWidth = width * 0.6;
const hourglassHeight = height * 0.7;
const neckWidth = 15;
const neckHeight = 20;
ctx.save();
// Apply rotation during flip animation
if (this.isRotating) {
const rotationAngle = (this.rotationProgress / this.rotationDuration) * Math.PI;
ctx.translate(centerX, centerY);
ctx.rotate(rotationAngle);
ctx.translate(-centerX, -centerY);
}
// Draw hourglass outline
ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)';
ctx.lineWidth = 3;
ctx.beginPath();
// Top curve
ctx.moveTo(centerX - hourglassWidth/2, centerY - hourglassHeight/2);
ctx.bezierCurveTo(
centerX - hourglassWidth/2, centerY - hourglassHeight/4,
centerX - neckWidth/2, centerY - neckHeight/2,
centerX - neckWidth/2, centerY - neckHeight/2
);
ctx.bezierCurveTo(
centerX - neckWidth/2, centerY - neckHeight/2,
centerX - neckWidth/2, centerY + neckHeight/2,
centerX - hourglassWidth/2, centerY + hourglassHeight/2
);
// Bottom curve
ctx.moveTo(centerX + hourglassWidth/2, centerY - hourglassHeight/2);
ctx.bezierCurveTo(
centerX + hourglassWidth/2, centerY - hourglassHeight/4,
centerX + neckWidth/2, centerY - neckHeight/2,
centerX + neckWidth/2, centerY - neckHeight/2
);
ctx.bezierCurveTo(
centerX + neckWidth/2, centerY - neckHeight/2,
centerX + neckWidth/2, centerY + neckHeight/2,
centerX + hourglassWidth/2, centerY + hourglassHeight/2
);
ctx.stroke();
ctx.restore();
return {
neckX: centerX,
neckY: centerY,
neckWidth,
neckHeight
};
}
animate() {
requestAnimationFrame(() => this.animate());
// Update rotation progress
if (this.isRotating) {
this.rotationProgress += 16; // ~60fps
if (this.rotationProgress >= this.rotationDuration) {
this.isRotating = false;
}
}
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
const neck = this.drawHourglass();
// Update and draw particles
for (let particle of this.particles) {
particle.update(this.particles, neck.neckX, neck.neckY, neck.neckWidth, neck.neckHeight, this.isUpsideDown);
particle.draw(this.ctx);
}
}
}
// Initialize hourglass when page loads
window.addEventListener('load', () => {
new Hourglass();
});
</script>