From 0ec01944417f671e3cb98d32e5d15ba17a9f44ce Mon Sep 17 00:00:00 2001 From: Evandro Fonseca Junior Date: Mon, 5 Oct 2026 00:22:18 -0300 Subject: [PATCH] feat(hero): campo de particulas WebGL com mergulho guiado pela rolagem e transicao de secoes em CSS Tres camadas de pontos ligadas por colunas de dados, animadas no vertex shader; o hero fica fixo em telas grandes enquanto a camera atravessa as camadas. Secoes sobem como cartoes via animation-timeline: view() (sem JS). Co-Authored-By: Claude Opus 5.5 --- app/components/Hero.test.tsx | 10 +- app/components/Hero.tsx | 151 +++-- app/components/cinematic/HeroField.tsx | 102 ++++ app/components/cinematic/HeroFieldScene.tsx | 338 +++++++++++ app/components/cinematic/HeroSystem.tsx | 350 ----------- app/components/cinematic/HeroSystemScene.tsx | 577 ------------------- app/globals.css | 167 +++++- 7 files changed, 694 insertions(+), 1001 deletions(-) create mode 100644 app/components/cinematic/HeroField.tsx create mode 100644 app/components/cinematic/HeroFieldScene.tsx delete mode 100644 app/components/cinematic/HeroSystem.tsx delete mode 100644 app/components/cinematic/HeroSystemScene.tsx diff --git a/app/components/Hero.test.tsx b/app/components/Hero.test.tsx index fe5ee34..20aa627 100644 --- a/app/components/Hero.test.tsx +++ b/app/components/Hero.test.tsx @@ -50,9 +50,13 @@ describe("Hero", () => { "href", "/cases", ); - expect(screen.getByRole("link", { name: /nutriscan/i })).toHaveAttribute( - "href", - "/cases/nutriscan", + }); + + it("keeps the headline as one readable sentence despite the per-word animation", () => { + render(); + + expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent( + "Sistemas sob medida para operações que não cabem em soluções prontas.", ); }); diff --git a/app/components/Hero.tsx b/app/components/Hero.tsx index 6f41ee7..af8125b 100644 --- a/app/components/Hero.tsx +++ b/app/components/Hero.tsx @@ -1,5 +1,6 @@ import Link from "next/link"; -import HeroSystem from "./cinematic/HeroSystem"; +import { type CSSProperties, Fragment } from "react"; +import HeroField from "./cinematic/HeroField"; const SOCIAL_LINKS = [ { @@ -32,32 +33,47 @@ function statItems(stats?: HeroStats) { ].filter((item): item is [string, string] => item !== null); } +/** Título em palavras para a entrada em cascata; o texto segue inteiro para leitores de tela e buscadores. */ +const TITLE = "Sistemas sob medida para operações que não cabem em soluções prontas.".split(" "); +const ACCENT = new Set([1, 2]); + export default function Hero({ stats }: { stats?: HeroStats }) { const items = statItems(stats); return (
-
diff --git a/app/components/cinematic/HeroField.tsx b/app/components/cinematic/HeroField.tsx new file mode 100644 index 0000000..dd590ae --- /dev/null +++ b/app/components/cinematic/HeroField.tsx @@ -0,0 +1,102 @@ +"use client"; + +import dynamic from "next/dynamic"; +import { useCallback, useEffect, useRef, useState } from "react"; +import type { HeroFieldSceneProps } from "./HeroFieldScene"; + +const Scene = dynamic(() => import("./HeroFieldScene"), { + ssr: false, +}); + +function supportsWebGL2() { + if (typeof window.WebGL2RenderingContext === "undefined") return false; + try { + const context = document.createElement("canvas").getContext("webgl2"); + context?.getExtension("WEBGL_lose_context")?.loseContext(); + return context !== null; + } catch { + return false; + } +} + +/** + * Fundo do hero: liga a rolagem do hero à variável CSS --hero-p (0 no topo, 1 no fim do mergulho) + * e monta a cena WebGL só quando a página fica ociosa, com WebGL2 e sem preferência por movimento reduzido. + */ +export default function HeroField() { + const rootRef = useRef(null); + const progressRef = useRef(0); + const [mount, setMount] = useState(false); + const [visible, setVisible] = useState(false); + const [ready, setReady] = useState(false); + const [failed, setFailed] = useState(false); + + useEffect(() => { + const section = rootRef.current?.closest(".hero"); + if (!section) return; + let frame = 0; + const update = () => { + frame = 0; + const rect = section.getBoundingClientRect(); + const span = Math.max(rect.height - window.innerHeight, window.innerHeight * 0.8); + const p = Math.min(1, Math.max(0, -rect.top / span)); + progressRef.current = p; + section.style.setProperty("--hero-p", p.toFixed(4)); + }; + const queue = () => { + if (!frame) frame = requestAnimationFrame(update); + }; + update(); + window.addEventListener("scroll", queue, { passive: true }); + window.addEventListener("resize", queue); + + let intersecting = false; + const sync = () => setVisible(intersecting && !document.hidden); + const observer = new IntersectionObserver(([entry]) => { + intersecting = entry.isIntersecting; + sync(); + }); + observer.observe(section); + document.addEventListener("visibilitychange", sync); + return () => { + cancelAnimationFrame(frame); + window.removeEventListener("scroll", queue); + window.removeEventListener("resize", queue); + observer.disconnect(); + document.removeEventListener("visibilitychange", sync); + }; + }, []); + + useEffect(() => { + if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; + if (!supportsWebGL2()) return; + // Título, botões e hidratação vêm primeiro; o Three.js entra quando o navegador fica ocioso. + if (typeof window.requestIdleCallback === "function") { + const id = window.requestIdleCallback(() => setMount(true), { timeout: 2500 }); + return () => window.cancelIdleCallback(id); + } + const id = setTimeout(() => setMount(true), 1200); + return () => clearTimeout(id); + }, []); + + const handleReady = useCallback(() => setReady(true), []); + const handleError = useCallback(() => setFailed(true), []); + + return ( + + ); +} diff --git a/app/components/cinematic/HeroFieldScene.tsx b/app/components/cinematic/HeroFieldScene.tsx new file mode 100644 index 0000000..d0e0e47 --- /dev/null +++ b/app/components/cinematic/HeroFieldScene.tsx @@ -0,0 +1,338 @@ +"use client"; + +import { Canvas, useFrame, useThree } from "@react-three/fiber"; +import { + Component, + type ReactNode, + type RefObject, + useEffect, + useMemo, + useRef, +} from "react"; +import * as THREE from "three"; + +export type HeroFieldSceneProps = { + progressRef: RefObject; + visible: boolean; + onReady: () => void; + onError: () => void; +}; + +/* + * Três camadas de pontos (interface, integrações, dados) ligadas por colunas de partículas que sobem + * como dados. Na entrada, os pontos saem de uma nuvem e se organizam; ao rolar, as camadas se afastam, + * ficam de frente e a câmera atravessa as três até o escuro da próxima seção. + * Tudo acontece no vertex shader: o JavaScript só atualiza meia dúzia de uniforms por quadro. + */ + +const RADIUS = 2.35; + +function buildField(dense: boolean) { + const grid = dense ? 76 : 46; + const columns = dense ? 18 : 10; + const perColumn = dense ? 64 : 40; + const base: number[] = []; + const kind: number[] = []; + const step = (RADIUS * 2) / (grid - 1); + for (const layer of [-1, 0, 1]) { + for (let i = 0; i < grid; i++) { + for (let j = 0; j < grid; j++) { + // leve desalinhamento: quebra o moiré da grade perfeita + const x = -RADIUS + (i + (Math.random() - 0.5) * 0.35) * step; + const z = -RADIUS + (j + (Math.random() - 0.5) * 0.35) * step; + if (Math.hypot(x, z) > RADIUS) continue; + base.push(x, layer, z); + kind.push(0); + } + } + } + for (let c = 0; c < columns; c++) { + const angle = c * 2.39996 + 0.4; // ângulo áureo: colunas espalhadas sem se amontoar + const r = Math.sqrt((c + 0.5) / columns) * RADIUS * 0.78; + const x = Math.round((Math.cos(angle) * r) / step) * step; + const z = Math.round((Math.sin(angle) * r) / step) * step; + for (let k = 0; k < perColumn; k++) { + base.push(x, 0, z); + kind.push(1); + } + for (const layer of [-1, 0, 1]) { + base.push(x, layer, z); + kind.push(2); + } + } + const count = kind.length; + const scatter = new Float32Array(count * 3); + const rand = new Float32Array(count * 4); + for (let n = 0; n < count; n++) { + const u = Math.random() * 2 - 1; + const t = Math.random() * Math.PI * 2; + const s = Math.sqrt(1 - u * u); + const d = 4 + Math.random() * 7; + scatter.set([Math.cos(t) * s * d, u * d, Math.sin(t) * s * d], n * 3); + rand.set([Math.random(), Math.random(), Math.random(), Math.random()], n * 4); + } + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute("position", new THREE.Float32BufferAttribute(base, 3)); + geometry.setAttribute("aScatter", new THREE.BufferAttribute(scatter, 3)); + geometry.setAttribute("aRand", new THREE.BufferAttribute(rand, 4)); + geometry.setAttribute("aKind", new THREE.Float32BufferAttribute(kind, 1)); + geometry.boundingSphere = new THREE.Sphere(new THREE.Vector3(), 12); + return geometry; +} + +const vertexShader = /* glsl */ ` + uniform float uTime; + uniform float uIntro; + uniform float uSpread; + uniform float uScroll; + uniform float uScale; + uniform float uFocus; + uniform float uFade; + uniform vec2 uMouse; + uniform float uMouseAmt; + attribute vec3 aScatter; + attribute vec4 aRand; + attribute float aKind; + varying vec3 vColor; + varying float vAlpha; + varying float vSoft; + + const vec3 PAPER = vec3(0.957, 0.949, 0.929); + const vec3 LIME = vec3(0.8, 1.0, 0.0); + + void main() { + float layer = position.y; + float isPlane = 1.0 - step(0.5, aKind); + float isStream = step(0.5, aKind) * (1.0 - step(1.5, aKind)); + float isNode = step(1.5, aKind); + + // colunas: cada partícula sobe da camada de dados até a interface e recomeça + float flow = fract(uTime * (0.08 + aRand.x * 0.07) + aRand.y); + layer = mix(layer, flow * 2.0 - 1.0, isStream); + + vec3 p = vec3(position.x, layer * uSpread, position.z); + float wave = sin(position.x * 1.7 + uTime * 0.55 + layer * 2.1) * cos(position.z * 1.3 - uTime * 0.4); + p.y += wave * (0.045 + uScroll * 0.25) * isPlane; + p.xz += (aRand.zw - 0.5) * 0.05 * isStream; + + // cursor: uma onda que levanta a malha e acende os pontos em verde + float d = length(position.xz - uMouse); + float lift = exp(-d * d * 2.2) * uMouseAmt; + p.y += (lift * 0.32 + sin(d * 7.0 - uTime * 3.5) * lift * 0.05) * isPlane; + + // entrada: cada ponto sai da nuvem com atraso próprio + float t = clamp((uIntro - aRand.z * 0.4) / 0.6, 0.0, 1.0); + float e = 1.0 - pow(1.0 - t, 4.0); + p = mix(aScatter, p, e); + + vec4 mv = modelViewMatrix * vec4(p, 1.0); + gl_Position = projectionMatrix * mv; + float depth = -mv.z; + + float size = 0.021 * isPlane * (0.7 + aRand.w * 0.6) + 0.024 * isStream + 0.07 * isNode; + float blur = clamp(abs(depth - uFocus) * 0.32, 0.0, 1.6); + gl_PointSize = min(size * uScale / max(depth, 0.05) * (1.0 + blur * 1.4), 64.0); + + // cada camada é um disco: miolo que esmaece para a borda e um aro fino que desenha o contorno + float radial = length(position.xz); + float edge = 1.0 - smoothstep(RADIUS_IN, RADIUS_OUT, radial); + float rim = smoothstep(RADIUS_OUT - 0.16, RADIUS_OUT - 0.04, radial); + float alpha = isPlane * ((0.5 + 0.2 * step(0.5, position.y)) * edge + rim * 0.75) + isStream * 0.55 + isNode; + alpha *= (0.25 + 0.75 * e) / (1.0 + blur * 1.6); + alpha *= smoothstep(0.25, 1.6, depth) * uFade; + vAlpha = alpha; + vSoft = clamp(0.35 + blur * 0.45, 0.35, 1.0); + vColor = mix(PAPER, LIME, clamp(isStream + isNode + (lift * 1.6 + rim * 0.55) * isPlane, 0.0, 1.0)); + } +` + .replaceAll("RADIUS_IN", (RADIUS * 0.62).toFixed(2)) + .replaceAll("RADIUS_OUT", RADIUS.toFixed(2)); + +const fragmentShader = /* glsl */ ` + varying vec3 vColor; + varying float vAlpha; + varying float vSoft; + void main() { + float r = length(gl_PointCoord - 0.5) * 2.0; + float a = 1.0 - smoothstep(1.0 - vSoft, 1.0, r); + if (a <= 0.0) discard; + gl_FragColor = vec4(vColor, a * vAlpha); + } +`; + +const smooth = (a: number, b: number, x: number) => { + const t = Math.min(1, Math.max(0, (x - a) / (b - a))); + return t * t * (3 - 2 * t); +}; + +function Field({ progressRef, onReady, onError }: HeroFieldSceneProps) { + const { gl, camera, size } = useThree(); + const group = useRef(null); + const dense = useMemo( + () => window.innerWidth >= 900 && (navigator.hardwareConcurrency ?? 4) >= 4, + [], + ); + const geometry = useMemo(() => buildField(dense), [dense]); + const material = useMemo( + () => + new THREE.ShaderMaterial({ + vertexShader, + fragmentShader, + transparent: true, + depthWrite: false, + blending: THREE.AdditiveBlending, + uniforms: { + uTime: { value: 0 }, + uIntro: { value: 0 }, + uSpread: { value: 1.05 }, + uScroll: { value: 0 }, + uScale: { value: 1 }, + uFocus: { value: 9 }, + uFade: { value: 1 }, + uMouse: { value: new THREE.Vector2(9, 9) }, + uMouseAmt: { value: 0 }, + }, + }), + [], + ); + const state = useRef({ + progress: 0, + pointer: new THREE.Vector2(), + lastMove: -10, + mouse: new THREE.Vector2(), + tilt: new THREE.Vector2(), + frames: 0, + }); + const helpers = useMemo( + () => ({ + ray: new THREE.Raycaster(), + plane: new THREE.Plane(), + hit: new THREE.Vector3(), + }), + [], + ); + + useEffect(() => { + const onMove = (event: PointerEvent) => { + if (event.pointerType !== "mouse") return; + const s = state.current; + s.pointer.set( + (event.clientX / window.innerWidth) * 2 - 1, + -(event.clientY / window.innerHeight) * 2 + 1, + ); + s.lastMove = performance.now() / 1000; + }; + const onLost = (event: Event) => { + event.preventDefault(); + onError(); + }; + window.addEventListener("pointermove", onMove, { passive: true }); + gl.domElement.addEventListener("webglcontextlost", onLost); + return () => { + window.removeEventListener("pointermove", onMove); + gl.domElement.removeEventListener("webglcontextlost", onLost); + geometry.dispose(); + material.dispose(); + }; + }, [gl, geometry, material, onError]); + + useFrame((frame, delta) => { + const g = group.current; + if (!g) return; + const dt = Math.min(delta, 1 / 20); + const s = state.current; + const u = material.uniforms; + const time = frame.clock.elapsedTime; + + // rolagem amortecida: o mergulho segue o dedo com um leve atraso de câmera + s.progress += ((progressRef.current ?? 0) - s.progress) * (1 - Math.exp(-dt * 5)); + const p = s.progress; + const wide = size.width / size.height > 1.1; + + u.uTime.value = time; + u.uIntro.value = Math.min(1, u.uIntro.value + dt / 2.6); + u.uScroll.value = p; + u.uSpread.value = 1.05 + 1.3 * smooth(0, 0.55, p); + u.uFade.value = 1 - smooth(0.86, 1, p); + u.uScale.value = + (gl.getPixelRatio() * size.height * 0.5) / + Math.tan(THREE.MathUtils.degToRad((camera as THREE.PerspectiveCamera).fov / 2)); + + // sem mouse (toque, ou mouse parado), um cursor fantasma percorre a malha + const mouseActive = time - s.lastMove < 2.5 && s.lastMove > 0 ? 1 : 0; + s.tilt.lerp(mouseActive ? s.pointer : new THREE.Vector2(), 1 - Math.exp(-dt * 3)); + + const face = smooth(0.04, 0.6, p); + g.rotation.set( + 0.42 + (Math.PI / 2 - 0.42) * face - s.tilt.y * 0.08, + time * 0.045 + s.tilt.x * 0.18, + 0, + ); + const settle = 1 - smooth(0, 0.4, p); + g.position.set(wide ? 1.85 * settle : 0, wide ? -0.15 * settle : -1.25 * settle, 0); + const dive = smooth(0.22, 1, p); + camera.position.z = 9 - 12.5 * dive * dive; + u.uFocus.value = Math.max(1.5, camera.position.z - g.position.z); + g.updateMatrixWorld(); + + if (mouseActive) { + helpers.ray.setFromCamera(s.pointer, camera); + helpers.plane + .set(new THREE.Vector3(0, 1, 0), -u.uSpread.value) + .applyMatrix4(g.matrixWorld); + if (helpers.ray.ray.intersectPlane(helpers.plane, helpers.hit)) { + g.worldToLocal(helpers.hit); + s.mouse.lerp(new THREE.Vector2(helpers.hit.x, helpers.hit.z), 1 - Math.exp(-dt * 10)); + } + } else { + s.mouse.lerp( + new THREE.Vector2(Math.sin(time * 0.31) * 1.5, Math.sin(time * 0.23 + 1.3) * 1.3), + 1 - Math.exp(-dt * 1.5), + ); + } + u.uMouse.value.copy(s.mouse); + const targetAmt = (mouseActive ? 1 : 0.55) * (1 - smooth(0.05, 0.3, p)); + u.uMouseAmt.value += (targetAmt - u.uMouseAmt.value) * (1 - Math.exp(-dt * 4)); + + if (++s.frames === 2) onReady(); + }); + + return ( + + + + ); +} + +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 HeroFieldScene(props: HeroFieldSceneProps) { + return ( + + + + ); +} diff --git a/app/components/cinematic/HeroSystem.tsx b/app/components/cinematic/HeroSystem.tsx deleted file mode 100644 index c1e915c..0000000 --- a/app/components/cinematic/HeroSystem.tsx +++ /dev/null @@ -1,350 +0,0 @@ -"use client"; - -import dynamic from "next/dynamic"; -import Image from "next/image"; -import Link from "next/link"; -import type { PointerEvent } from "react"; -import { useCallback, useEffect, useId, useRef, useState } from "react"; -import type { HeroSystemLayer, HeroSystemSceneProps } from "./HeroSystemScene"; - -const DynamicScene = dynamic( - () => import("./HeroSystemScene"), - { ssr: false }, -); - -const LAYERS: { id: HeroSystemLayer; label: string; description: string }[] = [ - { - id: "overview", - label: "Visão geral", - description: - "Da tela ao que acontece por trás: interface, integrações e dados trabalhando juntos.", - }, - { - id: "interface", - label: "Interface", - description: - "A parte que as pessoas usam. Aqui, uma tela real do NutriScan, projeto do portfólio.", - }, - { - id: "integrations", - label: "Integrações", - description: - "Conexões entre sistemas para levar informações de uma etapa da operação à próxima.", - }, - { - id: "data", - label: "Dados", - description: - "A base que organiza e guarda as informações de que a operação precisa.", - }, -]; - -const REST_POSE = { x: 0, y: 0 }; -type Gesture = { - id: number; - x: number; - y: number; - startPose: number; - width: number; - dragging: boolean; -}; - -export default function HeroSystem() { - const descriptionId = useId(); - const viewportRef = useRef(null); - const frameRef = useRef(null); - const nextPoseRef = useRef(REST_POSE); - const gestureRef = useRef(null); - const suppressClickRef = useRef(false); - const [activeLayer, setActiveLayer] = useState("overview"); - const [pose, setPose] = useState(REST_POSE); - const [canRender, setCanRender] = useState(false); - const [ready, setReady] = useState(false); - const [failed, setFailed] = useState(false); - const [visible, setVisible] = useState(false); - const renderScene = canRender && !failed; - const sceneActive = renderScene && ready; - - useEffect(() => { - const preference = window.matchMedia("(prefers-reduced-motion: reduce)"); - let supported: boolean | undefined; - const updateCapability = () => { - if (preference.matches) { - setCanRender(false); - setReady(false); - return; - } - if (supported === undefined) { - if (typeof window.WebGL2RenderingContext === "undefined") { - supported = false; - } else { - try { - const canvas = document.createElement("canvas"); - const context = canvas.getContext("webgl2"); - supported = context !== null; - context?.getExtension("WEBGL_lose_context")?.loseContext(); - } catch { - supported = false; - } - } - } - mountScene(supported); - }; - // A cena 3D (Three.js) so entra depois que a pagina ficou ociosa: titulo, imagem e botoes pintam e hidratam primeiro. - let idle: number | undefined; - let timer: ReturnType | undefined; - const cancelPending = () => { - if (idle !== undefined) window.cancelIdleCallback?.(idle); - if (timer !== undefined) clearTimeout(timer); - idle = timer = undefined; - }; - const mountScene = (value: boolean) => { - cancelPending(); - if (!value) { - setCanRender(false); - return; - } - if (typeof window.requestIdleCallback === "function") { - idle = window.requestIdleCallback(() => setCanRender(true), { - timeout: 2500, - }); - } else { - timer = setTimeout(() => setCanRender(true), 1200); - } - }; - updateCapability(); - preference.addEventListener("change", updateCapability); - return () => { - cancelPending(); - preference.removeEventListener("change", updateCapability); - }; - }, []); - - useEffect(() => { - const viewport = viewportRef.current; - if (!viewport) return; - let intersecting = false; - const updateVisibility = () => { - setVisible(intersecting && document.visibilityState === "visible"); - }; - const observer = new IntersectionObserver(([entry]) => { - intersecting = entry.isIntersecting; - updateVisibility(); - }); - observer.observe(viewport); - document.addEventListener("visibilitychange", updateVisibility); - return () => { - observer.disconnect(); - document.removeEventListener("visibilitychange", updateVisibility); - }; - }, []); - - const queuePose = useCallback((x: number, y: number) => { - nextPoseRef.current = { - x: Math.max(-1, Math.min(1, x)), - y: Math.max(-1, Math.min(1, y)), - }; - if (frameRef.current !== null) return; - frameRef.current = window.requestAnimationFrame(() => { - frameRef.current = null; - setPose(nextPoseRef.current); - }); - }, []); - - const releaseGesture = useCallback(() => { - const gesture = gestureRef.current; - gestureRef.current = null; - const viewport = viewportRef.current; - if (gesture && viewport?.hasPointerCapture(gesture.id)) { - viewport.releasePointerCapture(gesture.id); - } - }, []); - - useEffect(() => { - if (!sceneActive || !visible) { - releaseGesture(); - if (frameRef.current !== null) - window.cancelAnimationFrame(frameRef.current); - frameRef.current = null; - nextPoseRef.current = REST_POSE; - setPose(REST_POSE); - } - return () => { - releaseGesture(); - if (frameRef.current !== null) - window.cancelAnimationFrame(frameRef.current); - frameRef.current = null; - }; - }, [sceneActive, visible, releaseGesture]); - - const handleReady = useCallback(() => setReady(true), []); - const handleError = useCallback(() => { - setFailed(true); - setReady(false); - }, []); - const selectSceneLayer = useCallback((layer: HeroSystemLayer) => { - if (!suppressClickRef.current) setActiveLayer(layer); - }, []); - - const handlePointerDown = (event: PointerEvent) => { - if (!sceneActive || !visible || !event.isPrimary || event.button !== 0) - return; - suppressClickRef.current = false; - gestureRef.current = { - id: event.pointerId, - x: event.clientX, - y: event.clientY, - startPose: nextPoseRef.current.x, - width: event.currentTarget.clientWidth, - dragging: false, - }; - }; - - const handlePointerMove = (event: PointerEvent) => { - if (!sceneActive || !visible) return; - const gesture = gestureRef.current; - if (gesture && gesture.id === event.pointerId) { - const dx = event.clientX - gesture.x; - const dy = event.clientY - gesture.y; - if (!gesture.dragging) { - if (Math.abs(dy) > 7 && Math.abs(dy) > Math.abs(dx)) { - releaseGesture(); - return; - } - if (Math.abs(dx) < 7) return; - gesture.dragging = true; - suppressClickRef.current = true; - event.currentTarget.setPointerCapture(event.pointerId); - } - queuePose(gesture.startPose + (dx / Math.max(gesture.width, 1)) * 3, 0); - return; - } - if (event.pointerType !== "mouse" || event.buttons !== 0) return; - const bounds = event.currentTarget.getBoundingClientRect(); - queuePose( - ((event.clientX - bounds.left) / Math.max(bounds.width, 1)) * 2 - 1, - ((event.clientY - bounds.top) / Math.max(bounds.height, 1)) * 2 - 1, - ); - }; - - const cancelGesture = () => { - releaseGesture(); - if (sceneActive && visible) queuePose(0, 0); - }; - - return ( -
-
{ - if (!gestureRef.current?.dragging) cancelGesture(); - }} - onClickCapture={(event) => { - if (suppressClickRef.current) event.stopPropagation(); - }} - > -
-
-
- - Interface real: NutriScan - -
-
- Tela real do projeto NutriScan. -
-
-
- {renderScene && ( - - )} - {sceneActive && ( -

- - Mova ou arraste para explorar - - - Arraste para os lados para explorar - -

- )} -
- -
-
- {LAYERS.map((layer) => ( - - ))} -
-

- {LAYERS.find((layer) => layer.id === activeLayer)?.description} -

-
-

- Camadas ilustrativas de um sistema -

- - Interface real: NutriScan - -
-
-
- ); -} diff --git a/app/components/cinematic/HeroSystemScene.tsx b/app/components/cinematic/HeroSystemScene.tsx deleted file mode 100644 index 6991d88..0000000 --- a/app/components/cinematic/HeroSystemScene.tsx +++ /dev/null @@ -1,577 +0,0 @@ -"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 ( - - - - ); -} diff --git a/app/globals.css b/app/globals.css index 88fbc1b..111c05c 100644 --- a/app/globals.css +++ b/app/globals.css @@ -255,6 +255,7 @@ textarea { .ds-section { padding: clamp(72px, 9vw, 112px) clamp(24px, 6vw, 64px); + background: var(--paper); border-bottom: 1px solid var(--border); } @@ -986,30 +987,176 @@ textarea { radial-gradient(40% 50% at 6% 92%, rgba(204, 255, 0, 0.05), transparent 70%); } -.hero-rise > * { - animation: hero-rise 0.8s var(--ease-out) both; +/* Hero: palco de 100svh; em telas grandes ele fica fixo enquanto a rolagem conduz o mergulho da cena 3D. + --hero-p (0 a 1) vem de cinematic/HeroField.tsx. */ +.hero { + --hero-p: 0; + isolation: isolate; +} - &:nth-child(2) { - animation-delay: 0.08s; - } +.hero-stage { + position: relative; + display: flex; + flex-direction: column; + min-height: 100svh; + overflow: hidden; +} - &:nth-child(3) { - animation-delay: 0.16s; +/* Sem WebGL (ou antes de a cena carregar): malha de pontos estática no lugar das camadas */ +.hero-glow::after { + content: ""; + position: absolute; + inset: 0; + background: radial-gradient(circle, rgba(244, 242, 237, 0.22) 1px, transparent 1.5px) 0 0 / 22px 22px; + mask: radial-gradient(38% 46% at 74% 50%, #000, transparent 75%); + transition: opacity 1.2s var(--ease-out); +} + +.hero:has(.hero-field[data-ready]) .hero-glow::after { + opacity: 0; +} + +.hero-field { + position: absolute; + inset: 0; + opacity: 0; + transition: opacity 1.6s var(--ease-out); + + &[data-ready] { + opacity: 1; } } -@keyframes hero-rise { +.hero-scrim { + position: absolute; + inset: 0; + pointer-events: none; + background: + linear-gradient(90deg, rgba(10, 10, 10, 0.86) 0%, rgba(10, 10, 10, 0.55) 36%, transparent 62%), + linear-gradient(0deg, rgba(10, 10, 10, 0.9), transparent 28%); + + @media (max-width: 1023px) { + background: linear-gradient(0deg, rgba(10, 10, 10, 0.92) 0 30%, rgba(10, 10, 10, 0.5) 65%, rgba(10, 10, 10, 0.7)); + } +} + +/* Entrada: as palavras do título já pintam no primeiro quadro (sem opacity:0, bom para o LCP) e ganham foco em cascata */ +.hero-word { + display: inline-block; + animation: hero-word 1.1s var(--ease-out) both; + animation-delay: calc(0.12s + var(--i) * 45ms); +} + +@keyframes hero-word { from { - transform: translateY(16px); + transform: translateY(0.42em) rotate(2deg); + filter: blur(12px); + } +} + +.hero-in, +.hero-foot > * { + animation: hero-in 1s var(--ease-out) both; + animation-delay: var(--d, 0.05s); +} + +.hero-foot > * { + --d: 0.85s; +} + +@keyframes hero-in { + from { + opacity: 0; + transform: translateY(18px); + } +} + +.hero-cue-line { + width: 56px; + height: 1px; + background: currentColor; + transform-origin: left; + animation: hero-cue 2.4s var(--ease-out) infinite; +} + +@keyframes hero-cue { + 0% { + transform: scaleX(0); + } + 60% { + transform: scaleX(1); + opacity: 1; + } + 100% { + transform: scaleX(1); + opacity: 0; + } +} + +@media (min-width: 1024px) and (min-height: 700px) and (prefers-reduced-motion: no-preference) { + .hero { + height: 220svh; + } + + .hero-stage { + position: sticky; + top: 0; + height: 100svh; + } + + /* o texto sai de cena enquanto a câmera mergulha: sobe, encolhe e perde o foco */ + .hero-content { + transform: translate3d(0, calc(var(--hero-p) * -16vh), 0) scale(calc(1 - var(--hero-p) * 0.08)); + transform-origin: 0 50%; + opacity: calc(1 - var(--hero-p) * 2.6); + filter: blur(calc(var(--hero-p) * 18px)); + } + + .hero-foot { + opacity: calc(1 - var(--hero-p) * 5); + } + + .hero-scrim { + opacity: calc(1 - var(--hero-p) * 1.4); } } @media (prefers-reduced-motion: reduce) { - .hero-rise > * { + .hero-word, + .hero-in, + .hero-foot > *, + .hero-cue-line { animation: none; } } +/* Transição entre seções: cada seção sobe como um cartão e abre até a borda da tela. + CSS puro com linha do tempo de rolagem (Chrome, Edge, Safari 26+); sem suporte, a seção só aparece. */ +.ds-main { + background: var(--ink); +} + +@keyframes section-rise { + from { + clip-path: inset(8vh 4vw 0 4vw round 28px); + translate: 0 12vh; + } + to { + clip-path: inset(0 0 0 0 round 0); + translate: 0 0; + } +} + +@supports (animation-timeline: view()) { + @media (prefers-reduced-motion: no-preference) { + .ds-main > section:not(.hero, .notebook-story) { + animation: section-rise linear both; + animation-timeline: view(); + animation-range: entry 0% entry 85vh; + } + } +} + /* Capa dos cards de projeto: captura real ou painel gerado em codigo */ .ds-cover { position: relative;