From 329b22f15b6e6834f8a15e02e10ee97982b4913d Mon Sep 17 00:00:00 2001
From: Muthu Kumar <muthukumar@thefeathers.in>
Date: Wed, 9 Apr 2025 22:05:44 +0530
Subject: [PATCH] feat: add viewport exit & initialRotation support

---
 src/components/AnimateEntry.tsx         | 13 +++---
 src/draggable.attempts/6/Draggable.ts   | 76 +++++++++++++++------------------
 src/draggable.attempts/6/Draggable2.tsx | 21 +++++++--
 src/pages/main/Contact.tsx              | 18 +++++---
 src/util/index.ts                       | 26 +++++++++++
 5 files changed, 97 insertions(+), 57 deletions(-)

diff --git a/src/components/AnimateEntry.tsx b/src/components/AnimateEntry.tsx
index c4f66ec..dded012 100644
--- a/src/components/AnimateEntry.tsx
+++ b/src/components/AnimateEntry.tsx
@@ -1,13 +1,12 @@
 import React, { forwardRef } from "react";
 import { css, cx } from "@emotion/css";
 
-export const AnimateEntry = forwardRef<
-	HTMLDivElement,
-	React.HTMLAttributes<HTMLDivElement> & {
-		as?: React.ElementType;
-		delay?: number;
-	}
->(
+export interface AnimateEntryProps extends React.HTMLAttributes<any> {
+	as?: React.ElementType;
+	delay?: number;
+}
+
+export const AnimateEntry = forwardRef<HTMLDivElement, AnimateEntryProps>(
 	(
 		{ children, className, as: Component = "div", delay = 100, ...props },
 		ref,
diff --git a/src/draggable.attempts/6/Draggable.ts b/src/draggable.attempts/6/Draggable.ts
index 9eaeb0f..cfe3fc6 100644
--- a/src/draggable.attempts/6/Draggable.ts
+++ b/src/draggable.attempts/6/Draggable.ts
@@ -1,28 +1,19 @@
+import { clamp, debounce, normaliseAngleDifference } from "../../util/index.ts";
+
 interface Vec2 {
 	x: number;
 	y: number;
 }
 
-// --- Debounce Utility ---
-function debounce<T extends (...args: any[]) => void>(
-	func: T,
-	wait: number,
-): T {
-	let timeoutId: number | null = null;
-	return function (this: ThisParameterType<T>, ...args: Parameters<T>) {
-		if (timeoutId !== null) {
-			clearTimeout(timeoutId);
-		}
-		timeoutId = window.setTimeout(() => {
-			func.apply(this, args);
-			timeoutId = null; // Clear after execution
-		}, wait);
-	} as T;
+export interface DraggableOpts {
+	initialRotation?: number;
+	onViewportExit?: () => void;
+	onViewportEnter?: () => void;
 }
 
-export function makeDraggable(card: HTMLElement) {
+export function makeDraggable(card: HTMLElement, opts: DraggableOpts = {}) {
 	// --- Initial Setup ---
-	const calculateInitialCenter = () => {
+	const calculateInitialCenter = (): Vec2 => {
 		const rect = card.getBoundingClientRect();
 		return {
 			x: rect.left + rect.width / 2,
@@ -34,11 +25,11 @@ export function makeDraggable(card: HTMLElement) {
 	let initialCenter = calculateInitialCenter();
 	let center = { ...initialCenter };
 
-	let rotation = 0;
+	let rotation = opts.initialRotation ?? 0;
 	let dragging = false;
-	let offsetLocal = { x: 0, y: 0 };
+	let offsetLocal: Vec2 = { x: 0, y: 0 };
 
-	let velocity = { x: 0, y: 0 };
+	let velocity: Vec2 = { x: 0, y: 0 };
 	let angularVelocity = 0;
 
 	// --- Constants ---
@@ -46,25 +37,34 @@ export function makeDraggable(card: HTMLElement) {
 	const springFactor = 0.2;
 	const maxAngularVelocity = 0.95;
 	const momentumDampening = 0.98;
-	const RESIZE_DEBOUNCE_MS = 100; // Debounce time for resize
+	const RESIZE_DEBOUNCE_MS = 100;
+	const VIEWPORT_CHECK_INTERVAL_MS = 100;
 
 	// --- State ---
-	let lastMousePosition = { x: 0, y: 0 };
+	let lastMousePosition: Vec2 = { x: 0, y: 0 };
 	let activePointerId: number | null = null;
 	let animationFrameId: number | null = null;
+	let isOutsideViewport = false;
 
 	// --- Helpers ---
-	function clamp(value: number, min: number, max: number) {
-		return Math.min(Math.max(value, min), max);
-	}
 
-	function normaliseAngleDifference(delta: number): number {
-		// Bring into range (-2PI, 2PI)
-		delta = delta % (2 * Math.PI);
-		if (delta > Math.PI) delta -= 2 * Math.PI;
-		else if (delta <= -Math.PI) delta += 2 * Math.PI;
-		return delta;
-	}
+	const checkViewportExit = debounce(() => {
+		// Don't check if we're dragging, user may still be able to move the card back into view
+		if (dragging) return;
+
+		const rect = card.getBoundingClientRect();
+		const outside =
+			rect.right < 0 ||
+			rect.bottom < 0 ||
+			rect.left > window.innerWidth ||
+			rect.top > window.innerHeight;
+
+		if (outside !== isOutsideViewport) {
+			isOutsideViewport = outside;
+			if (isOutsideViewport) opts.onViewportExit?.();
+			else opts.onViewportEnter?.();
+		}
+	}, VIEWPORT_CHECK_INTERVAL_MS);
 
 	// --- Event Handlers ---
 	const down = (e: PointerEvent) => {
@@ -86,7 +86,6 @@ export function makeDraggable(card: HTMLElement) {
 		};
 
 		lastMousePosition = { x: e.pageX, y: e.pageY };
-		// Stop momentum on new grab
 		angularVelocity = 0;
 	};
 
@@ -109,7 +108,6 @@ export function makeDraggable(card: HTMLElement) {
 		const targetRotation =
 			Math.atan2(my - center.y, mx - center.x) - Math.atan2(py, px);
 
-		// Apply spring physics to rotation
 		const shortestAngleDifference = normaliseAngleDifference(
 			targetRotation - rotation,
 		);
@@ -146,7 +144,6 @@ export function makeDraggable(card: HTMLElement) {
 
 	// Debounced Resize Handler using the Reset-Reflow-Recalculate-Reapply strategy
 	const handleResize = debounce(() => {
-		console.log("handle resize triggered");
 		// 1. Store current visual state relative to the *old* initialCenter
 		const currentDeltaX = center.x - initialCenter.x;
 		const currentDeltaY = center.y - initialCenter.y;
@@ -186,9 +183,7 @@ export function makeDraggable(card: HTMLElement) {
 			if (Math.abs(angularVelocity) > 0.01) {
 				rotation += angularVelocity;
 				angularVelocity *= momentumDampening;
-			} else {
-				angularVelocity = 0;
-			}
+			} else angularVelocity = 0;
 
 			const speed = Math.sqrt(
 				velocity.x * velocity.x + velocity.y * velocity.y,
@@ -199,9 +194,7 @@ export function makeDraggable(card: HTMLElement) {
 				center.y += velocity.y * 0.4;
 				velocity.x *= momentumDampening;
 				velocity.y *= momentumDampening;
-			} else {
-				velocity = { x: 0, y: 0 };
-			}
+			} else velocity = { x: 0, y: 0 };
 		}
 
 		const deltaX = center.x - initialCenter.x;
@@ -212,6 +205,7 @@ export function makeDraggable(card: HTMLElement) {
 			rotate(${rotation}rad)
 		`;
 
+		checkViewportExit();
 		animationFrameId = requestAnimationFrame(render);
 	}
 
diff --git a/src/draggable.attempts/6/Draggable2.tsx b/src/draggable.attempts/6/Draggable2.tsx
index 2451307..669048d 100644
--- a/src/draggable.attempts/6/Draggable2.tsx
+++ b/src/draggable.attempts/6/Draggable2.tsx
@@ -3,21 +3,36 @@ import { makeDraggable } from "./Draggable.ts";
 import { composeRefs } from "../../util/index.ts";
 import { css, cx } from "@emotion/css";
 
-export type DraggableProps = React.ComponentPropsWithRef<any> & {
+export type DraggableProps = React.HtmlHTMLAttributes<any> & {
 	as?: React.ElementType;
+	onViewportEnter?: () => void;
+	onViewportExit?: () => void;
 	children: React.ReactNode;
+	initialRotation?: number;
 };
 
 export const Draggable = forwardRef<HTMLElement, DraggableProps>(
 	(
-		{ as: Comp = "div", children, className, ...props }: DraggableProps,
+		{
+			as: Comp = "div",
+			children,
+			className,
+			onViewportEnter,
+			onViewportExit,
+			initialRotation,
+			...props
+		}: DraggableProps,
 		ref,
 	) => {
 		const cardRef = useRef<HTMLElement>(null);
 
 		useEffect(() => {
 			if (!cardRef.current) return;
-			return makeDraggable(cardRef.current);
+			return makeDraggable(cardRef.current, {
+				onViewportEnter,
+				onViewportExit,
+				initialRotation,
+			});
 		}, []);
 
 		return (
diff --git a/src/pages/main/Contact.tsx b/src/pages/main/Contact.tsx
index 02af05e..5abdc23 100644
--- a/src/pages/main/Contact.tsx
+++ b/src/pages/main/Contact.tsx
@@ -45,15 +45,19 @@ const CONTACT: Contact = {
 	"Blog": { value: "→", link: "https://MKRhere.com" },
 };
 
+const CARD_COUNT = 5;
 // slightly random rotations within -20 to 20 degrees
-const cardRotations = Array.from({ length: 1 }, () => {
-	const rotation = Math.random() * 40 - 20;
+const CARD_ROTATION_VARIANCE = 20 * (Math.PI / 180);
+
+const contactCards = Array.from({ length: CARD_COUNT }, () => {
+	const rotation =
+		Math.random() * CARD_ROTATION_VARIANCE - CARD_ROTATION_VARIANCE / 2;
 	return rotation;
 });
 
 const Contact: React.FC = () => {
 	const [contact, setContact] = useState<Contact>(CONTACT);
-	const [visible, setVisible] = useState(cardRotations.length);
+	const [visible, setVisible] = useState(contactCards.length);
 
 	useEffect(() => {
 		const deob = () => {
@@ -108,10 +112,12 @@ const Contact: React.FC = () => {
 					<a href="/">Start over?</a>
 				</AnimateEntry>
 			)}
-			{cardRotations.map((rot, i) => (
+			{contactCards.map((rot, i) => (
 				<Draggable
 					key={i}
-					onOutsideViewport={() => setVisible(v => v - 1)}
+					onViewportExit={() => setVisible(v => v - 1)}
+					onViewportEnter={() => setVisible(v => v + 1)}
+					initialRotation={rot}
 					className={css`
 						width: 22rem;
 						height: 14rem;
@@ -125,7 +131,7 @@ const Contact: React.FC = () => {
 					`}
 					ref={setupCursorTracking}>
 					<Flippable
-						defaultFlipped={i !== cardRotations.length - 1}
+						defaultFlipped={i !== contactCards.length - 1}
 						front={
 							<main
 								className={css`
diff --git a/src/util/index.ts b/src/util/index.ts
index b49f152..cc533de 100644
--- a/src/util/index.ts
+++ b/src/util/index.ts
@@ -50,6 +50,20 @@ export const getTimeout = () => {
 	return [timeout, clearTimers] as const;
 };
 
+export function debounce<T extends (...args: any[]) => void>(
+	func: T,
+	wait: number,
+): T {
+	let timeoutId: number | null = null;
+	return function (this: ThisParameterType<T>, ...args: Parameters<T>) {
+		if (timeoutId !== null) clearTimeout(timeoutId);
+		timeoutId = window.setTimeout(() => {
+			func.apply(this, args);
+			timeoutId = null;
+		}, wait);
+	} as T;
+}
+
 export const ellipses = (text: string, length: number = 100) =>
 	text.length > length ? text.slice(0, length - 3) + "..." : text;
 
@@ -104,3 +118,15 @@ export function setupCursorTracking(el: HTMLElement | null) {
 		el.style.setProperty("--y", y + "px");
 	});
 }
+
+export function clamp(value: number, min: number, max: number) {
+	return Math.min(Math.max(value, min), max);
+}
+
+export function normaliseAngleDifference(delta: number): number {
+	// Bring into range (-2PI, 2PI)
+	delta = delta % (2 * Math.PI);
+	if (delta > Math.PI) delta -= 2 * Math.PI;
+	else if (delta <= -Math.PI) delta += 2 * Math.PI;
+	return delta;
+}