diff --git a/app/components/About.tsx b/app/components/About.tsx index 5b59183..701fbca 100644 --- a/app/components/About.tsx +++ b/app/components/About.tsx @@ -4,7 +4,7 @@ import { motion, type Variants } from "framer-motion"; import { useReducedMotion } from "@/app/hooks/useReducedMotion"; import profilePhoto from "@/public/edev.jpg"; import { DrawLine } from "./animations/DrawLine"; -import { LazyRevealImage } from "./animations/LazyRevealImage"; +import PortraitCyberScene from "./cinematic/PortraitCyberScene"; import { Section } from "./Section"; const STORY_DATA = [ @@ -132,20 +132,19 @@ export default function About() { whileTap={shouldReduceMotion ? undefined : { scale: 0.99 }} transition={{ type: "spring", stiffness: 180, damping: 18 }} > -
Technical profile

Base tecnica e postura de entrega

Sou engenheiro da computação e desenvolvedor de software full-stack, - atuando ha 5 anos com foco em integrar camadas que normalmente - ficam separadas: hardware, servicos, operacao e interface. Meu - trabalho e dar forma clara ao que costuma nascer fragmentado. + atuando ha 5 anos com foco em integrar camadas que normalmente ficam + separadas: hardware, servicos, operacao e interface. Meu trabalho e + dar forma clara ao que costuma nascer fragmentado.

{PROFILE_POINTS.map((point) => ( diff --git a/app/components/cinematic/PortraitCyberScene.test.tsx b/app/components/cinematic/PortraitCyberScene.test.tsx new file mode 100644 index 0000000..ddd015a --- /dev/null +++ b/app/components/cinematic/PortraitCyberScene.test.tsx @@ -0,0 +1,112 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { useReducedMotion } from "@/app/hooks/useReducedMotion"; +import { + PORTRAIT_CYBER_FEATURES, + PortraitCyberScene, +} from "./PortraitCyberScene"; + +jest.mock("@/app/hooks/useReducedMotion", () => ({ + useReducedMotion: jest.fn(), +})); + +jest.mock("../animations/LazyRevealImage", () => ({ + LazyRevealImage: ({ + alt, + className, + }: { + alt: string; + className?: string; + }) => ( +
+ ), +})); + +jest.mock("@react-three/fiber", () => ({ + Canvas: ({ children: _children, ...props }: Record) => ( +
+ ), + useFrame: jest.fn(), +})); + +const mockedUseReducedMotion = jest.mocked(useReducedMotion); + +describe("PortraitCyberScene", () => { + beforeEach(() => { + mockedUseReducedMotion.mockReturnValue(false); + }); + + it("keeps the accessible photo and mounts the cyber layers when visible", async () => { + render( + , + ); + + const scene = screen.getByTestId("portrait-cyber"); + + await waitFor(() => + expect(screen.getByTestId("portrait-cyber-canvas")).toBeInTheDocument(), + ); + expect( + screen.getByRole("img", { + name: "Retrato profissional de Evandro Fonseca Junior", + }), + ).toBeInTheDocument(); + expect(scene).toHaveAttribute( + "data-cyber-effects", + PORTRAIT_CYBER_FEATURES.join(" "), + ); + expect(screen.getByTestId("portrait-cyber-scanlines")).toBeInTheDocument(); + }); + + it("exposes the RGB split state only while the pointer is over the portrait", async () => { + render(); + + const scene = screen.getByTestId("portrait-cyber"); + Object.defineProperty(scene, "getBoundingClientRect", { + configurable: true, + value: () => ({ + bottom: 100, + height: 100, + left: 0, + right: 100, + top: 0, + width: 100, + x: 0, + y: 0, + }), + }); + + await waitFor(() => + expect(screen.getByTestId("portrait-cyber-canvas")).toBeInTheDocument(), + ); + fireEvent.pointerMove(scene, { clientX: 80, clientY: 20 }); + expect(scene).toHaveAttribute("data-cyber-state", "hovered"); + + fireEvent.pointerLeave(scene); + expect(scene).toHaveAttribute("data-cyber-state", "idle"); + }); + + it("keeps the photo static while showing the cyber layers when reduced motion is preferred", async () => { + mockedUseReducedMotion.mockReturnValue(true); + + render(); + + expect(screen.getByTestId("portrait-cyber")).toHaveAttribute( + "data-motion", + "reduced", + ); + await waitFor(() => + expect(screen.getByTestId("portrait-cyber-canvas")).toBeInTheDocument(), + ); + expect( + screen.queryByRole("img", { name: "Retrato profissional" }), + ).toBeInTheDocument(); + }); +}); diff --git a/app/components/cinematic/PortraitCyberScene.tsx b/app/components/cinematic/PortraitCyberScene.tsx new file mode 100644 index 0000000..e92b392 --- /dev/null +++ b/app/components/cinematic/PortraitCyberScene.tsx @@ -0,0 +1,386 @@ +"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 ( +
+
+ +
+ +