interface PhysicsBody {
el: HTMLElement;
x: number;
y: number;
vx: number;
vy: number;
isDragging: boolean;
startX: number;
startY: number;
}
export class GravityEngine {
private bodies: PhysicsBody[] = [];
private animationFrameId: number | null = null;
private isRunning = false;
INITIAL_FORCE = 10;
start() {
if (this.isRunning) return;
this.isRunning = true;
const containers = document.querySelectorAll(".gravity-body");
const targetElements: HTMLElement[] = [];
// Helper to recursively drill down to leaf nodes
const extractLeaves = (el: HTMLElement) => {
// 1. Define "solid" elements that should fall as one single piece
const isSolid = ["SVG", "IMG", "BUTTON", "IFRAME", "A"].includes(
el.tagName.toUpperCase(),
);
// 2. Base case: If it's solid, or has no children, it's a target
if (isSolid || el.children.length === 0) {
// Only extract elements that actually take up visual space
if (el.offsetWidth > 0 && el.offsetHeight > 0) {
targetElements.push(el);
}
return;
}
el.style.width = `${el.offsetWidth}px`;
el.style.height = `${el.offsetHeight}px`;
Array.from(el.children).forEach((child) => {
extractLeaves(child as HTMLElement);
});
};
containers.forEach((container) => {
const htmlContainer = container as HTMLElement;
// Lock the main container's dimensions
htmlContainer.style.width = `${htmlContainer.offsetWidth}px`;
htmlContainer.style.height = `${htmlContainer.offsetHeight}px`;
// Start the recursive extraction on its direct children
Array.from(htmlContainer.children).forEach((child) => {
extractLeaves(child as HTMLElement);
});
});
const initialStates = targetElements.map((el) => {
const rect = el.getBoundingClientRect();
return { el, rect };
});
this.bodies = initialStates.map(({ el, rect }) => {
// Lock dimensions so it doesn't warp when pulled out of flex/grid
el.style.width = `${rect.width}px`;
el.style.height = `${rect.height}px`;
el.style.margin = "0px";
// Snap to fixed positioning at its exact current visual location
el.style.position = "fixed";
el.style.left = "0px";
el.style.top = "0px";
el.style.transform = `translate(${rect.left}px, ${rect.top}px)`;
const body: PhysicsBody = {
el: el,
x: rect.left,
y: rect.top,
vx: (Math.random() - 0.5) * 8, // slight explosion outward
vy: (Math.random() - 0.5) * 5, // slight pop upward
isDragging: false,
startX: 0,
startY: 0,
};
this.attachMouseEvents(body);
return body;
});
this.tick();
}
stop() {
this.isRunning = false;
if (this.animationFrameId) cancelAnimationFrame(this.animationFrameId);
// Reset all children back to their normal document flow
this.bodies.forEach((body) => {
body.el.style.transform = "";
body.el.style.position = "";
body.el.style.left = "";
body.el.style.top = "";
body.el.style.width = "";
body.el.style.height = "";
body.el.style.margin = "";
});
// Reset parent containers
const containers = document.querySelectorAll(".gravity-body");
containers.forEach((container) => {
const htmlContainer = container as HTMLElement;
htmlContainer.style.width = "";
htmlContainer.style.height = "";
});
this.bodies = [];
}
private attachMouseEvents(body: PhysicsBody) {
// Prevent default drag behaviors that interfere with physics
body.el.ondragstart = () => false;
body.el.addEventListener("pointerdown", (e) => {
body.isDragging = true;
body.startX = e.clientX;
body.startY = e.clientY;
body.vx = 0;
body.vy = 0;
body.el.setPointerCapture(e.pointerId);
});
body.el.addEventListener("pointermove", (e) => {
if (!body.isDragging) return;
// Calculate velocity based on mouse movement for the "throw"
body.vx = e.movementX * 0.5;
body.vy = e.movementY * 0.5;
body.x += e.movementX;
body.y += e.movementY;
});
body.el.addEventListener("pointerup", (e) => {
body.isDragging = false;
body.el.releasePointerCapture(e.pointerId);
// The Drag vs. Click Resolver
const deltaX = Math.abs(e.clientX - body.startX);
const deltaY = Math.abs(e.clientY - body.startY);
// If the user moved the mouse less than 5px, treat it as a click
if (deltaX > 5 || deltaY > 5) {
// It was a drag. Prevent links from firing.
e.preventDefault();
e.stopPropagation();
}
});
// Catch clicks at the capture phase to stop them if we were dragging
body.el.addEventListener(
"click",
(e) => {
const deltaX = Math.abs(e.clientX - body.startX);
const deltaY = Math.abs(e.clientY - body.startY);
if (deltaX > 5 || deltaY > 5) {
e.preventDefault();
e.stopPropagation();
}
},
true,
);
}
private tick = () => {
if (!this.isRunning) return;
const gravity = 0.5;
const bounce = -0.7;
const floorY = window.innerHeight;
this.bodies.forEach((body) => {
if (body.isDragging) {
// Only update visually while dragging, physics are paused
body.el.style.position = "fixed";
body.el.style.left = "0px";
body.el.style.top = "0px";
body.el.style.transform = `translate(${body.x}px, ${body.y}px)`;
return;
}
// Apply Gravity
body.vy += gravity;
body.x += body.vx;
body.y += body.vy;
// Apply Air Friction
body.vx *= 0.99;
body.vy *= 0.99;
const rect = body.el.getBoundingClientRect();
// Floor Collision
if (body.y + rect.height > floorY) {
body.y = floorY - rect.height;
body.vy *= bounce;
body.vx *= 0.9; // Ground friction
}
// Render Step
body.el.style.position = "fixed";
body.el.style.left = "0px";
body.el.style.top = "0px";
body.el.style.transform = `translate(${body.x}px, ${body.y}px)`;
});
this.animationFrameId = requestAnimationFrame(this.tick);
};
}