Compare commits

...
Author SHA1 Message Date
Evandro Fonseca Junior 4e68670362 wip: cena PortraitCyber e ajuste no About 2026-10-14 00:42:50 -03:00
3 changed files with 504 additions and 7 deletions

No files matched your search

+6 -7
View File
@@ -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 }}
>
<LazyRevealImage
<PortraitCyberScene
src={profilePhoto}
alt="Evandro Fonseca Junior, engenheiro da computação e desenvolvedor de software em Garuva, SC"
className="mb-sp-6 aspect-square w-full overflow-hidden rounded-[6px] border border-[var(--border)]"
imageClassName="object-contain"
className="mb-sp-6"
/>
<div className="ds-kicker">Technical profile</div>
<DrawLine delay={0.05} />
<h3 className="ds-card-title">Base tecnica e postura de entrega</h3>
<p className="ds-copy">
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.
</p>
<div className="mt-sp-6 space-y-sp-4">
{PROFILE_POINTS.map((point) => (
@@ -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;
}) => (
<div
aria-label={alt}
className={className}
data-testid="portrait-image"
role="img"
/>
),
}));
jest.mock("@react-three/fiber", () => ({
Canvas: ({ children: _children, ...props }: Record<string, unknown>) => (
<div data-testid="portrait-cyber-canvas" {...props} />
),
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(
<PortraitCyberScene
src="/edev.jpg"
alt="Retrato profissional de Evandro Fonseca Junior"
/>,
);
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(<PortraitCyberScene src="/edev.jpg" alt="Retrato profissional" />);
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(<PortraitCyberScene src="/edev.jpg" alt="Retrato profissional" />);
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();
});
});
@@ -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<THREE.Group>(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 (
<group ref={groupRef} position={[0, 0, 0.2]}>
<lineLoop geometry={geometry}>
<lineBasicMaterial color="#ccff00" transparent opacity={0.46} />
</lineLoop>
<lineLoop geometry={geometry} position={[hovered ? 0.035 : 0, 0, 0.01]}>
<lineBasicMaterial
color="#ff3b81"
transparent
opacity={hovered ? 0.48 : 0}
/>
</lineLoop>
<lineLoop geometry={geometry} position={[hovered ? -0.035 : 0, 0, 0.02]}>
<lineBasicMaterial
color="#00e5ff"
transparent
opacity={hovered ? 0.42 : 0}
/>
</lineLoop>
</group>
);
}
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<THREE.Points>(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 (
<points ref={pointsRef}>
<bufferGeometry>
<bufferAttribute
attach="attributes-position"
args={[positions, 3]}
count={count}
/>
</bufferGeometry>
<pointsMaterial
color={color}
size={0.026}
transparent
opacity={0.74}
sizeAttenuation
depthWrite={false}
blending={THREE.AdditiveBlending}
/>
</points>
);
}
function CyberScene({
animate,
hovered,
pointerRef,
}: {
animate: boolean;
hovered: boolean;
pointerRef: PointerReference;
}) {
const groupRef = useRef<THREE.Group>(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 (
<group ref={groupRef}>
<ContourLines animate={animate} hovered={hovered} />
<ParticleField animate={animate} color="#ccff00" count={128} phase={0} />
<ParticleField animate={animate} color="#00e5ff" count={48} phase={7} />
</group>
);
}
export interface PortraitCyberSceneProps {
src: string | StaticImageData;
alt: string;
className?: string;
}
export function PortraitCyberScene({
alt,
className = "",
src,
}: Readonly<PortraitCyberSceneProps>) {
const shouldReduceMotion = useReducedMotion();
const containerRef = useRef<HTMLDivElement>(null);
const pointerRef = useRef<PointerPosition>({ x: 0, y: 0 });
const [isVisible, setIsVisible] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const [pointerPosition, setPointerPosition] = useState<PointerPosition>({
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<HTMLDivElement>) => {
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 (
<div
ref={containerRef}
className={sceneClassName}
data-cyber-effects={PORTRAIT_CYBER_FEATURES.join(" ")}
data-cyber-state={isHovered ? "hovered" : "idle"}
data-motion={motionMode}
data-testid="portrait-cyber"
onPointerLeave={handlePointerLeave}
onPointerMove={handlePointerMove}
>
<div className="absolute inset-0 h-full w-full" style={imageTransform}>
<LazyRevealImage
src={src}
alt={alt}
className="h-full w-full"
imageClassName="object-contain"
/>
</div>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 z-10 opacity-[0.1] mix-blend-screen"
data-testid="portrait-cyber-scanlines"
data-effect="scanlines"
style={{
backgroundImage:
"repeating-linear-gradient(0deg, transparent 0, transparent 3px, rgba(204,255,0,0.22) 4px, transparent 5px)",
}}
/>
{isVisible && (
<Canvas
aria-hidden="true"
camera={{ far: 20, fov: 45, near: 0.1, position: [0, 0, 5] }}
className="pointer-events-none absolute inset-0 z-20"
dpr={[1, 1.5]}
frameloop={shouldReduceMotion ? "demand" : "always"}
gl={{
alpha: true,
antialias: false,
powerPreference: "high-performance",
}}
style={{ background: "transparent" }}
>
<CyberScene
animate={!isCoarsePointer && !shouldReduceMotion}
hovered={isHovered}
pointerRef={pointerRef}
/>
</Canvas>
)}
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 z-30 rounded-[6px] opacity-40"
data-effect="vignette"
style={{
background:
"radial-gradient(circle at center, transparent 48%, rgba(10,10,10,0.18) 100%)",
}}
/>
</div>
);
}
export default PortraitCyberScene;