wip: cena PortraitCyber e ajuste no About
This commit is contained in:
1 parent
0920370f30
commit
4e68670362
3 files changed
+504
-7
No files matched your search
@@ -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;
|
||||
Reference in new issue
Block a user