Files
portifolio-evandrodevbr/app/components/cinematic/HeroSystemScene.tsx
T

578 lines
17 KiB
TypeScript

"use client";
import { RoundedBox } from "@react-three/drei";
import { Canvas, useFrame, useThree } from "@react-three/fiber";
import {
Component,
type ReactNode,
Suspense,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import * as THREE from "three";
export type HeroSystemLayer =
| "overview"
| "interface"
| "integrations"
| "data";
export type HeroSystemSceneProps = {
activeLayer: HeroSystemLayer;
pose: { x: number; y: number };
visible: boolean;
onReady: () => void;
onError: () => void;
onSelectLayer: (layer: HeroSystemLayer) => void;
};
type LayerPose = readonly [number, number, number, number];
type SceneTextures = {
screen: THREE.CanvasTexture;
integrations: THREE.CanvasTexture;
data: THREE.CanvasTexture;
};
const LIME = "#ccff00";
const LAYERS = ["interface", "integrations", "data"] as const;
const LAYER_POSES: Record<HeroSystemLayer, readonly LayerPose[]> = {
overview: [
[-0.38, -0.42, 0.9, 1],
[0.12, 0.25, 0, 1],
[0.65, 0.92, -0.9, 1],
],
interface: [
[-0.1, -0.15, 1.3, 1.04],
[0.36, 0.6, -0.45, 0.91],
[0.8, 1.08, -1.25, 0.88],
],
integrations: [
[-0.82, -1.05, 1.05, 0.77],
[0.12, 0.55, 0.55, 1.02],
[0.76, 1.16, -1, 0.88],
],
data: [
[-0.9, -1.25, 1.02, 0.68],
[-0.44, -0.48, 0.18, 0.81],
[0.38, 1.08, 0.55, 1.02],
],
};
const NODE_X = [-1.28, 0, 1.28];
const SERVER_ROWS = [-0.47, -0.17, 0.13];
function canvasContext(width: number, height: number) {
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const context = canvas.getContext("2d");
if (!context) throw new Error("Canvas 2D is unavailable");
return { canvas, context };
}
function asTexture(canvas: HTMLCanvasElement) {
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
texture.anisotropy = 4;
return texture;
}
function createBoardTexture(layer: "integrations" | "data") {
const { canvas, context: ctx } = canvasContext(1024, 640);
ctx.fillStyle = "#121a1b";
ctx.fillRect(0, 0, 1024, 640);
ctx.strokeStyle = "#293737";
ctx.lineWidth = 1;
for (let x = 32; x < 1024; x += 40) {
for (let y = 32; y < 640; y += 40) {
ctx.beginPath();
ctx.moveTo(x - 2, y);
ctx.lineTo(x + 2, y);
ctx.stroke();
}
}
ctx.fillStyle = "#edf0e6";
ctx.font = "500 32px sans-serif";
ctx.fillText(layer === "integrations" ? "Integrações" : "Dados", 46, 61);
ctx.fillStyle = LIME;
ctx.fillRect(932, 43, 42, 5);
ctx.strokeStyle = "#455c50";
ctx.lineWidth = 3;
const endpoints = [175, 512, 849];
for (const x of endpoints) {
ctx.beginPath();
ctx.moveTo(x, 132);
ctx.lineTo(x, 231);
ctx.lineTo(512, 300);
ctx.lineTo(512, 490);
ctx.lineTo(x, 490);
ctx.lineTo(x, 556);
ctx.stroke();
ctx.fillStyle = LIME;
ctx.beginPath();
ctx.arc(x, 556, 6, 0, Math.PI * 2);
ctx.fill();
}
ctx.strokeStyle = "#a6c748";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(52, 342);
ctx.lineTo(125, 342);
ctx.lineTo(206, 422);
ctx.lineTo(820, 422);
ctx.lineTo(899, 342);
ctx.lineTo(972, 342);
ctx.stroke();
return asTexture(canvas);
}
function useSceneTextures(onError: () => void) {
const [textures, setTextures] = useState<SceneTextures | null>(null);
useEffect(() => {
let cancelled = false;
const owned: THREE.Texture[] = [];
const image = new Image();
image.decoding = "async";
image.onload = () => {
if (cancelled) return;
try {
const { canvas, context } = canvasContext(1600, 800);
// Keep the actual product interface; exclude the old metrics footer.
context.drawImage(image, 0, 0, 1600, 800, 0, 0, 1600, 800);
const screen = asTexture(canvas);
owned.push(screen);
const integrations = createBoardTexture("integrations");
owned.push(integrations);
const data = createBoardTexture("data");
owned.push(data);
setTextures({ screen, integrations, data });
} catch {
onError();
}
};
image.onerror = () => {
if (!cancelled) onError();
};
image.src = "/assets/notebook/nutriscan.webp";
return () => {
cancelled = true;
image.onload = null;
image.onerror = null;
image.removeAttribute("src");
for (const texture of owned) texture.dispose();
};
}, [onError]);
return textures;
}
function Plate({ selected }: { selected: boolean }) {
return (
<>
<RoundedBox args={[4.28, 2.72, 0.14]} radius={0.06} smoothness={4}>
<meshStandardMaterial
color={selected ? "#93ad76" : "#687579"}
metalness={0.62}
roughness={0.29}
/>
</RoundedBox>
<RoundedBox
args={[4.2, 2.64, 0.08]}
radius={0.03}
smoothness={4}
position={[0, 0, 0.075]}
>
<meshStandardMaterial color="#111919" metalness={0.3} roughness={0.4} />
</RoundedBox>
<mesh position={[0, -1.3, 0.124]}>
<boxGeometry args={[3.8, 0.018, 0.014]} />
<meshBasicMaterial color={selected ? LIME : "#667451"} />
</mesh>
{[-1.96, 1.96].map((x) => (
<mesh key={x} position={[x, 1.18, 0.13]}>
<circleGeometry args={[0.025, 12]} />
<meshStandardMaterial
color="#b6c0b7"
metalness={0.8}
roughness={0.3}
/>
</mesh>
))}
</>
);
}
function InterfacePlate({ texture }: { texture: THREE.Texture }) {
return (
<>
<RoundedBox
args={[4.02, 2.09, 0.035]}
position={[0, -0.075, 0.139]}
radius={0.012}
smoothness={3}
>
<meshStandardMaterial
color="#dfe5d7"
metalness={0.2}
roughness={0.38}
/>
</RoundedBox>
<mesh position={[0, -0.075, 0.16]}>
<planeGeometry args={[3.94, 1.97]} />
<meshBasicMaterial map={texture} toneMapped={false} />
</mesh>
<mesh position={[-1.84, 1.16, 0.13]}>
<circleGeometry args={[0.025, 16]} />
<meshBasicMaterial color={LIME} />
</mesh>
<mesh position={[-1.6, 1.16, 0.13]}>
<boxGeometry args={[0.28, 0.018, 0.012]} />
<meshBasicMaterial color="#b3bcb3" />
</mesh>
<mesh position={[0, 1.16, 0.13]}>
<boxGeometry args={[1.35, 0.12, 0.015]} />
<meshStandardMaterial color="#2b3633" roughness={0.7} />
</mesh>
</>
);
}
function IntegrationNodes() {
return (
<>
{NODE_X.map((x) => (
<group key={x} position={[x, 0.45, 0.21]}>
<RoundedBox args={[0.7, 0.52, 0.14]} radius={0.06} smoothness={3}>
<meshStandardMaterial
color="#d6ddcf"
metalness={0.5}
roughness={0.28}
/>
</RoundedBox>
<mesh position={[0, 0, 0.077]}>
<planeGeometry args={[0.53, 0.35]} />
<meshStandardMaterial
color="#25382d"
roughness={0.36}
metalness={0.35}
/>
</mesh>
<mesh position={[0, 0, 0.083]}>
<boxGeometry args={[0.2, 0.035, 0.01]} />
<meshBasicMaterial color={LIME} />
</mesh>
</group>
))}
<RoundedBox
args={[1.03, 0.68, 0.2]}
radius={0.055}
smoothness={3}
position={[0, -0.38, 0.24]}
>
<meshStandardMaterial
color="#89a962"
metalness={0.55}
roughness={0.32}
/>
</RoundedBox>
<mesh position={[0, -0.38, 0.345]}>
<planeGeometry args={[0.77, 0.44]} />
<meshStandardMaterial color="#15251c" metalness={0.2} roughness={0.5} />
</mesh>
{[-0.15, 0, 0.15].map((y) => (
<mesh key={y} position={[0, -0.38 + y, 0.352]}>
<boxGeometry args={[0.43, 0.022, 0.01]} />
<meshBasicMaterial color={LIME} />
</mesh>
))}
</>
);
}
function DataVolumes() {
return (
<>
{[-1.25, 0].map((x) => (
<group key={x} position={[x, 0.03, 0.3]}>
<RoundedBox args={[0.86, 1.24, 0.3]} radius={0.065} smoothness={3}>
<meshStandardMaterial
color="#51645f"
metalness={0.6}
roughness={0.3}
/>
</RoundedBox>
{SERVER_ROWS.map((y) => (
<group key={y} position={[0, y + 0.17, 0.159]}>
<mesh>
<boxGeometry args={[0.7, 0.21, 0.035]} />
<meshStandardMaterial
color="#142020"
metalness={0.3}
roughness={0.45}
/>
</mesh>
<mesh position={[-0.25, 0, 0.022]}>
<circleGeometry args={[0.021, 12]} />
<meshBasicMaterial color={LIME} />
</mesh>
<mesh position={[0.06, 0, 0.022]}>
<boxGeometry args={[0.34, 0.025, 0.013]} />
<meshStandardMaterial
color="#a6b3a5"
metalness={0.6}
roughness={0.35}
/>
</mesh>
</group>
))}
</group>
))}
<group position={[1.28, 0.02, 0.59]}>
{[-0.2, 0, 0.2].map((y) => (
<group key={y} position={[0, y, 0]}>
<mesh>
<cylinderGeometry args={[0.43, 0.43, 0.17, 40]} />
<meshStandardMaterial
color="#a8b6a4"
metalness={0.62}
roughness={0.29}
/>
</mesh>
<mesh position={[0, 0.078, 0]}>
<cylinderGeometry args={[0.435, 0.435, 0.015, 40]} />
<meshBasicMaterial color={LIME} />
</mesh>
</group>
))}
</group>
</>
);
}
function SystemAssembly(
props: HeroSystemSceneProps & {
textures: SceneTextures;
reducedMotion: boolean;
},
) {
const { textures, reducedMotion } = props;
const root = useRef<THREE.Group>(null);
const plates = useRef<(THREE.Group | null)[]>([]);
const entrance = useRef(0);
const ready = useRef(false);
const failed = useRef(false);
const { camera, gl, invalidate, size } = useThree();
useLayoutEffect(() => {
if (!(camera instanceof THREE.PerspectiveCamera)) return;
camera.zoom = Math.min(1.1, size.width / Math.max(size.height, 1) / 1.12);
camera.updateProjectionMatrix();
if (props.visible) invalidate();
}, [camera, invalidate, props.visible, size.height, size.width]);
useEffect(() => {
if (props.visible) invalidate();
}, [invalidate, props]);
// Render on demand and stop scheduling as soon as every transform settles.
useFrame((state, delta) => {
if (
!props.visible ||
document.hidden ||
failed.current ||
!root.current ||
gl.getContext().isContextLost()
)
return;
const dt = Math.min(delta, 1 / 30);
entrance.current = reducedMotion
? 1
: Math.min(1, entrance.current + dt / 1.05);
const assembly = (1 - entrance.current) ** 3;
const alpha = reducedMotion ? 1 : 1 - Math.exp(-11 * dt);
const targetX = -0.12 + Math.max(-1, Math.min(1, props.pose.y)) * 0.11;
const targetY = -0.36 + Math.max(-1, Math.min(1, props.pose.x)) * 0.16;
let moving = entrance.current < 1;
const rotation = root.current.rotation;
rotation.x += (targetX - rotation.x) * alpha;
rotation.y += (targetY - rotation.y) * alpha;
if (
Math.abs(targetX - rotation.x) + Math.abs(targetY - rotation.y) >
0.0004
)
moving = true;
else {
rotation.x = targetX;
rotation.y = targetY;
}
const targets = LAYER_POSES[props.activeLayer];
for (let index = 0; index < LAYERS.length; index++) {
const plate = plates.current[index];
if (!plate) continue;
const target = targets[index];
const x = target[0] + (index - 1) * assembly * 0.65;
const y = target[1] + (index - 1) * assembly * 0.5;
const z = target[2] - (3 - index) * assembly * 0.8;
plate.position.x += (x - plate.position.x) * alpha;
plate.position.y += (y - plate.position.y) * alpha;
plate.position.z += (z - plate.position.z) * alpha;
const scale = plate.scale.x + (target[3] - plate.scale.x) * alpha;
plate.scale.setScalar(scale);
const remaining =
Math.abs(x - plate.position.x) +
Math.abs(y - plate.position.y) +
Math.abs(z - plate.position.z) +
Math.abs(target[3] - scale);
if (remaining > 0.0008) moving = true;
else {
plate.position.set(x, y, z);
plate.scale.setScalar(target[3]);
}
}
try {
gl.render(state.scene, camera);
if (!ready.current && !gl.getContext().isContextLost()) {
ready.current = true;
props.onReady();
}
} catch {
failed.current = true;
props.onError();
return;
}
if (moving) invalidate();
}, 1);
return (
<>
<ambientLight intensity={1.15} />
<hemisphereLight args={["#f6faed", "#283b30", 1.8]} />
<directionalLight position={[1, 5, 6]} color="#f6f9ef" intensity={3.7} />
<directionalLight position={[-5, 1, 2]} color={LIME} intensity={2.1} />
<directionalLight position={[4, -1, -3]} color="#e0e8df" intensity={3} />
<group
ref={root}
rotation={[-0.12, -0.36, -0.055]}
position={[0, -0.07, 0]}
>
{LAYERS.map((layer, index) => (
<group
key={layer}
ref={(group) => {
plates.current[index] = group;
}}
position={[
LAYER_POSES.overview[index][0],
LAYER_POSES.overview[index][1] + (index - 1) * 0.5,
LAYER_POSES.overview[index][2] - (3 - index) * 0.8,
]}
onPointerUp={(event) => {
event.stopPropagation();
props.onSelectLayer(layer);
}}
>
<Plate
selected={
props.activeLayer === layer || props.activeLayer === "overview"
}
/>
{layer === "interface" ? (
<InterfacePlate texture={textures.screen} />
) : (
<>
<mesh position={[0, 0, 0.12]}>
<planeGeometry args={[4.02, 2.5]} />
<meshBasicMaterial map={textures[layer]} toneMapped={false} />
</mesh>
{layer === "integrations" ? (
<IntegrationNodes />
) : (
<DataVolumes />
)}
</>
)}
</group>
))}
</group>
</>
);
}
function SceneContent(
props: HeroSystemSceneProps & { reducedMotion: boolean },
) {
const textures = useSceneTextures(props.onError);
const gl = useThree((state) => state.gl);
useEffect(() => {
const canvas = gl.domElement;
const handleContextLost = (event: Event) => {
event.preventDefault();
props.onError();
};
canvas.addEventListener("webglcontextlost", handleContextLost);
return () =>
canvas.removeEventListener("webglcontextlost", handleContextLost);
}, [gl, props.onError]);
return textures ? <SystemAssembly {...props} textures={textures} /> : null;
}
class SceneBoundary extends Component<
{ children: ReactNode; onError: () => void },
{ failed: boolean }
> {
state = { failed: false };
static getDerivedStateFromError() {
return { failed: true };
}
componentDidCatch() {
this.props.onError();
}
render() {
return this.state.failed ? null : this.props.children;
}
}
export default function HeroSystemScene(props: HeroSystemSceneProps) {
const [tabVisible, setTabVisible] = useState(false);
const [started, setStarted] = useState(false);
const [reducedMotion, setReducedMotion] = useState(false);
useEffect(() => {
const media = window.matchMedia("(prefers-reduced-motion: reduce)");
const updateVisibility = () => setTabVisible(!document.hidden);
const updateMotion = () => setReducedMotion(media.matches);
updateVisibility();
updateMotion();
document.addEventListener("visibilitychange", updateVisibility);
media.addEventListener("change", updateMotion);
return () => {
document.removeEventListener("visibilitychange", updateVisibility);
media.removeEventListener("change", updateMotion);
};
}, []);
const visible = props.visible && tabVisible;
useEffect(() => {
if (visible) setStarted(true);
}, [visible]);
if (!started) return null;
return (
<SceneBoundary onError={props.onError}>
<Canvas
aria-hidden="true"
style={{ width: "100%", height: "100%" }}
camera={{ fov: 32, position: [0, 0, 10], near: 0.1, far: 40 }}
dpr={[1, 1.5]}
frameloop={visible ? "demand" : "never"}
gl={{ alpha: true, antialias: true, powerPreference: "low-power" }}
>
<Suspense fallback={null}>
<SceneContent
{...props}
visible={visible}
reducedMotion={reducedMotion}
/>
</Suspense>
</Canvas>
</SceneBoundary>
);
}