Files
portifolio-evandrodevbr/app/components/cinematic/HeroField.tsx
T

250 lines
9.5 KiB
TypeScript

"use client";
import {
type ComponentType,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { isKnownSoftwareWebGL, isLowPowerDevice } from "@/lib/device";
import type { HeroFieldSceneProps } from "./HeroFieldScene";
/** A mesma condição do CSS do mergulho (globals.css): tela com altura para o conteúdo e sem movimento reduzido. */
export const DIVE_QUERY =
"(min-height: 700px) and (prefers-reduced-motion: no-preference)";
/**
* Telas de hero além do mergulho: 100 do palco preso e o resto em que Projetos sobe sobre ele. Quem manda é o CSS
* (`--hero-tail` em globals.css, a mesma variável da altura do hero e da margem de Projetos); o mergulho (progresso 0
* a 1) é o resto da altura do hero. Sem a variável (a folha de estilos não definiu), não há cauda: só o palco.
*/
function heroTailScreens(section: HTMLElement) {
const tail = Number.parseFloat(
getComputedStyle(section).getPropertyValue("--hero-tail"),
);
return Number.isFinite(tail) ? tail : 1;
}
/** Tempo livre (ms) que um intervalo ocioso precisa ter para a cena ser montada sem engasgar a página. */
const IDLE_SLACK_MS = 20;
/** Sem intervalo ocioso folgado até aqui (ms), a cena entra mesmo assim. */
const IDLE_PATIENCE_MS = 4000;
/** Gestos que contam como a primeira interação: a cena entra logo depois, sem esperar a folga. */
const FIRST_ACTIVITY = [
"pointerdown",
"pointermove",
"keydown",
"wheel",
"touchstart",
"scroll",
] as const;
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);
};
/**
* O navegador tem a API do WebGL2? É só uma leitura: abrir um contexto de teste aqui, na hidratação, custava uma tarefa
* de 150 ms (500 ms por software). Se o WebGL2 abre de verdade, e se ele desenha na GPU, quem diz é a própria cena,
* no contexto que ela abre para desenhar (HeroFieldScene.createRenderer); o veredito "por software" fica anotado na
* sessão (isKnownSoftwareWebGL), e as páginas seguintes já nascem estáticas.
*/
const hasWebGL2 = () => typeof window.WebGL2RenderingContext !== "undefined";
/** Um intervalo ocioso do navegador (sem requestIdleCallback, um instante depois). */
function nextIdle() {
return new Promise<void>((resolve) => {
if (typeof window.requestIdleCallback === "function")
window.requestIdleCallback(() => resolve(), { timeout: 500 });
else setTimeout(resolve, 50);
});
}
/**
* Chama `run` uma vez: na primeira interação do visitante ou quando o navegador fica ocioso com folga (o que
* vier primeiro). Título, botões e hidratação vêm antes; devolve a função que cancela a espera.
*/
function whenIdle(run: () => void) {
let idle: number | undefined;
let timer: ReturnType<typeof setTimeout> | undefined;
const stop = () => {
for (const type of FIRST_ACTIVITY) window.removeEventListener(type, fire);
if (idle !== undefined) window.cancelIdleCallback(idle);
clearTimeout(timer);
};
function fire() {
stop();
run();
}
for (const type of FIRST_ACTIVITY)
window.addEventListener(type, fire, { passive: true });
if (typeof window.requestIdleCallback === "function") {
const wait = (deadline: IdleDeadline) => {
if (deadline.didTimeout || deadline.timeRemaining() >= IDLE_SLACK_MS)
fire();
else
idle = window.requestIdleCallback(wait, { timeout: IDLE_PATIENCE_MS });
};
idle = window.requestIdleCallback(wait, { timeout: IDLE_PATIENCE_MS });
} else timer = setTimeout(fire, 1200);
return stop;
}
/**
* Carrega a cena em passos separados, cada um na própria tarefa: o pacote do Three.js, a geometria (pré-calculada
* num intervalo ocioso) e só então o Canvas. Devolve `null` se a tela foi desmontada no meio do caminho.
*/
async function loadScene(isAlive: () => boolean) {
const scene = await import("./HeroFieldScene");
await nextIdle();
if (!isAlive()) return null;
scene.prepareField();
await nextIdle();
return isAlive() ? scene.default : null;
}
/**
* 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 marca o hero com data-dive, que
* liga o bloco de rolagem do mergulho no CSS. Devolve a função que desfaz tudo.
*/
function trackDive(
section: HTMLElement,
progress: { current: number },
onDone: (done: boolean) => void,
) {
section.dataset.dive = "";
const tail = heroTailScreens(section);
let frame = 0;
const update = () => {
frame = 0;
const rect = section.getBoundingClientRect();
// o palco fica preso por (altura - 1 tela); o fim dessa rolagem é de Projetos subindo sobre ele
const span = Math.max(
rect.height - window.innerHeight * tail,
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 === progress.current) return;
progress.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));
onDone(p >= 0.999);
};
const queue = () => {
if (!frame) frame = requestAnimationFrame(update);
};
update();
window.addEventListener("scroll", queue, { passive: true });
window.addEventListener("resize", queue);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener("scroll", queue);
window.removeEventListener("resize", queue);
delete section.dataset.dive;
section.style.removeProperty("--hero-p");
section.style.removeProperty("--hero-open");
};
}
/** Avisa se o hero está à vista (e a aba visível): fora disso a cena não desenha nada. */
function trackVisibility(
section: HTMLElement,
onChange: (visible: boolean) => void,
) {
let intersecting = false;
const sync = () => onChange(intersecting && !document.hidden);
const observer = new IntersectionObserver(([entry]) => {
intersecting = entry.isIntersecting;
sync();
});
observer.observe(section);
document.addEventListener("visibilitychange", sync);
return () => {
observer.disconnect();
document.removeEventListener("visibilitychange", sync);
};
}
/**
* Fundo ambiente do hero e dono do mergulho. Só um aparelho que aguenta a cena (WebGL2 na GPU, não por software,
* sem toque, com mais de 4 núcleos e mais de 4 GB, sem "economizar dados" e sem movimento reduzido) recebe qualquer coisa daqui: nos
* outros o hero é só o palco de 100svh com o fundo estático leve do CSS (.hero-glow), sem ouvinte de rolagem
* e sem baixar o Three.js. No aparelho capaz, a cena WebGL entra na primeira interação ou quando a página fica
* ociosa com folga, em passos separados (pacote, geometria, Canvas); a rolagem do hero conduz o mergulho
* (trackDive). O desenho por software só se descobre no contexto que a cena abre (nunca num contexto de teste na
* hidratação): ela avisa por `onError` e o hero volta ao palco estático. 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 giveUp = useRef(() => {});
const [Scene, setScene] = useState<ComponentType<HeroFieldSceneProps> | null>(
null,
);
const [visible, setVisible] = useState(false);
const [ready, setReady] = useState(false);
const [failed, setFailed] = useState(false);
// fim do mergulho: a câmera parou diante dos dados e Projetos já vai cobrindo o palco; parar o desenho poupa a GPU
const [done, setDone] = useState(false);
useEffect(() => {
if (isLowPowerDevice()) return;
const section = rootRef.current?.closest<HTMLElement>(".hero");
if (!section) return;
// sem WebGL2, ou com o desenho por software já descoberto nesta sessão: o hero estático de 100svh, já na hidratação,
// sem o bloco de rolagem do mergulho. Num aparelho ainda não testado o mergulho liga já (o veredito sai do contexto
// da cena, ocioso, sem uma tarefa longa na hidratação); sendo software, a cena avisa e o hero volta a 100svh
if (!hasWebGL2() || isKnownSoftwareWebGL()) return;
let alive = true;
const stopDive = window.matchMedia?.(DIVE_QUERY).matches
? trackDive(section, progressRef, setDone)
: undefined;
const stopVisibility = trackVisibility(section, setVisible);
// com o pacote que não carrega ou com o contexto perdido: de volta ao hero simples de 100svh
giveUp.current = () => {
stopDive?.();
if (alive) setFailed(true);
};
const stopWaiting = whenIdle(() => {
loadScene(() => alive)
.then((component) => {
if (!alive) return;
if (component) setScene(() => component);
else giveUp.current();
})
.catch(() => giveUp.current());
});
return () => {
alive = false;
stopWaiting();
stopVisibility();
stopDive?.();
};
}, []);
const handleReady = useCallback(() => setReady(true), []);
const handleError = useCallback(() => giveUp.current(), []);
return (
<div
ref={rootRef}
aria-hidden="true"
className="hero-field"
data-ready={ready && !failed ? "" : undefined}
>
{Scene && !failed && (
<Scene
progressRef={progressRef}
visible={visible && !done}
onReady={handleReady}
onError={handleError}
/>
)}
</div>
);
}