+
Evandro Fonseca Junior · Engenharia de software
-
- Sistemas sob medida para
- operações que não cabem em soluções prontas.
+ {/* Sem opacity:0: as palavras pintam no primeiro quadro (LCP); a entrada anima só posição e foco. */}
+
+ {TITLE.map((word, index) => (
+
+
+ {word}
+ {" "}
+
+ ))}
-
+
Projeto e desenvolvo sistemas web, aplicativos e integrações, do
entendimento do negócio à arquitetura e à entrega em produção.
-
+
Ver projetos entregues
-
- Santa Catarina · Atendimento remoto
-
-
-
-
-
- {items.length > 0 && (
-
- {items.map(([value, label]) => (
-
-
- {label}
-
-
- {value}
-
-
- ))}
-
- )}
-
- {["Sistemas web", "Aplicativos", "Integrações"].map((service) => (
-
-
- {service}
-
- ))}
-
-
- {SOCIAL_LINKS.map((link) => (
-
- {link.label}
-
- ))}
+
+
+
+ {items.length > 0 && (
+
+ {items.map(([value, label]) => (
+
+
+ {label}
+
+
+ {value}
+
+
+ ))}
+
+ )}
+
+
+ {["Sistemas web", "Aplicativos", "Integrações"].map(
+ (service) => (
+
+
+ {service}
+
+ ),
+ )}
+
+
+ {SOCIAL_LINKS.map((link) => (
+
+ {link.label}
+
+ ))}
+
+ Santa Catarina · Atendimento remoto
+
+
+
+
+ Role para mergulhar
+
+
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 (
+
+ {mount && !failed && (
+
+ )}
+
+ );
+}
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
-
-
-
-
-
-
-
- {renderScene && (
-
-
-
- )}
- {sceneActive && (
-
-
- Mova ou arraste para explorar
-
-
- Arraste para os lados para explorar
-
-
- )}
-
-
-
-
- {LAYERS.map((layer) => (
- {
- suppressClickRef.current = false;
- setActiveLayer(layer.id);
- if (layer.id === "overview" && sceneActive && visible)
- queuePose(0, 0);
- }}
- className={`min-h-11 px-sp-2 py-sp-2 text-[12px] font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--slash)] motion-reduce:transition-none sm:text-[13px] ${activeLayer === layer.id ? "bg-[var(--slash)] text-[var(--ink)]" : "bg-white/5 text-white/75 hover:bg-white/10 hover:text-white"}`}
- >
- {layer.label}
-
- ))}
-
-
- {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;