"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 = { 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(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 ( <> {[-1.96, 1.96].map((x) => ( ))} ); } function InterfacePlate({ texture }: { texture: THREE.Texture }) { return ( <> ); } function IntegrationNodes() { return ( <> {NODE_X.map((x) => ( ))} {[-0.15, 0, 0.15].map((y) => ( ))} ); } function DataVolumes() { return ( <> {[-1.25, 0].map((x) => ( {SERVER_ROWS.map((y) => ( ))} ))} {[-0.2, 0, 0.2].map((y) => ( ))} ); } function SystemAssembly( props: HeroSystemSceneProps & { textures: SceneTextures; reducedMotion: boolean; }, ) { const { textures, reducedMotion } = props; const root = useRef(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 ( <> {LAYERS.map((layer, index) => ( { 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); }} > {layer === "interface" ? ( ) : ( <> {layer === "integrations" ? ( ) : ( )} )} ))} ); } 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 ? : 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 ( ); }