Passagens com fade e 32 px, cena anterior termina em opacidade 0, sem visibility nem clip-path. 3D do hero como fundo ambiente, sob demanda e desligado em toque, aparelho fraco, saveData e movimento reduzido. Lenis fora do toque e salto direto em âncoras distantes. Vitrine só de clientes, até 3 capítulos, fixa só no desktop e carrossel no celular; vídeo só do projeto em cena com preload metadata. Rótulos mono a partir de 12 px e limão só no contato.
124 lines
4.4 KiB
TypeScript
124 lines
4.4 KiB
TypeScript
"use client";
|
|
|
|
import dynamic from "next/dynamic";
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { isLowPowerDevice } from "@/lib/device";
|
|
import type { HeroFieldSceneProps } from "./HeroFieldScene";
|
|
|
|
const Scene = dynamic<HeroFieldSceneProps>(() => import("./HeroFieldScene"), {
|
|
ssr: false,
|
|
});
|
|
|
|
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 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 ambiente do hero. Liga a rolagem do hero às variáveis CSS --hero-p (0 no topo, 1 no fim do mergulho)
|
|
* e --hero-open (0 em repouso, 1 quando a máscara do globals.css já se abriu para o mergulho), e monta a cena
|
|
* WebGL só quando a página fica ociosa e o aparelho aguenta: WebGL2, sem toque (pointer: coarse), com mais de
|
|
* 4 núcleos e mais de 4 GB, sem "economizar dados" e sem movimento reduzido. Nos outros casos fica o fundo
|
|
* estático leve do CSS (.hero-glow). Sem legendas sobre o 3D: o espaço da direita é das molduras.
|
|
*/
|
|
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);
|
|
// fim do mergulho: a cena já está no escuro e o hero se dissolve; parar o desenho poupa a GPU
|
|
const [done, setDone] = 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));
|
|
// depois do mergulho (ou antes dele) a rolagem não muda mais nada: sem reescrever as variáveis CSS
|
|
if (p === progressRef.current) return;
|
|
progressRef.current = p;
|
|
section.style.setProperty("--hero-p", p.toFixed(4));
|
|
// a máscara só se abre quando o texto e a coluna da direita já quase saíram (o conteúdo some até p ≈ 0,24)
|
|
section.style.setProperty("--hero-open", smooth(0.16, 0.3, p).toFixed(4));
|
|
setDone(p >= 0.999);
|
|
};
|
|
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 (isLowPowerDevice()) 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 && !done}
|
|
onReady={handleReady}
|
|
onError={handleError}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|