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 <[email protected]>
This commit is contained in:
Evandro Fonseca JuniorandClaude Opus 5.5 committed 2026-10-05 00:22:18 -03:00
1 parent 3d47fe9cc7
commit 0ec0194441
7 files changed
+658 -965

No files matched your search

+7 -3
View File
@@ -50,9 +50,13 @@ describe("Hero", () => {
"href", "href",
"/cases", "/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(<Hero />);
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(
"Sistemas sob medida para operações que não cabem em soluções prontas.",
); );
}); });
+54 -25
View File
@@ -1,5 +1,6 @@
import Link from "next/link"; import Link from "next/link";
import HeroSystem from "./cinematic/HeroSystem"; import { type CSSProperties, Fragment } from "react";
import HeroField from "./cinematic/HeroField";
const SOCIAL_LINKS = [ const SOCIAL_LINKS = [
{ {
@@ -32,32 +33,47 @@ function statItems(stats?: HeroStats) {
].filter((item): item is [string, string] => item !== null); ].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 }) { export default function Hero({ stats }: { stats?: HeroStats }) {
const items = statItems(stats); const items = statItems(stats);
return ( return (
<section <section
id="home" id="home"
className="relative w-full overflow-hidden bg-[var(--ink)] font-display text-[#f4f2ed]" className="hero relative w-full bg-[var(--ink)] font-display text-[#f4f2ed]"
> >
<div className="hero-stage">
<div className="hero-glow" aria-hidden="true" /> <div className="hero-glow" aria-hidden="true" />
{/* Sem opacity:0 inicial: o título já vem visível no HTML (LCP). A entrada anima só a posição, em CSS. */} <HeroField />
<div className="hero-rise relative mx-auto grid w-full max-w-[max(1400px,var(--shell))] grid-cols-1 items-center gap-sp-8 px-sp-6 pt-[calc(var(--site-header-height)+32px)] pb-sp-10 lg:grid-cols-[minmax(0,53fr)_minmax(0,47fr)] lg:gap-sp-8 lg:px-sp-12 lg:pb-sp-16"> <div className="hero-scrim" aria-hidden="true" />
<div className="min-w-0">
<p className="mb-sp-5 flex items-center gap-sp-3 font-mono text-[11px] uppercase leading-[1.5] tracking-[0.14em] text-[rgba(244,242,237,.72)] sm:text-[12px]"> <div className="hero-content relative mx-auto flex w-full max-w-[max(1400px,var(--shell))] flex-1 flex-col justify-center px-sp-6 pt-[calc(var(--site-header-height)+32px)] pb-sp-8 lg:px-sp-12">
<p className="hero-in mb-sp-5 flex items-center gap-sp-3 font-mono text-[11px] uppercase leading-[1.5] tracking-[0.14em] text-[rgba(244,242,237,.72)] sm:text-[12px]">
<span aria-hidden="true" className="h-2 w-2 bg-[var(--slash)]" /> <span aria-hidden="true" className="h-2 w-2 bg-[var(--slash)]" />
Evandro Fonseca Junior · Engenharia de software Evandro Fonseca Junior · Engenharia de software
</p> </p>
<h1 className="max-w-[12.5em] text-balance text-[clamp(34px,1.6rem+2.4vw,88px)] font-semibold leading-[1.04] tracking-[-0.045em]"> {/* Sem opacity:0: as palavras pintam no primeiro quadro (LCP); a entrada anima só posição e foco. */}
Sistemas <span className="text-[var(--slash)]">sob medida</span> para <h1 className="hero-title max-w-[12.5em] lg:max-w-[min(11em,54vw)] text-[clamp(38px,min(6vw,10.5svh),124px)] font-semibold leading-[0.98] tracking-[-0.05em]">
operações que não cabem em soluções prontas. {TITLE.map((word, index) => (
<Fragment key={word}>
<span
className={`hero-word${ACCENT.has(index) ? " text-[var(--slash)]" : ""}`}
style={{ "--i": index } as CSSProperties}
>
{word}
</span>{" "}
</Fragment>
))}
</h1> </h1>
<p className="mt-sp-5 max-w-[34em] text-[clamp(16px,0.95rem+0.4vw,22px)] leading-[1.55] text-[rgba(244,242,237,.8)]"> <p className="hero-in mt-sp-5 max-w-[34em] text-[clamp(16px,0.95rem+0.4vw,22px)] leading-[1.55] text-[rgba(244,242,237,.8)] [--d:.55s]">
Projeto e desenvolvo sistemas web, aplicativos e integrações, do Projeto e desenvolvo sistemas web, aplicativos e integrações, do
entendimento do negócio à arquitetura e à entrega em produção. entendimento do negócio à arquitetura e à entrega em produção.
</p> </p>
<div className="mt-sp-6 flex flex-col gap-sp-3 sm:flex-row sm:flex-wrap sm:items-center"> <div className="hero-in mt-sp-6 flex flex-col gap-sp-3 [--d:.65s] sm:flex-row sm:flex-wrap sm:items-center">
<Link <Link
href="/#contact" href="/#contact"
className="group inline-flex min-h-12 items-center justify-center gap-sp-3 bg-[var(--slash)] px-sp-4 py-sp-3 text-center text-[15px] font-semibold leading-6 text-[var(--ink)] no-underline transition-colors hover:bg-[#d9ff40] motion-reduce:transition-none sm:px-sp-6 sm:text-[16px]" className="group inline-flex min-h-12 items-center justify-center gap-sp-3 bg-[var(--slash)] px-sp-4 py-sp-3 text-center text-[15px] font-semibold leading-6 text-[var(--ink)] no-underline transition-colors hover:bg-[#d9ff40] motion-reduce:transition-none sm:px-sp-6 sm:text-[16px]"
@@ -76,35 +92,33 @@ export default function Hero({ stats }: { stats?: HeroStats }) {
</Link> </Link>
<Link <Link
href="/cases" href="/cases"
className="inline-flex min-h-12 items-center justify-center border border-white/12 px-sp-5 py-sp-3 text-center text-[15px] font-medium leading-6 text-[#f4f2ed] no-underline transition-colors hover:border-white/40 motion-reduce:transition-none sm:text-[16px]" className="inline-flex min-h-12 items-center justify-center border border-white/15 bg-[rgba(10,10,10,.35)] px-sp-5 py-sp-3 text-center text-[15px] font-medium leading-6 text-[#f4f2ed] no-underline backdrop-blur-sm transition-colors hover:border-white/40 motion-reduce:transition-none sm:text-[16px]"
> >
Ver projetos entregues Ver projetos entregues
</Link> </Link>
</div> </div>
<p className="mt-sp-3 text-[13px] leading-[1.5] text-[rgba(244,242,237,.64)]">
Santa Catarina · Atendimento remoto
</p>
</div> </div>
<div className="min-w-0 lg:col-start-2 lg:row-start-1 lg:row-span-2">
<HeroSystem /> <div className="hero-foot relative mx-auto flex w-full max-w-[max(1400px,var(--shell))] flex-wrap items-end justify-between gap-sp-5 px-sp-6 pb-sp-5 lg:px-sp-12">
</div> <div className="flex flex-wrap items-end gap-x-sp-8 gap-y-sp-4 border-t border-white/10 pt-sp-4">
<div className="min-w-0 lg:col-start-1 lg:row-start-2">
{items.length > 0 && ( {items.length > 0 && (
<dl className="mb-sp-6 flex flex-wrap gap-x-sp-8 gap-y-sp-4 border-t border-white/10 pt-sp-5"> <dl className="flex flex-wrap gap-x-sp-8 gap-y-sp-4">
{items.map(([value, label]) => ( {items.map(([value, label]) => (
<div key={label} className="flex flex-col-reverse"> <div key={label} className="flex flex-col-reverse">
<dt className="text-[13px] leading-[1.4] text-[rgba(244,242,237,.64)]"> <dt className="text-[13px] leading-[1.4] text-[rgba(244,242,237,.64)]">
{label} {label}
</dt> </dt>
<dd className="font-mono text-[clamp(26px,1.2rem+0.9vw,40px)] font-semibold leading-none tracking-[-0.03em] text-[var(--slash)]"> <dd className="font-mono text-[clamp(24px,1.1rem+0.8vw,36px)] font-semibold leading-none tracking-[-0.03em] text-[var(--slash)]">
{value} {value}
</dd> </dd>
</div> </div>
))} ))}
</dl> </dl>
)} )}
<div>
<ul className="flex flex-wrap gap-x-sp-5 gap-y-sp-2 text-[14px] leading-[1.5] text-[rgba(244,242,237,.8)]"> <ul className="flex flex-wrap gap-x-sp-5 gap-y-sp-2 text-[14px] leading-[1.5] text-[rgba(244,242,237,.8)]">
{["Sistemas web", "Aplicativos", "Integrações"].map((service) => ( {["Sistemas web", "Aplicativos", "Integrações"].map(
(service) => (
<li key={service} className="flex items-center gap-sp-2"> <li key={service} className="flex items-center gap-sp-2">
<span <span
aria-hidden="true" aria-hidden="true"
@@ -112,14 +126,17 @@ export default function Hero({ stats }: { stats?: HeroStats }) {
/> />
{service} {service}
</li> </li>
))} ),
)}
</ul> </ul>
<div className="mt-sp-2 flex flex-wrap gap-x-sp-5"> <div className="flex flex-wrap items-center gap-x-sp-5">
{SOCIAL_LINKS.map((link) => ( {SOCIAL_LINKS.map((link) => (
<a <a
key={link.label} key={link.label}
href={link.href} href={link.href}
target={link.href.startsWith("http") ? "_blank" : undefined} target={
link.href.startsWith("http") ? "_blank" : undefined
}
rel={ rel={
link.href.startsWith("http") link.href.startsWith("http")
? "noopener noreferrer" ? "noopener noreferrer"
@@ -130,9 +147,21 @@ export default function Hero({ stats }: { stats?: HeroStats }) {
{link.label} {link.label}
</a> </a>
))} ))}
<span className="text-[13px] text-[rgba(244,242,237,.55)]">
Santa Catarina · Atendimento remoto
</span>
</div> </div>
</div> </div>
</div> </div>
<p
aria-hidden="true"
className="hero-cue hidden items-center gap-sp-3 font-mono text-[11px] uppercase tracking-[0.14em] text-[rgba(244,242,237,.55)] lg:flex"
>
Role para mergulhar
<span className="hero-cue-line" />
</p>
</div>
</div>
</section> </section>
); );
} }
+102
View File
@@ -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<HeroFieldSceneProps>(() => 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<HTMLDivElement>(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<HTMLElement>(".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 (
<div
ref={rootRef}
aria-hidden="true"
className="hero-field"
data-ready={ready && !failed ? "" : undefined}
>
{mount && !failed && (
<Scene
progressRef={progressRef}
visible={visible}
onReady={handleReady}
onError={handleError}
/>
)}
</div>
);
}
+338
View File
@@ -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<number>;
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<THREE.Group>(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 (
<group ref={group}>
<points geometry={geometry} material={material} frustumCulled={false} />
</group>
);
}
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 (
<SceneBoundary onError={props.onError}>
<Canvas
aria-hidden="true"
style={{ width: "100%", height: "100%" }}
camera={{ fov: 35, position: [0, 0, 9], near: 0.05, far: 40 }}
dpr={[1, 1.75]}
frameloop={props.visible ? "always" : "never"}
gl={{ alpha: true, antialias: false, powerPreference: "high-performance" }}
>
<Field {...props} />
</Canvas>
</SceneBoundary>
);
}
-350
View File
@@ -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<HeroSystemSceneProps>(
() => 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<HTMLDivElement>(null);
const frameRef = useRef<number | null>(null);
const nextPoseRef = useRef(REST_POSE);
const gestureRef = useRef<Gesture | null>(null);
const suppressClickRef = useRef(false);
const [activeLayer, setActiveLayer] = useState<HeroSystemLayer>("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<typeof setTimeout> | 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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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 (
<div className="hero-system min-w-0 w-full">
<div
ref={viewportRef}
className="hero-system-viewport relative isolate h-[350px] w-full overflow-hidden sm:h-[400px] lg:h-[440px]"
style={{
touchAction: "pan-y",
cursor: sceneActive ? "grab" : undefined,
}}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={releaseGesture}
onPointerCancel={cancelGesture}
onLostPointerCapture={releaseGesture}
onPointerLeave={() => {
if (!gestureRef.current?.dragging) cancelGesture();
}}
onClickCapture={(event) => {
if (suppressClickRef.current) event.stopPropagation();
}}
>
<div
className={`absolute inset-0 flex items-center px-sp-3 ${sceneActive ? "pointer-events-none opacity-0" : "opacity-100"}`}
aria-hidden={sceneActive}
>
<div className="w-full">
<div className="mb-sp-3 flex items-center justify-between gap-sp-3 text-[12px] text-white/65">
<span className="font-medium text-[#f4f2ed]">
Interface real: NutriScan
</span>
</div>
<div className="relative block aspect-[2/1] overflow-hidden rounded-lg border border-white/15 bg-[#151a16] shadow-[0_24px_70px_rgba(0,0,0,0.45)]">
<Image
src="/assets/notebook/nutriscan.webp"
alt="Tela real do projeto NutriScan."
fill
priority
sizes="(min-width: 1024px) 44vw, 92vw"
className="object-cover object-top"
/>
</div>
</div>
</div>
{renderScene && (
<div
aria-hidden="true"
className={`absolute inset-0 ${sceneActive ? "opacity-100" : "pointer-events-none opacity-0"}`}
>
<DynamicScene
activeLayer={activeLayer}
pose={pose}
visible={visible}
onReady={handleReady}
onError={handleError}
onSelectLayer={selectSceneLayer}
/>
</div>
)}
{sceneActive && (
<p className="pointer-events-none absolute inset-x-0 bottom-sp-3 text-center text-[12px] text-white/60">
<span className="hidden sm:inline">
Mova ou arraste para explorar
</span>
<span className="sm:hidden">
Arraste para os lados para explorar
</span>
</p>
)}
</div>
<div className="relative px-sp-1 sm:px-sp-3">
<fieldset
aria-label="Explorar camadas do sistema"
className="grid grid-cols-2 gap-1 min-[400px]:grid-cols-4"
>
{LAYERS.map((layer) => (
<button
key={layer.id}
type="button"
aria-pressed={activeLayer === layer.id}
aria-controls={descriptionId}
onClick={() => {
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}
</button>
))}
</fieldset>
<p
id={descriptionId}
aria-live="polite"
aria-atomic="true"
className="mt-sp-3 min-h-[4.5em] text-[13px] leading-[1.5] text-white/75 sm:min-h-[3em]"
>
{LAYERS.find((layer) => layer.id === activeLayer)?.description}
</p>
<div className="mt-sp-1 flex flex-wrap items-center justify-between gap-x-sp-3">
<p className="max-w-[230px] text-[11px] leading-[1.5] text-white/55">
Camadas ilustrativas de um sistema
</p>
<Link
href="/cases/nutriscan"
className="inline-flex min-h-11 items-center text-[12px] font-medium text-[#f4f2ed] underline decoration-white/35 underline-offset-4 hover:decoration-[var(--slash)] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[var(--slash)]"
>
Interface real: NutriScan
</Link>
</div>
</div>
</div>
);
}
@@ -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<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>
);
}
+157 -10
View File
@@ -255,6 +255,7 @@ textarea {
.ds-section { .ds-section {
padding: clamp(72px, 9vw, 112px) clamp(24px, 6vw, 64px); padding: clamp(72px, 9vw, 112px) clamp(24px, 6vw, 64px);
background: var(--paper);
border-bottom: 1px solid var(--border); 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%); radial-gradient(40% 50% at 6% 92%, rgba(204, 255, 0, 0.05), transparent 70%);
} }
.hero-rise > * { /* Hero: palco de 100svh; em telas grandes ele fica fixo enquanto a rolagem conduz o mergulho da cena 3D.
animation: hero-rise 0.8s var(--ease-out) both; --hero-p (0 a 1) vem de cinematic/HeroField.tsx. */
.hero {
&:nth-child(2) { --hero-p: 0;
animation-delay: 0.08s; isolation: isolate;
} }
&:nth-child(3) { .hero-stage {
animation-delay: 0.16s; position: relative;
display: flex;
flex-direction: column;
min-height: 100svh;
overflow: hidden;
}
/* 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 { 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) { @media (prefers-reduced-motion: reduce) {
.hero-rise > * { .hero-word,
.hero-in,
.hero-foot > *,
.hero-cue-line {
animation: none; 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 */ /* Capa dos cards de projeto: captura real ou painel gerado em codigo */
.ds-cover { .ds-cover {
position: relative; position: relative;