578 lines
17 KiB
TypeScript
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>
|
|
);
|
|
}
|