Compare commits

...

1 Commits

Author SHA1 Message Date
009a1b0b1a gravity 2026-04-23 13:24:06 +02:00
5 changed files with 263 additions and 2 deletions

View File

@@ -67,7 +67,7 @@ const AboutPage = () => {
const age = calculateAge("2002-02-27"); const age = calculateAge("2002-02-27");
return ( return (
<div className="flex w-full flex-col items-center gap-8 p-4 pt-24 text-white"> <div className="flex w-full flex-col items-center gap-8 p-4 pt-24 text-white gravity-body">
<div className="flex flex-col items-center justify-center gap-2 p-4 backdrop-blur-sm glass-effect glossy-effect bottom gloss-highlight rounded-lg shadow-lg"> <div className="flex flex-col items-center justify-center gap-2 p-4 backdrop-blur-sm glass-effect glossy-effect bottom gloss-highlight rounded-lg shadow-lg">
<Image <Image
src="/images/ja.avif" src="/images/ja.avif"

View File

@@ -3,6 +3,7 @@ import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css"; import "./globals.css";
import Navbar from "@/components/navbar"; import Navbar from "@/components/navbar";
import Footer from "@/components/footer"; import Footer from "@/components/footer";
import GravityToggle from "@/components/gravity-toggle";
const geistSans = Geist({ const geistSans = Geist({
variable: "--font-geist-sans", variable: "--font-geist-sans",
@@ -98,6 +99,7 @@ export default function RootLayout({
<Navbar /> <Navbar />
{children} {children}
<GravityToggle />
<Footer /> <Footer />
</body> </body>
</html> </html>

View File

@@ -7,7 +7,7 @@ import { skills, jobs } from "@/lib/data";
export default function Home() { export default function Home() {
return ( return (
<div className="flex flex-col items-center w-full"> <div className="flex flex-col items-center w-full gravity-body">
<Hero /> <Hero />
<div className="container mx-auto px-4"> <div className="container mx-auto px-4">
<AboutSummary /> <AboutSummary />

View File

@@ -0,0 +1,36 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { GravityEngine } from "@/lib/gravity-engine";
import { Magnet } from "lucide-react";
export default function GravityToggle() {
const [isActive, setIsActive] = useState(false);
const engineRef = useRef<GravityEngine | null>(null);
useEffect(() => {
engineRef.current = new GravityEngine();
return () => engineRef.current?.stop();
}, []);
const toggleGravity = () => {
if (!engineRef.current) return;
if (isActive) {
engineRef.current.stop();
} else {
engineRef.current.start();
}
setIsActive(!isActive);
};
return (
<button
onClick={toggleGravity}
className="fixed bottom-4 right-4 p-3 bg-yellow-400 text-black rounded-full shadow-lg z-50 hover:bg-yellow-500 transition-colors"
title="Toggle Gravity"
>
<Magnet size={24} />
</button>
);
}

223
lib/gravity-engine.ts Normal file
View File

@@ -0,0 +1,223 @@
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);
};
}