"use client"; import { Canvas, useFrame } from "@react-three/fiber"; import type { StaticImageData } from "next/image"; import { type PointerEvent, useCallback, useEffect, useMemo, useRef, useState, } from "react"; import * as THREE from "three"; import { useReducedMotion } from "@/app/hooks/useReducedMotion"; import { LazyRevealImage } from "../animations/LazyRevealImage"; export const PORTRAIT_CYBER_FEATURES = [ "particles", "contour", "scanlines", "rgb-split", ] as const; type PointerPosition = { x: number; y: number; }; type PointerReference = { current: PointerPosition; }; const CYBER_OUTLINE: readonly [number, number][] = [ [-0.78, -0.92], [-0.96, -0.64], [-0.9, -0.28], [-0.68, 0.02], [-0.48, 0.22], [-0.42, 0.58], [-0.25, 0.88], [0, 1.02], [0.24, 0.94], [0.43, 0.62], [0.48, 0.24], [0.72, 0.02], [0.94, -0.3], [0.9, -0.66], [0.75, -0.92], [0.35, -0.78], [0, -0.68], [-0.38, -0.78], ]; function createContourGeometry(scale = 1.65) { return new THREE.BufferGeometry().setFromPoints( CYBER_OUTLINE.map(([x, y]) => new THREE.Vector3(x * scale, y * scale, 0)), ); } function ContourLines({ hovered, animate, }: { hovered: boolean; animate: boolean; }) { const groupRef = useRef(null); const geometry = useMemo(() => createContourGeometry(), []); useFrame((state) => { if (!groupRef.current || !animate) return; groupRef.current.rotation.z = Math.sin(state.clock.elapsedTime * 0.35) * 0.008; }); return ( ); } function createParticlePositions(count: number, phase: number) { const positions = new Float32Array(count * 3); for (let index = 0; index < count; index += 1) { const [x, y] = CYBER_OUTLINE[(index + phase) % CYBER_OUTLINE.length]; const jitterX = (((index * 17 + phase * 7) % 19) / 19 - 0.5) * 0.18; const jitterY = (((index * 11 + phase * 5) % 17) / 17 - 0.5) * 0.18; const radius = 1 + ((index * 13 + phase) % 9) / 26; const index3 = index * 3; positions[index3] = x * 1.65 * radius + jitterX; positions[index3 + 1] = y * 1.65 * radius + jitterY; positions[index3 + 2] = 0.24 + ((index + phase) % 7) * 0.025; } return positions; } function ParticleField({ animate, color, count, phase, }: { animate: boolean; color: string; count: number; phase: number; }) { const pointsRef = useRef(null); const positions = useMemo( () => createParticlePositions(count, phase), [count, phase], ); useFrame((state, delta) => { if (!pointsRef.current || !animate) return; const positionAttribute = pointsRef.current.geometry.getAttribute( "position", ) as THREE.BufferAttribute; const values = positionAttribute.array as Float32Array; for (let index = 0; index < count; index += 1) { const index3 = index * 3; values[index3 + 1] += (0.012 + (index % 4) * 0.002) * delta; values[index3] += Math.sin(state.clock.elapsedTime * 0.55 + index + phase) * 0.0007; if (values[index3 + 1] > 1.9) values[index3 + 1] = -1.9; } positionAttribute.needsUpdate = true; }); return ( ); } function CyberScene({ animate, hovered, pointerRef, }: { animate: boolean; hovered: boolean; pointerRef: PointerReference; }) { const groupRef = useRef(null); useFrame((_, delta) => { if (!groupRef.current) return; const targetX = animate ? pointerRef.current.x * 0.09 : 0; const targetY = animate ? pointerRef.current.y * 0.06 : 0; groupRef.current.position.x = THREE.MathUtils.damp( groupRef.current.position.x, targetX, 5, delta, ); groupRef.current.position.y = THREE.MathUtils.damp( groupRef.current.position.y, targetY, 5, delta, ); groupRef.current.rotation.y = THREE.MathUtils.damp( groupRef.current.rotation.y, targetX * 0.4, 5, delta, ); }); return ( ); } export interface PortraitCyberSceneProps { src: string | StaticImageData; alt: string; className?: string; } export function PortraitCyberScene({ alt, className = "", src, }: Readonly) { const shouldReduceMotion = useReducedMotion(); const containerRef = useRef(null); const pointerRef = useRef({ x: 0, y: 0 }); const [isVisible, setIsVisible] = useState(false); const [isHovered, setIsHovered] = useState(false); const [pointerPosition, setPointerPosition] = useState({ x: 0, y: 0, }); const [isCoarsePointer, setIsCoarsePointer] = useState(false); useEffect(() => { const element = containerRef.current; if (!element) return; if (typeof IntersectionObserver === "undefined") { setIsVisible(true); return; } const observer = new IntersectionObserver( ([entry]) => { if (!entry?.isIntersecting) return; setIsVisible(true); observer.disconnect(); }, { rootMargin: "120px" }, ); observer.observe(element); return () => observer.disconnect(); }, []); useEffect(() => { const mediaQuery = window.matchMedia("(pointer: coarse)"); const updatePointerMode = () => setIsCoarsePointer(mediaQuery.matches); updatePointerMode(); mediaQuery.addEventListener?.("change", updatePointerMode); return () => mediaQuery.removeEventListener?.("change", updatePointerMode); }, []); const handlePointerMove = useCallback( (event: PointerEvent) => { if (shouldReduceMotion || isCoarsePointer) return; const rect = event.currentTarget.getBoundingClientRect(); const width = rect.width || 1; const height = rect.height || 1; const position = { x: ((event.clientX - rect.left) / width) * 2 - 1, y: -(((event.clientY - rect.top) / height) * 2 - 1), }; pointerRef.current = position; setPointerPosition(position); setIsHovered(true); }, [isCoarsePointer, shouldReduceMotion], ); const handlePointerLeave = useCallback(() => { pointerRef.current = { x: 0, y: 0 }; setPointerPosition({ x: 0, y: 0 }); setIsHovered(false); }, []); const imageTransform = shouldReduceMotion ? undefined : { transform: `translate3d(${pointerPosition.x * 3}px, ${ pointerPosition.y * 3 }px, 0) scale(1.012)`, transition: isHovered ? "transform 220ms cubic-bezier(0.16, 1, 0.3, 1)" : "transform 420ms cubic-bezier(0.16, 1, 0.3, 1)", }; const motionMode = shouldReduceMotion ? "reduced" : isCoarsePointer ? "static" : "full"; const sceneClassName = [ "relative aspect-square w-full overflow-hidden rounded-[6px] border border-[var(--border)]", className, ] .filter(Boolean) .join(" "); return (