perf(movimento): sem tela vazia, passagens em CSS e vitrine leve
Projetos sobem sobre o palco do hero e o celular tem hero de uma tela sem mergulho; passagens com animation-timeline em CSS, sem GSAP nem motion; troca da vitrine sem texto duplicado; moldura de celular só com pôster e scrub reencodado em 960 px (9,4 para 5,2 MB); 3D monta na primeira interação e para quando parado; transição entre páginas do mesmo segmento; foco em anel duplo; texto de 15 px no celular; código morto removido.
This commit is contained in:
1 parent
10f170abf4
commit
318a764190
77 files changed
+4293
-4951
No files matched your search
@@ -1,13 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
type ComponentType,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { isLowPowerDevice } from "@/lib/device";
|
||||
import type { HeroFieldSceneProps } from "./HeroFieldScene";
|
||||
|
||||
const Scene = dynamic<HeroFieldSceneProps>(() => import("./HeroFieldScene"), {
|
||||
ssr: false,
|
||||
});
|
||||
/** 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)";
|
||||
/** 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)));
|
||||
@@ -25,83 +43,178 @@ function supportsWebGL2() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 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);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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 o aparelho não faz WebGL2 ou se a tela foi
|
||||
* desmontada no meio do caminho.
|
||||
*/
|
||||
async function loadScene(isAlive: () => boolean) {
|
||||
if (!supportsWebGL2()) return null;
|
||||
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 = "";
|
||||
let frame = 0;
|
||||
const update = () => {
|
||||
frame = 0;
|
||||
const rect = section.getBoundingClientRect();
|
||||
// o palco fica preso por (altura - 1 tela); a última tela dessa rolagem é de Projetos subindo sobre ele
|
||||
const span = Math.max(
|
||||
rect.height - window.innerHeight * 2,
|
||||
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, 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). 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 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 cena já está no escuro e o hero se dissolve; parar o desenho poupa a GPU
|
||||
// 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;
|
||||
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);
|
||||
let alive = true;
|
||||
const stopDive = window.matchMedia?.(DIVE_QUERY).matches
|
||||
? trackDive(section, progressRef, setDone)
|
||||
: undefined;
|
||||
const stopVisibility = trackVisibility(section, setVisible);
|
||||
// sem WebGL2, 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 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();
|
||||
const stopWaiting = whenIdle(() => {
|
||||
loadScene(() => alive)
|
||||
.then((component) => {
|
||||
if (!alive) return;
|
||||
if (component) setScene(() => component);
|
||||
else giveUp.current();
|
||||
})
|
||||
.catch(() => giveUp.current());
|
||||
});
|
||||
observer.observe(section);
|
||||
document.addEventListener("visibilitychange", sync);
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
window.removeEventListener("scroll", queue);
|
||||
window.removeEventListener("resize", queue);
|
||||
observer.disconnect();
|
||||
document.removeEventListener("visibilitychange", sync);
|
||||
alive = false;
|
||||
stopWaiting();
|
||||
stopVisibility();
|
||||
stopDive?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
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), []);
|
||||
const handleError = useCallback(() => giveUp.current(), []);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -110,7 +223,7 @@ export default function HeroField() {
|
||||
className="hero-field"
|
||||
data-ready={ready && !failed ? "" : undefined}
|
||||
>
|
||||
{mount && !failed && (
|
||||
{Scene && !failed && (
|
||||
<Scene
|
||||
progressRef={progressRef}
|
||||
visible={visible && !done}
|
||||
|
||||
Reference in new issue
Block a user