Hero e movimento - cena 3D em tempo real com os blocos da marca (kit feito no Blender, 3d/blocos.py): marca, tela, integrações e dados; segue a rolagem, tem vida em repouso, reage ao cursor e, parada no topo, passa sozinha pelos estados com legenda - três níveis de aparelho (full, lite, static); sem WebGL fica o pôster - título novo: "Sites e sistemas sob medida para o jeito que a sua empresa trabalha." - faixa de clientes com o ramo, linha do primeiro passo, hero alinhado às seções - rótulos digitados, entrada dos blocos, cortina de blocos entre páginas e capa compartilhada entre a lista de cases e o case Ofertas - atendimento no WhatsApp com IA e nota fiscal para a reforma tributária (páginas de serviço, cartões da home, formulário e aviso com data de validade) - página /ia atualizada, com fontes - peças 3D em loop nos cartões e topos de página (3d/pecas.py) Design - hover por papel (bloco nos botões, barra nos links), rótulos sem caixa alta - um destino por rótulo: "Falar sobre meu projeto" leva ao formulário, "Chamar no WhatsApp" abre o WhatsApp - logo montado em blocos, 404 redesenhado, grades sem célula vazia - ícones do PWA e manifesto novos Specs em specs/, contexto de design em DESIGN.md e PRODUCT.md.
398 lines
15 KiB
TypeScript
398 lines
15 KiB
TypeScript
"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<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, 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<HTMLElement>(".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<BlocosSceneProps>;
|
|
tier: Exclude<SceneTier, "static">;
|
|
};
|
|
|
|
/**
|
|
* 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<HTMLDivElement>(null);
|
|
const progressRef = useRef(0);
|
|
const placementRef = useRef<Rect | null>(null);
|
|
// a área útil do mergulho (full): entre o cabeçalho e as legendas
|
|
const stageRef = useRef<Rect | null>(null);
|
|
const giveUp = useRef(() => {});
|
|
const [mounted, setMounted] = useState<Mounted | null>(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<Rect | null>(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<HTMLElement>(".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 (
|
|
<div
|
|
ref={rootRef}
|
|
aria-hidden="true"
|
|
className="hero-field"
|
|
data-tier={scene?.tier}
|
|
data-ready={ready && scene ? "" : undefined}
|
|
style={
|
|
lite && box
|
|
? {
|
|
left: box.x,
|
|
top: box.y,
|
|
width: box.width,
|
|
height: box.height,
|
|
}
|
|
: undefined
|
|
}
|
|
>
|
|
{scene && (
|
|
<scene.Scene
|
|
progressRef={progressRef}
|
|
placementRef={lite ? undefined : placementRef}
|
|
stageRef={lite ? undefined : stageRef}
|
|
tier={scene.tier}
|
|
visible={visible && !done}
|
|
onReady={handleReady}
|
|
onError={handleError}
|
|
onEstado={handleEstado}
|
|
/>
|
|
)}
|
|
{/* abaixo de 1024 px a peça fica no canto, na linha do rótulo, e a legenda cairia sobre o título: só de 1024 em diante */}
|
|
{scene && legenda && box && (
|
|
<p
|
|
className="hero-legenda absolute flex max-lg:hidden items-center gap-sp-3 whitespace-nowrap font-display text-[13px] leading-[1.5] tracking-[0.02em] text-[rgba(244,242,237,.72)]"
|
|
data-shown={ready && legendaOn ? "" : undefined}
|
|
style={
|
|
lite
|
|
? { left: 0, top: box.height + LEGENDA_GAP }
|
|
: {
|
|
left: box.x + box.width * LEGENDA_RECUO,
|
|
top: box.y + box.height + LEGENDA_GAP,
|
|
}
|
|
}
|
|
>
|
|
<span className="h-2 w-2 shrink-0 bg-[var(--slash)]" />
|
|
<span
|
|
key={legenda.vez}
|
|
className="ds-type"
|
|
style={{ "--n": legenda.texto.length } as CSSProperties}
|
|
>
|
|
{legenda.texto}
|
|
</span>
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|