387 lines
10 KiB
TypeScript
387 lines
10 KiB
TypeScript
"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;
|