"use client"; import { type ComponentType, type CSSProperties, useCallback, useEffect, useRef, useState, } from "react"; import { caixaDosEstados, legendaDe, type Rect } from "@/lib/blocos"; import { isKnownSlowScene, isKnownSoftwareWebGL, type SceneTier, sceneTier, } from "@/lib/device"; import type { BlocosSceneProps } from "./BlocosScene"; import "./hero-legenda.css"; /** 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; /** Distância (px) entre a caixa da peça e a legenda abaixo dela. */ const LEGENDA_GAP = 12; /** * Recuo da legenda no full, em fração da largura da caixa dos estados: tela, integrações e dados têm até 14,5 das 17 * unidades dela, centrados (3d/blocos.py), e a legenda começa onde eles começam. */ const LEGENDA_RECUO = (1 - 14.5 / 17) / 2; /** 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; /** * 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 (createRenderer em BlocosScene); 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((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 | 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, os ativos (bloco, estados e * matcaps, baixados 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("./BlocosScene"); await nextIdle(); if (!isAlive()) return null; await scene.prepareBlocos(); await nextIdle(); return isAlive() ? scene.default : null; } /** * Mede, em relação ao palco que a cena cobre (o pai do elemento raiz, onde ele fica com `inset: 0`), a peça do hero * (o pôster, `.hero-piece`) e a área útil do mergulho: 6% a 94% da largura, do cabeçalho (`--site-header-height`) mais * 24 px até 70% da altura, onde começam as legendas e a faixa escura delas. Mede na montagem, quando a entrada da peça * acaba (`.hero-in` anima o deslocamento) e a cada mudança de tamanho do hero, um quadro depois. Sem a peça, `null`. * Diz também o tamanho do palco. Devolve a função que desfaz tudo. */ function trackPiece( root: HTMLElement, section: HTMLElement, onChange: ( piece: Rect | null, stage: Rect | null, size: { width: number; height: number }, ) => void, ) { let frame = 0; const measure = () => { frame = 0; const piece = section.querySelector(".hero-piece"); const stage = root.parentElement; if (!stage) return onChange(null, null, { width: 0, height: 0 }); const origin = stage.getBoundingClientRect(); const header = Number.parseFloat( getComputedStyle(stage).getPropertyValue("--site-header-height"), ) || 0; const top = header + 24; const area = { x: origin.width * 0.06, y: top, width: origin.width * 0.88, height: Math.max(0, origin.height * 0.7 - top), }; const size = { width: origin.width, height: origin.height }; if (!piece) return onChange(null, area, size); const box = piece.getBoundingClientRect(); onChange( { x: box.left - origin.left, y: box.top - origin.top, width: box.width, height: box.height, }, area, size, ); }; const queue = () => { if (!frame) frame = requestAnimationFrame(measure); }; const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(queue); observer?.observe(section); if (!observer) window.addEventListener("resize", queue); section.addEventListener("animationend", queue); measure(); return () => { cancelAnimationFrame(frame); observer?.disconnect(); window.removeEventListener("resize", queue); section.removeEventListener("animationend", queue); }; } /** * Liga a rolagem do hero à variável CSS --hero-p (0 no topo, 1 no fim do 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)); 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"); }; } /** 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); }; } type Mounted = { Scene: ComponentType; tier: Exclude; }; /** * A cena 3D do hero (os blocos da marca) e dono do mergulho. O pôster (`.hero-piece`, renderizado no Blender) é o * primeiro quadro em todo aparelho; daqui só sai algo onde `sceneTier()` não é `static` e o WebGL2 existe, sem desenho * por software nem cena lenta já descobertos nesta sessão. Fora disso não há ouvinte de rolagem nem download do * Three.js. A cena entra na primeira interação ou quando a página fica ociosa com folga, em passos separados (pacote, * ativos, canvas), por cima do pôster, que some quando ela fica pronta (`data-ready`, no CSS): * - `full` (computador): o canvas cobre o palco; a marca descansa sobre o pôster (`placementRef`) e a rolagem do * hero conduz o mergulho (trackDive), em que a peça vai ao centro e passa por tela, integrações e dados junto das * legendas. * - `lite` (celular capaz): sem mergulho; o canvas ocupa só a caixa da peça e troca de estado sozinho. * Com a cena pronta, uma legenda abaixo da caixa da peça diz o que ela está montando (`legendaDe`, avisada pela cena * em `onEstado`): nenhuma na marca nem fora da caixa, no mergulho. * O desenho por software e a cena lenta só se descobrem no contexto que a cena abre (nunca num contexto de teste na * hidratação): ela avisa por `onError` e o hero volta ao pôster. */ export default function HeroField() { const rootRef = useRef(null); const progressRef = useRef(0); const placementRef = useRef(null); // a área útil do mergulho (full): entre o cabeçalho e as legendas const stageRef = useRef(null); const giveUp = useRef(() => {}); const [mounted, setMounted] = useState(null); // no lite, a caixa da peça e do canvas (em estilo em linha); no full, a caixa dos estados: a legenda vai abaixo dela const [box, setBox] = useState(null); // a última legenda (o texto fica enquanto ela some) e quantas vezes entrou (cada entrada digita de novo) const [legenda, setLegenda] = useState<{ texto: string; vez: number } | null>( null, ); const [legendaOn, setLegendaOn] = useState(false); const [visible, setVisible] = useState(false); const [ready, setReady] = useState(false); const [failed, setFailed] = useState(false); // fim do mergulho: a cena parou nos dados e Projetos já vai cobrindo o palco; parar o desenho poupa a GPU const [done, setDone] = useState(false); useEffect(() => { const tier = sceneTier(); if (tier === "static") return; const root = rootRef.current; const section = root?.closest(".hero"); if (!root || !section) return; // sem WebGL2, ou com o desenho por software ou a cena lenta já descobertos nesta sessão: só o pôster, 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); se ela desistir, avisa e o hero volta ao pôster if (!hasWebGL2() || isKnownSoftwareWebGL() || isKnownSlowScene()) return; let alive = true; const stopDive = tier === "full" && window.matchMedia?.(DIVE_QUERY).matches ? trackDive(section, progressRef, setDone) : undefined; const stopVisibility = trackVisibility(section, setVisible); const stopPiece = trackPiece(root, section, (piece, stage, size) => { // no full, a legenda vai sob a caixa dos estados (onde a cena põe tela, integrações e dados no topo) setBox(piece && tier === "full" ? caixaDosEstados(piece, size) : piece); if (tier === "lite") return; placementRef.current = piece; stageRef.current = stage; }); // com o pacote que não carrega, sem WebGL ou com o contexto perdido: de volta ao pôster giveUp.current = () => { stopDive?.(); if (alive) setFailed(true); }; const stopWaiting = whenIdle(() => { loadScene(() => alive) .then((component) => { if (!alive) return; if (component) setMounted({ Scene: component, tier }); else giveUp.current(); }) .catch(() => giveUp.current()); }); return () => { alive = false; stopWaiting(); stopVisibility(); stopPiece(); stopDive?.(); }; }, []); const handleReady = useCallback(() => setReady(true), []); const handleError = useCallback(() => giveUp.current(), []); const handleEstado = useCallback((estado: number | null) => { const texto = legendaDe(estado); setLegendaOn(texto !== null); if (texto) setLegenda((anterior) => ({ texto, vez: (anterior?.vez ?? 0) + 1 })); }, []); const scene = mounted && !failed ? mounted : null; const lite = scene?.tier === "lite"; return ( ); }