interface PhysicsBody { el: HTMLElement; x: number; y: number; vx: number; vy: number; width: number; height: number; originX: number; originY: number; isDragging: boolean; startX: number; startY: number; } export class GravityEngine { private bodies: PhysicsBody[] = []; private dirtyElements: HTMLElement[] = []; private animationFrameId: number | null = null; private isRunning = false; INITIAL_FORCE = 10; start() { if (this.isRunning) return; this.isRunning = true; this.dirtyElements = []; const containers = document.querySelectorAll(".gravity-body"); const leafElements: HTMLElement[] = []; // Phase 1 — Size-lock: freeze intermediate container dimensions // so they don't collapse when children become position:fixed. const extractLeaves = (el: HTMLElement) => { const isSolid = ["SVG", "IMG", "BUTTON", "IFRAME", "A"].includes( el.tagName.toUpperCase(), ); if (isSolid || el.children.length === 0) { if (el.offsetWidth > 0 && el.offsetHeight > 0) { leafElements.push(el); } return; } el.style.width = `${el.offsetWidth}px`; el.style.height = `${el.offsetHeight}px`; this.dirtyElements.push(el); Array.from(el.children).forEach((child) => extractLeaves(child as HTMLElement), ); }; containers.forEach((container) => { const htmlContainer = container as HTMLElement; htmlContainer.style.width = `${htmlContainer.offsetWidth}px`; htmlContainer.style.height = `${htmlContainer.offsetHeight}px`; this.dirtyElements.push(htmlContainer); Array.from(htmlContainer.children).forEach((child) => extractLeaves(child as HTMLElement), ); }); // Phase 2 — Read: batch all getBoundingClientRect() before any writes. const snapshots = leafElements.map((el) => ({ el, rect: el.getBoundingClientRect(), })); // Phase 3 — Write: apply fixed positioning using snapshotted rects. this.bodies = snapshots.map(({ el, rect }) => { el.style.width = `${rect.width}px`; el.style.height = `${rect.height}px`; el.style.margin = "0px"; el.style.position = "fixed"; el.style.left = "0px"; el.style.top = "0px"; el.style.transform = `translate(${rect.left}px, ${rect.top}px)`; this.dirtyElements.push(el); const body: PhysicsBody = { el, x: rect.left, y: rect.top, vx: (Math.random() - 0.5) * 8, vy: (Math.random() - 0.5) * 5, width: rect.width, height: rect.height, originX: rect.left, originY: rect.top, 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); }; }