feat(hero): campo de particulas WebGL com mergulho guiado pela rolagem e transicao de secoes em CSS
Tres camadas de pontos ligadas por colunas de dados, animadas no vertex shader; o hero fica fixo em telas grandes enquanto a camera atravessa as camadas. Secoes sobem como cartoes via animation-timeline: view() (sem JS). Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
3d47fe9cc7
commit
0ec0194441
7 files changed
+694
-1001
No files matched your search
@@ -50,9 +50,13 @@ describe("Hero", () => {
|
|||||||
"href",
|
"href",
|
||||||
"/cases",
|
"/cases",
|
||||||
);
|
);
|
||||||
expect(screen.getByRole("link", { name: /nutriscan/i })).toHaveAttribute(
|
});
|
||||||
"href",
|
|
||||||
"/cases/nutriscan",
|
it("keeps the headline as one readable sentence despite the per-word animation", () => {
|
||||||
|
render(<Hero />);
|
||||||
|
|
||||||
|
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(
|
||||||
|
"Sistemas sob medida para operações que não cabem em soluções prontas.",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+90
-61
@@ -1,5 +1,6 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import HeroSystem from "./cinematic/HeroSystem";
|
import { type CSSProperties, Fragment } from "react";
|
||||||
|
import HeroField from "./cinematic/HeroField";
|
||||||
|
|
||||||
const SOCIAL_LINKS = [
|
const SOCIAL_LINKS = [
|
||||||
{
|
{
|
||||||
@@ -32,32 +33,47 @@ function statItems(stats?: HeroStats) {
|
|||||||
].filter((item): item is [string, string] => item !== null);
|
].filter((item): item is [string, string] => item !== null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Título em palavras para a entrada em cascata; o texto segue inteiro para leitores de tela e buscadores. */
|
||||||
|
const TITLE = "Sistemas sob medida para operações que não cabem em soluções prontas.".split(" ");
|
||||||
|
const ACCENT = new Set([1, 2]);
|
||||||
|
|
||||||
export default function Hero({ stats }: { stats?: HeroStats }) {
|
export default function Hero({ stats }: { stats?: HeroStats }) {
|
||||||
const items = statItems(stats);
|
const items = statItems(stats);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
id="home"
|
id="home"
|
||||||
className="relative w-full overflow-hidden bg-[var(--ink)] font-display text-[#f4f2ed]"
|
className="hero relative w-full bg-[var(--ink)] font-display text-[#f4f2ed]"
|
||||||
>
|
>
|
||||||
<div className="hero-glow" aria-hidden="true" />
|
<div className="hero-stage">
|
||||||
{/* Sem opacity:0 inicial: o título já vem visível no HTML (LCP). A entrada anima só a posição, em CSS. */}
|
<div className="hero-glow" aria-hidden="true" />
|
||||||
<div className="hero-rise relative mx-auto grid w-full max-w-[max(1400px,var(--shell))] grid-cols-1 items-center gap-sp-8 px-sp-6 pt-[calc(var(--site-header-height)+32px)] pb-sp-10 lg:grid-cols-[minmax(0,53fr)_minmax(0,47fr)] lg:gap-sp-8 lg:px-sp-12 lg:pb-sp-16">
|
<HeroField />
|
||||||
<div className="min-w-0">
|
<div className="hero-scrim" aria-hidden="true" />
|
||||||
<p className="mb-sp-5 flex items-center gap-sp-3 font-mono text-[11px] uppercase leading-[1.5] tracking-[0.14em] text-[rgba(244,242,237,.72)] sm:text-[12px]">
|
|
||||||
|
<div className="hero-content relative mx-auto flex w-full max-w-[max(1400px,var(--shell))] flex-1 flex-col justify-center px-sp-6 pt-[calc(var(--site-header-height)+32px)] pb-sp-8 lg:px-sp-12">
|
||||||
|
<p className="hero-in mb-sp-5 flex items-center gap-sp-3 font-mono text-[11px] uppercase leading-[1.5] tracking-[0.14em] text-[rgba(244,242,237,.72)] sm:text-[12px]">
|
||||||
<span aria-hidden="true" className="h-2 w-2 bg-[var(--slash)]" />
|
<span aria-hidden="true" className="h-2 w-2 bg-[var(--slash)]" />
|
||||||
Evandro Fonseca Junior · Engenharia de software
|
Evandro Fonseca Junior · Engenharia de software
|
||||||
</p>
|
</p>
|
||||||
<h1 className="max-w-[12.5em] text-balance text-[clamp(34px,1.6rem+2.4vw,88px)] font-semibold leading-[1.04] tracking-[-0.045em]">
|
{/* Sem opacity:0: as palavras pintam no primeiro quadro (LCP); a entrada anima só posição e foco. */}
|
||||||
Sistemas <span className="text-[var(--slash)]">sob medida</span> para
|
<h1 className="hero-title max-w-[12.5em] lg:max-w-[min(11em,54vw)] text-[clamp(38px,min(6vw,10.5svh),124px)] font-semibold leading-[0.98] tracking-[-0.05em]">
|
||||||
operações que não cabem em soluções prontas.
|
{TITLE.map((word, index) => (
|
||||||
|
<Fragment key={word}>
|
||||||
|
<span
|
||||||
|
className={`hero-word${ACCENT.has(index) ? " text-[var(--slash)]" : ""}`}
|
||||||
|
style={{ "--i": index } as CSSProperties}
|
||||||
|
>
|
||||||
|
{word}
|
||||||
|
</span>{" "}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-sp-5 max-w-[34em] text-[clamp(16px,0.95rem+0.4vw,22px)] leading-[1.55] text-[rgba(244,242,237,.8)]">
|
<p className="hero-in mt-sp-5 max-w-[34em] text-[clamp(16px,0.95rem+0.4vw,22px)] leading-[1.55] text-[rgba(244,242,237,.8)] [--d:.55s]">
|
||||||
Projeto e desenvolvo sistemas web, aplicativos e integrações, do
|
Projeto e desenvolvo sistemas web, aplicativos e integrações, do
|
||||||
entendimento do negócio à arquitetura e à entrega em produção.
|
entendimento do negócio à arquitetura e à entrega em produção.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mt-sp-6 flex flex-col gap-sp-3 sm:flex-row sm:flex-wrap sm:items-center">
|
<div className="hero-in mt-sp-6 flex flex-col gap-sp-3 [--d:.65s] sm:flex-row sm:flex-wrap sm:items-center">
|
||||||
<Link
|
<Link
|
||||||
href="/#contact"
|
href="/#contact"
|
||||||
className="group inline-flex min-h-12 items-center justify-center gap-sp-3 bg-[var(--slash)] px-sp-4 py-sp-3 text-center text-[15px] font-semibold leading-6 text-[var(--ink)] no-underline transition-colors hover:bg-[#d9ff40] motion-reduce:transition-none sm:px-sp-6 sm:text-[16px]"
|
className="group inline-flex min-h-12 items-center justify-center gap-sp-3 bg-[var(--slash)] px-sp-4 py-sp-3 text-center text-[15px] font-semibold leading-6 text-[var(--ink)] no-underline transition-colors hover:bg-[#d9ff40] motion-reduce:transition-none sm:px-sp-6 sm:text-[16px]"
|
||||||
@@ -76,61 +92,74 @@ export default function Hero({ stats }: { stats?: HeroStats }) {
|
|||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/cases"
|
href="/cases"
|
||||||
className="inline-flex min-h-12 items-center justify-center border border-white/12 px-sp-5 py-sp-3 text-center text-[15px] font-medium leading-6 text-[#f4f2ed] no-underline transition-colors hover:border-white/40 motion-reduce:transition-none sm:text-[16px]"
|
className="inline-flex min-h-12 items-center justify-center border border-white/15 bg-[rgba(10,10,10,.35)] px-sp-5 py-sp-3 text-center text-[15px] font-medium leading-6 text-[#f4f2ed] no-underline backdrop-blur-sm transition-colors hover:border-white/40 motion-reduce:transition-none sm:text-[16px]"
|
||||||
>
|
>
|
||||||
Ver projetos entregues
|
Ver projetos entregues
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-sp-3 text-[13px] leading-[1.5] text-[rgba(244,242,237,.64)]">
|
|
||||||
Santa Catarina · Atendimento remoto
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 lg:col-start-2 lg:row-start-1 lg:row-span-2">
|
|
||||||
<HeroSystem />
|
<div className="hero-foot relative mx-auto flex w-full max-w-[max(1400px,var(--shell))] flex-wrap items-end justify-between gap-sp-5 px-sp-6 pb-sp-5 lg:px-sp-12">
|
||||||
</div>
|
<div className="flex flex-wrap items-end gap-x-sp-8 gap-y-sp-4 border-t border-white/10 pt-sp-4">
|
||||||
<div className="min-w-0 lg:col-start-1 lg:row-start-2">
|
{items.length > 0 && (
|
||||||
{items.length > 0 && (
|
<dl className="flex flex-wrap gap-x-sp-8 gap-y-sp-4">
|
||||||
<dl className="mb-sp-6 flex flex-wrap gap-x-sp-8 gap-y-sp-4 border-t border-white/10 pt-sp-5">
|
{items.map(([value, label]) => (
|
||||||
{items.map(([value, label]) => (
|
<div key={label} className="flex flex-col-reverse">
|
||||||
<div key={label} className="flex flex-col-reverse">
|
<dt className="text-[13px] leading-[1.4] text-[rgba(244,242,237,.64)]">
|
||||||
<dt className="text-[13px] leading-[1.4] text-[rgba(244,242,237,.64)]">
|
{label}
|
||||||
{label}
|
</dt>
|
||||||
</dt>
|
<dd className="font-mono text-[clamp(24px,1.1rem+0.8vw,36px)] font-semibold leading-none tracking-[-0.03em] text-[var(--slash)]">
|
||||||
<dd className="font-mono text-[clamp(26px,1.2rem+0.9vw,40px)] font-semibold leading-none tracking-[-0.03em] text-[var(--slash)]">
|
{value}
|
||||||
{value}
|
</dd>
|
||||||
</dd>
|
</div>
|
||||||
</div>
|
))}
|
||||||
))}
|
</dl>
|
||||||
</dl>
|
)}
|
||||||
)}
|
<div>
|
||||||
<ul className="flex flex-wrap gap-x-sp-5 gap-y-sp-2 text-[14px] leading-[1.5] text-[rgba(244,242,237,.8)]">
|
<ul className="flex flex-wrap gap-x-sp-5 gap-y-sp-2 text-[14px] leading-[1.5] text-[rgba(244,242,237,.8)]">
|
||||||
{["Sistemas web", "Aplicativos", "Integrações"].map((service) => (
|
{["Sistemas web", "Aplicativos", "Integrações"].map(
|
||||||
<li key={service} className="flex items-center gap-sp-2">
|
(service) => (
|
||||||
<span
|
<li key={service} className="flex items-center gap-sp-2">
|
||||||
aria-hidden="true"
|
<span
|
||||||
className="h-1 w-1 bg-[var(--slash)]"
|
aria-hidden="true"
|
||||||
/>
|
className="h-1 w-1 bg-[var(--slash)]"
|
||||||
{service}
|
/>
|
||||||
</li>
|
{service}
|
||||||
))}
|
</li>
|
||||||
</ul>
|
),
|
||||||
<div className="mt-sp-2 flex flex-wrap gap-x-sp-5">
|
)}
|
||||||
{SOCIAL_LINKS.map((link) => (
|
</ul>
|
||||||
<a
|
<div className="flex flex-wrap items-center gap-x-sp-5">
|
||||||
key={link.label}
|
{SOCIAL_LINKS.map((link) => (
|
||||||
href={link.href}
|
<a
|
||||||
target={link.href.startsWith("http") ? "_blank" : undefined}
|
key={link.label}
|
||||||
rel={
|
href={link.href}
|
||||||
link.href.startsWith("http")
|
target={
|
||||||
? "noopener noreferrer"
|
link.href.startsWith("http") ? "_blank" : undefined
|
||||||
: undefined
|
}
|
||||||
}
|
rel={
|
||||||
className="inline-flex min-h-11 items-center text-[13px] text-[rgba(244,242,237,.64)] no-underline underline-offset-4 hover:text-[#f4f2ed] hover:underline"
|
link.href.startsWith("http")
|
||||||
>
|
? "noopener noreferrer"
|
||||||
{link.label}
|
: undefined
|
||||||
</a>
|
}
|
||||||
))}
|
className="inline-flex min-h-11 items-center text-[13px] text-[rgba(244,242,237,.64)] no-underline underline-offset-4 hover:text-[#f4f2ed] hover:underline"
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
<span className="text-[13px] text-[rgba(244,242,237,.55)]">
|
||||||
|
Santa Catarina · Atendimento remoto
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<p
|
||||||
|
aria-hidden="true"
|
||||||
|
className="hero-cue hidden items-center gap-sp-3 font-mono text-[11px] uppercase tracking-[0.14em] text-[rgba(244,242,237,.55)] lg:flex"
|
||||||
|
>
|
||||||
|
Role para mergulhar
|
||||||
|
<span className="hero-cue-line" />
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import type { HeroFieldSceneProps } from "./HeroFieldScene";
|
||||||
|
|
||||||
|
const Scene = dynamic<HeroFieldSceneProps>(() => import("./HeroFieldScene"), {
|
||||||
|
ssr: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
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 do hero: liga a rolagem do hero à variável CSS --hero-p (0 no topo, 1 no fim do mergulho)
|
||||||
|
* e monta a cena WebGL só quando a página fica ociosa, com WebGL2 e sem preferência por movimento reduzido.
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
|
||||||
|
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));
|
||||||
|
progressRef.current = p;
|
||||||
|
section.style.setProperty("--hero-p", p.toFixed(4));
|
||||||
|
};
|
||||||
|
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 (window.matchMedia("(prefers-reduced-motion: reduce)").matches) 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}
|
||||||
|
onReady={handleReady}
|
||||||
|
onError={handleError}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Canvas, useFrame, useThree } from "@react-three/fiber";
|
||||||
|
import {
|
||||||
|
Component,
|
||||||
|
type ReactNode,
|
||||||
|
type RefObject,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
} from "react";
|
||||||
|
import * as THREE from "three";
|
||||||
|
|
||||||
|
export type HeroFieldSceneProps = {
|
||||||
|
progressRef: RefObject<number>;
|
||||||
|
visible: boolean;
|
||||||
|
onReady: () => void;
|
||||||
|
onError: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Três camadas de pontos (interface, integrações, dados) ligadas por colunas de partículas que sobem
|
||||||
|
* como dados. Na entrada, os pontos saem de uma nuvem e se organizam; ao rolar, as camadas se afastam,
|
||||||
|
* ficam de frente e a câmera atravessa as três até o escuro da próxima seção.
|
||||||
|
* Tudo acontece no vertex shader: o JavaScript só atualiza meia dúzia de uniforms por quadro.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const RADIUS = 2.35;
|
||||||
|
|
||||||
|
function buildField(dense: boolean) {
|
||||||
|
const grid = dense ? 76 : 46;
|
||||||
|
const columns = dense ? 18 : 10;
|
||||||
|
const perColumn = dense ? 64 : 40;
|
||||||
|
const base: number[] = [];
|
||||||
|
const kind: number[] = [];
|
||||||
|
const step = (RADIUS * 2) / (grid - 1);
|
||||||
|
for (const layer of [-1, 0, 1]) {
|
||||||
|
for (let i = 0; i < grid; i++) {
|
||||||
|
for (let j = 0; j < grid; j++) {
|
||||||
|
// leve desalinhamento: quebra o moiré da grade perfeita
|
||||||
|
const x = -RADIUS + (i + (Math.random() - 0.5) * 0.35) * step;
|
||||||
|
const z = -RADIUS + (j + (Math.random() - 0.5) * 0.35) * step;
|
||||||
|
if (Math.hypot(x, z) > RADIUS) continue;
|
||||||
|
base.push(x, layer, z);
|
||||||
|
kind.push(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let c = 0; c < columns; c++) {
|
||||||
|
const angle = c * 2.39996 + 0.4; // ângulo áureo: colunas espalhadas sem se amontoar
|
||||||
|
const r = Math.sqrt((c + 0.5) / columns) * RADIUS * 0.78;
|
||||||
|
const x = Math.round((Math.cos(angle) * r) / step) * step;
|
||||||
|
const z = Math.round((Math.sin(angle) * r) / step) * step;
|
||||||
|
for (let k = 0; k < perColumn; k++) {
|
||||||
|
base.push(x, 0, z);
|
||||||
|
kind.push(1);
|
||||||
|
}
|
||||||
|
for (const layer of [-1, 0, 1]) {
|
||||||
|
base.push(x, layer, z);
|
||||||
|
kind.push(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const count = kind.length;
|
||||||
|
const scatter = new Float32Array(count * 3);
|
||||||
|
const rand = new Float32Array(count * 4);
|
||||||
|
for (let n = 0; n < count; n++) {
|
||||||
|
const u = Math.random() * 2 - 1;
|
||||||
|
const t = Math.random() * Math.PI * 2;
|
||||||
|
const s = Math.sqrt(1 - u * u);
|
||||||
|
const d = 4 + Math.random() * 7;
|
||||||
|
scatter.set([Math.cos(t) * s * d, u * d, Math.sin(t) * s * d], n * 3);
|
||||||
|
rand.set([Math.random(), Math.random(), Math.random(), Math.random()], n * 4);
|
||||||
|
}
|
||||||
|
const geometry = new THREE.BufferGeometry();
|
||||||
|
geometry.setAttribute("position", new THREE.Float32BufferAttribute(base, 3));
|
||||||
|
geometry.setAttribute("aScatter", new THREE.BufferAttribute(scatter, 3));
|
||||||
|
geometry.setAttribute("aRand", new THREE.BufferAttribute(rand, 4));
|
||||||
|
geometry.setAttribute("aKind", new THREE.Float32BufferAttribute(kind, 1));
|
||||||
|
geometry.boundingSphere = new THREE.Sphere(new THREE.Vector3(), 12);
|
||||||
|
return geometry;
|
||||||
|
}
|
||||||
|
|
||||||
|
const vertexShader = /* glsl */ `
|
||||||
|
uniform float uTime;
|
||||||
|
uniform float uIntro;
|
||||||
|
uniform float uSpread;
|
||||||
|
uniform float uScroll;
|
||||||
|
uniform float uScale;
|
||||||
|
uniform float uFocus;
|
||||||
|
uniform float uFade;
|
||||||
|
uniform vec2 uMouse;
|
||||||
|
uniform float uMouseAmt;
|
||||||
|
attribute vec3 aScatter;
|
||||||
|
attribute vec4 aRand;
|
||||||
|
attribute float aKind;
|
||||||
|
varying vec3 vColor;
|
||||||
|
varying float vAlpha;
|
||||||
|
varying float vSoft;
|
||||||
|
|
||||||
|
const vec3 PAPER = vec3(0.957, 0.949, 0.929);
|
||||||
|
const vec3 LIME = vec3(0.8, 1.0, 0.0);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
float layer = position.y;
|
||||||
|
float isPlane = 1.0 - step(0.5, aKind);
|
||||||
|
float isStream = step(0.5, aKind) * (1.0 - step(1.5, aKind));
|
||||||
|
float isNode = step(1.5, aKind);
|
||||||
|
|
||||||
|
// colunas: cada partícula sobe da camada de dados até a interface e recomeça
|
||||||
|
float flow = fract(uTime * (0.08 + aRand.x * 0.07) + aRand.y);
|
||||||
|
layer = mix(layer, flow * 2.0 - 1.0, isStream);
|
||||||
|
|
||||||
|
vec3 p = vec3(position.x, layer * uSpread, position.z);
|
||||||
|
float wave = sin(position.x * 1.7 + uTime * 0.55 + layer * 2.1) * cos(position.z * 1.3 - uTime * 0.4);
|
||||||
|
p.y += wave * (0.045 + uScroll * 0.25) * isPlane;
|
||||||
|
p.xz += (aRand.zw - 0.5) * 0.05 * isStream;
|
||||||
|
|
||||||
|
// cursor: uma onda que levanta a malha e acende os pontos em verde
|
||||||
|
float d = length(position.xz - uMouse);
|
||||||
|
float lift = exp(-d * d * 2.2) * uMouseAmt;
|
||||||
|
p.y += (lift * 0.32 + sin(d * 7.0 - uTime * 3.5) * lift * 0.05) * isPlane;
|
||||||
|
|
||||||
|
// entrada: cada ponto sai da nuvem com atraso próprio
|
||||||
|
float t = clamp((uIntro - aRand.z * 0.4) / 0.6, 0.0, 1.0);
|
||||||
|
float e = 1.0 - pow(1.0 - t, 4.0);
|
||||||
|
p = mix(aScatter, p, e);
|
||||||
|
|
||||||
|
vec4 mv = modelViewMatrix * vec4(p, 1.0);
|
||||||
|
gl_Position = projectionMatrix * mv;
|
||||||
|
float depth = -mv.z;
|
||||||
|
|
||||||
|
float size = 0.021 * isPlane * (0.7 + aRand.w * 0.6) + 0.024 * isStream + 0.07 * isNode;
|
||||||
|
float blur = clamp(abs(depth - uFocus) * 0.32, 0.0, 1.6);
|
||||||
|
gl_PointSize = min(size * uScale / max(depth, 0.05) * (1.0 + blur * 1.4), 64.0);
|
||||||
|
|
||||||
|
// cada camada é um disco: miolo que esmaece para a borda e um aro fino que desenha o contorno
|
||||||
|
float radial = length(position.xz);
|
||||||
|
float edge = 1.0 - smoothstep(RADIUS_IN, RADIUS_OUT, radial);
|
||||||
|
float rim = smoothstep(RADIUS_OUT - 0.16, RADIUS_OUT - 0.04, radial);
|
||||||
|
float alpha = isPlane * ((0.5 + 0.2 * step(0.5, position.y)) * edge + rim * 0.75) + isStream * 0.55 + isNode;
|
||||||
|
alpha *= (0.25 + 0.75 * e) / (1.0 + blur * 1.6);
|
||||||
|
alpha *= smoothstep(0.25, 1.6, depth) * uFade;
|
||||||
|
vAlpha = alpha;
|
||||||
|
vSoft = clamp(0.35 + blur * 0.45, 0.35, 1.0);
|
||||||
|
vColor = mix(PAPER, LIME, clamp(isStream + isNode + (lift * 1.6 + rim * 0.55) * isPlane, 0.0, 1.0));
|
||||||
|
}
|
||||||
|
`
|
||||||
|
.replaceAll("RADIUS_IN", (RADIUS * 0.62).toFixed(2))
|
||||||
|
.replaceAll("RADIUS_OUT", RADIUS.toFixed(2));
|
||||||
|
|
||||||
|
const fragmentShader = /* glsl */ `
|
||||||
|
varying vec3 vColor;
|
||||||
|
varying float vAlpha;
|
||||||
|
varying float vSoft;
|
||||||
|
void main() {
|
||||||
|
float r = length(gl_PointCoord - 0.5) * 2.0;
|
||||||
|
float a = 1.0 - smoothstep(1.0 - vSoft, 1.0, r);
|
||||||
|
if (a <= 0.0) discard;
|
||||||
|
gl_FragColor = vec4(vColor, a * vAlpha);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
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 Field({ progressRef, onReady, onError }: HeroFieldSceneProps) {
|
||||||
|
const { gl, camera, size } = useThree();
|
||||||
|
const group = useRef<THREE.Group>(null);
|
||||||
|
const dense = useMemo(
|
||||||
|
() => window.innerWidth >= 900 && (navigator.hardwareConcurrency ?? 4) >= 4,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const geometry = useMemo(() => buildField(dense), [dense]);
|
||||||
|
const material = useMemo(
|
||||||
|
() =>
|
||||||
|
new THREE.ShaderMaterial({
|
||||||
|
vertexShader,
|
||||||
|
fragmentShader,
|
||||||
|
transparent: true,
|
||||||
|
depthWrite: false,
|
||||||
|
blending: THREE.AdditiveBlending,
|
||||||
|
uniforms: {
|
||||||
|
uTime: { value: 0 },
|
||||||
|
uIntro: { value: 0 },
|
||||||
|
uSpread: { value: 1.05 },
|
||||||
|
uScroll: { value: 0 },
|
||||||
|
uScale: { value: 1 },
|
||||||
|
uFocus: { value: 9 },
|
||||||
|
uFade: { value: 1 },
|
||||||
|
uMouse: { value: new THREE.Vector2(9, 9) },
|
||||||
|
uMouseAmt: { value: 0 },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const state = useRef({
|
||||||
|
progress: 0,
|
||||||
|
pointer: new THREE.Vector2(),
|
||||||
|
lastMove: -10,
|
||||||
|
mouse: new THREE.Vector2(),
|
||||||
|
tilt: new THREE.Vector2(),
|
||||||
|
frames: 0,
|
||||||
|
});
|
||||||
|
const helpers = useMemo(
|
||||||
|
() => ({
|
||||||
|
ray: new THREE.Raycaster(),
|
||||||
|
plane: new THREE.Plane(),
|
||||||
|
hit: new THREE.Vector3(),
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onMove = (event: PointerEvent) => {
|
||||||
|
if (event.pointerType !== "mouse") return;
|
||||||
|
const s = state.current;
|
||||||
|
s.pointer.set(
|
||||||
|
(event.clientX / window.innerWidth) * 2 - 1,
|
||||||
|
-(event.clientY / window.innerHeight) * 2 + 1,
|
||||||
|
);
|
||||||
|
s.lastMove = performance.now() / 1000;
|
||||||
|
};
|
||||||
|
const onLost = (event: Event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
onError();
|
||||||
|
};
|
||||||
|
window.addEventListener("pointermove", onMove, { passive: true });
|
||||||
|
gl.domElement.addEventListener("webglcontextlost", onLost);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("pointermove", onMove);
|
||||||
|
gl.domElement.removeEventListener("webglcontextlost", onLost);
|
||||||
|
geometry.dispose();
|
||||||
|
material.dispose();
|
||||||
|
};
|
||||||
|
}, [gl, geometry, material, onError]);
|
||||||
|
|
||||||
|
useFrame((frame, delta) => {
|
||||||
|
const g = group.current;
|
||||||
|
if (!g) return;
|
||||||
|
const dt = Math.min(delta, 1 / 20);
|
||||||
|
const s = state.current;
|
||||||
|
const u = material.uniforms;
|
||||||
|
const time = frame.clock.elapsedTime;
|
||||||
|
|
||||||
|
// rolagem amortecida: o mergulho segue o dedo com um leve atraso de câmera
|
||||||
|
s.progress += ((progressRef.current ?? 0) - s.progress) * (1 - Math.exp(-dt * 5));
|
||||||
|
const p = s.progress;
|
||||||
|
const wide = size.width / size.height > 1.1;
|
||||||
|
|
||||||
|
u.uTime.value = time;
|
||||||
|
u.uIntro.value = Math.min(1, u.uIntro.value + dt / 2.6);
|
||||||
|
u.uScroll.value = p;
|
||||||
|
u.uSpread.value = 1.05 + 1.3 * smooth(0, 0.55, p);
|
||||||
|
u.uFade.value = 1 - smooth(0.86, 1, p);
|
||||||
|
u.uScale.value =
|
||||||
|
(gl.getPixelRatio() * size.height * 0.5) /
|
||||||
|
Math.tan(THREE.MathUtils.degToRad((camera as THREE.PerspectiveCamera).fov / 2));
|
||||||
|
|
||||||
|
// sem mouse (toque, ou mouse parado), um cursor fantasma percorre a malha
|
||||||
|
const mouseActive = time - s.lastMove < 2.5 && s.lastMove > 0 ? 1 : 0;
|
||||||
|
s.tilt.lerp(mouseActive ? s.pointer : new THREE.Vector2(), 1 - Math.exp(-dt * 3));
|
||||||
|
|
||||||
|
const face = smooth(0.04, 0.6, p);
|
||||||
|
g.rotation.set(
|
||||||
|
0.42 + (Math.PI / 2 - 0.42) * face - s.tilt.y * 0.08,
|
||||||
|
time * 0.045 + s.tilt.x * 0.18,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const settle = 1 - smooth(0, 0.4, p);
|
||||||
|
g.position.set(wide ? 1.85 * settle : 0, wide ? -0.15 * settle : -1.25 * settle, 0);
|
||||||
|
const dive = smooth(0.22, 1, p);
|
||||||
|
camera.position.z = 9 - 12.5 * dive * dive;
|
||||||
|
u.uFocus.value = Math.max(1.5, camera.position.z - g.position.z);
|
||||||
|
g.updateMatrixWorld();
|
||||||
|
|
||||||
|
if (mouseActive) {
|
||||||
|
helpers.ray.setFromCamera(s.pointer, camera);
|
||||||
|
helpers.plane
|
||||||
|
.set(new THREE.Vector3(0, 1, 0), -u.uSpread.value)
|
||||||
|
.applyMatrix4(g.matrixWorld);
|
||||||
|
if (helpers.ray.ray.intersectPlane(helpers.plane, helpers.hit)) {
|
||||||
|
g.worldToLocal(helpers.hit);
|
||||||
|
s.mouse.lerp(new THREE.Vector2(helpers.hit.x, helpers.hit.z), 1 - Math.exp(-dt * 10));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
s.mouse.lerp(
|
||||||
|
new THREE.Vector2(Math.sin(time * 0.31) * 1.5, Math.sin(time * 0.23 + 1.3) * 1.3),
|
||||||
|
1 - Math.exp(-dt * 1.5),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
u.uMouse.value.copy(s.mouse);
|
||||||
|
const targetAmt = (mouseActive ? 1 : 0.55) * (1 - smooth(0.05, 0.3, p));
|
||||||
|
u.uMouseAmt.value += (targetAmt - u.uMouseAmt.value) * (1 - Math.exp(-dt * 4));
|
||||||
|
|
||||||
|
if (++s.frames === 2) onReady();
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group ref={group}>
|
||||||
|
<points geometry={geometry} material={material} frustumCulled={false} />
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class SceneBoundary extends Component<
|
||||||
|
{ children: ReactNode; onError: () => void },
|
||||||
|
{ failed: boolean }
|
||||||
|
> {
|
||||||
|
state = { failed: false };
|
||||||
|
static getDerivedStateFromError() {
|
||||||
|
return { failed: true };
|
||||||
|
}
|
||||||
|
componentDidCatch() {
|
||||||
|
this.props.onError();
|
||||||
|
}
|
||||||
|
render() {
|
||||||
|
return this.state.failed ? null : this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HeroFieldScene(props: HeroFieldSceneProps) {
|
||||||
|
return (
|
||||||
|
<SceneBoundary onError={props.onError}>
|
||||||
|
<Canvas
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
camera={{ fov: 35, position: [0, 0, 9], near: 0.05, far: 40 }}
|
||||||
|
dpr={[1, 1.75]}
|
||||||
|
frameloop={props.visible ? "always" : "never"}
|
||||||
|
gl={{ alpha: true, antialias: false, powerPreference: "high-performance" }}
|
||||||
|
>
|
||||||
|
<Field {...props} />
|
||||||
|
</Canvas>
|
||||||
|
</SceneBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,350 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import dynamic from "next/dynamic";
|
|
||||||
import Image from "next/image";
|
|
||||||
import Link from "next/link";
|
|
||||||
import type { PointerEvent } from "react";
|
|
||||||
import { useCallback, useEffect, useId, useRef, useState } from "react";
|
|
||||||
import type { HeroSystemLayer, HeroSystemSceneProps } from "./HeroSystemScene";
|
|
||||||
|
|
||||||
const DynamicScene = dynamic<HeroSystemSceneProps>(
|
|
||||||
() => import("./HeroSystemScene"),
|
|
||||||
{ ssr: false },
|
|
||||||
);
|
|
||||||
|
|
||||||
const LAYERS: { id: HeroSystemLayer; label: string; description: string }[] = [
|
|
||||||
{
|
|
||||||
id: "overview",
|
|
||||||
label: "Visão geral",
|
|
||||||
description:
|
|
||||||
"Da tela ao que acontece por trás: interface, integrações e dados trabalhando juntos.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "interface",
|
|
||||||
label: "Interface",
|
|
||||||
description:
|
|
||||||
"A parte que as pessoas usam. Aqui, uma tela real do NutriScan, projeto do portfólio.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "integrations",
|
|
||||||
label: "Integrações",
|
|
||||||
description:
|
|
||||||
"Conexões entre sistemas para levar informações de uma etapa da operação à próxima.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "data",
|
|
||||||
label: "Dados",
|
|
||||||
description:
|
|
||||||
"A base que organiza e guarda as informações de que a operação precisa.",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const REST_POSE = { x: 0, y: 0 };
|
|
||||||
type Gesture = {
|
|
||||||
id: number;
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
startPose: number;
|
|
||||||
width: number;
|
|
||||||
dragging: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function HeroSystem() {
|
|
||||||
const descriptionId = useId();
|
|
||||||
const viewportRef = useRef<HTMLDivElement>(null);
|
|
||||||
const frameRef = useRef<number | null>(null);
|
|
||||||
const nextPoseRef = useRef(REST_POSE);
|
|
||||||
const gestureRef = useRef<Gesture | null>(null);
|
|
||||||
const suppressClickRef = useRef(false);
|
|
||||||
const [activeLayer, setActiveLayer] = useState<HeroSystemLayer>("overview");
|
|
||||||
const [pose, setPose] = useState(REST_POSE);
|
|
||||||
const [canRender, setCanRender] = useState(false);
|
|
||||||
const [ready, setReady] = useState(false);
|
|
||||||
const [failed, setFailed] = useState(false);
|
|
||||||
const [visible, setVisible] = useState(false);
|
|
||||||
const renderScene = canRender && !failed;
|
|
||||||
const sceneActive = renderScene && ready;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const preference = window.matchMedia("(prefers-reduced-motion: reduce)");
|
|
||||||
let supported: boolean | undefined;
|
|
||||||
const updateCapability = () => {
|
|
||||||
if (preference.matches) {
|
|
||||||
setCanRender(false);
|
|
||||||
setReady(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (supported === undefined) {
|
|
||||||
if (typeof window.WebGL2RenderingContext === "undefined") {
|
|
||||||
supported = false;
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
const canvas = document.createElement("canvas");
|
|
||||||
const context = canvas.getContext("webgl2");
|
|
||||||
supported = context !== null;
|
|
||||||
context?.getExtension("WEBGL_lose_context")?.loseContext();
|
|
||||||
} catch {
|
|
||||||
supported = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
mountScene(supported);
|
|
||||||
};
|
|
||||||
// A cena 3D (Three.js) so entra depois que a pagina ficou ociosa: titulo, imagem e botoes pintam e hidratam primeiro.
|
|
||||||
let idle: number | undefined;
|
|
||||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
||||||
const cancelPending = () => {
|
|
||||||
if (idle !== undefined) window.cancelIdleCallback?.(idle);
|
|
||||||
if (timer !== undefined) clearTimeout(timer);
|
|
||||||
idle = timer = undefined;
|
|
||||||
};
|
|
||||||
const mountScene = (value: boolean) => {
|
|
||||||
cancelPending();
|
|
||||||
if (!value) {
|
|
||||||
setCanRender(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (typeof window.requestIdleCallback === "function") {
|
|
||||||
idle = window.requestIdleCallback(() => setCanRender(true), {
|
|
||||||
timeout: 2500,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
timer = setTimeout(() => setCanRender(true), 1200);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
updateCapability();
|
|
||||||
preference.addEventListener("change", updateCapability);
|
|
||||||
return () => {
|
|
||||||
cancelPending();
|
|
||||||
preference.removeEventListener("change", updateCapability);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const viewport = viewportRef.current;
|
|
||||||
if (!viewport) return;
|
|
||||||
let intersecting = false;
|
|
||||||
const updateVisibility = () => {
|
|
||||||
setVisible(intersecting && document.visibilityState === "visible");
|
|
||||||
};
|
|
||||||
const observer = new IntersectionObserver(([entry]) => {
|
|
||||||
intersecting = entry.isIntersecting;
|
|
||||||
updateVisibility();
|
|
||||||
});
|
|
||||||
observer.observe(viewport);
|
|
||||||
document.addEventListener("visibilitychange", updateVisibility);
|
|
||||||
return () => {
|
|
||||||
observer.disconnect();
|
|
||||||
document.removeEventListener("visibilitychange", updateVisibility);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const queuePose = useCallback((x: number, y: number) => {
|
|
||||||
nextPoseRef.current = {
|
|
||||||
x: Math.max(-1, Math.min(1, x)),
|
|
||||||
y: Math.max(-1, Math.min(1, y)),
|
|
||||||
};
|
|
||||||
if (frameRef.current !== null) return;
|
|
||||||
frameRef.current = window.requestAnimationFrame(() => {
|
|
||||||
frameRef.current = null;
|
|
||||||
setPose(nextPoseRef.current);
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const releaseGesture = useCallback(() => {
|
|
||||||
const gesture = gestureRef.current;
|
|
||||||
gestureRef.current = null;
|
|
||||||
const viewport = viewportRef.current;
|
|
||||||
if (gesture && viewport?.hasPointerCapture(gesture.id)) {
|
|
||||||
viewport.releasePointerCapture(gesture.id);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!sceneActive || !visible) {
|
|
||||||
releaseGesture();
|
|
||||||
if (frameRef.current !== null)
|
|
||||||
window.cancelAnimationFrame(frameRef.current);
|
|
||||||
frameRef.current = null;
|
|
||||||
nextPoseRef.current = REST_POSE;
|
|
||||||
setPose(REST_POSE);
|
|
||||||
}
|
|
||||||
return () => {
|
|
||||||
releaseGesture();
|
|
||||||
if (frameRef.current !== null)
|
|
||||||
window.cancelAnimationFrame(frameRef.current);
|
|
||||||
frameRef.current = null;
|
|
||||||
};
|
|
||||||
}, [sceneActive, visible, releaseGesture]);
|
|
||||||
|
|
||||||
const handleReady = useCallback(() => setReady(true), []);
|
|
||||||
const handleError = useCallback(() => {
|
|
||||||
setFailed(true);
|
|
||||||
setReady(false);
|
|
||||||
}, []);
|
|
||||||
const selectSceneLayer = useCallback((layer: HeroSystemLayer) => {
|
|
||||||
if (!suppressClickRef.current) setActiveLayer(layer);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handlePointerDown = (event: PointerEvent<HTMLDivElement>) => {
|
|
||||||
if (!sceneActive || !visible || !event.isPrimary || event.button !== 0)
|
|
||||||
return;
|
|
||||||
suppressClickRef.current = false;
|
|
||||||
gestureRef.current = {
|
|
||||||
id: event.pointerId,
|
|
||||||
x: event.clientX,
|
|
||||||
y: event.clientY,
|
|
||||||
startPose: nextPoseRef.current.x,
|
|
||||||
width: event.currentTarget.clientWidth,
|
|
||||||
dragging: false,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePointerMove = (event: PointerEvent<HTMLDivElement>) => {
|
|
||||||
if (!sceneActive || !visible) return;
|
|
||||||
const gesture = gestureRef.current;
|
|
||||||
if (gesture && gesture.id === event.pointerId) {
|
|
||||||
const dx = event.clientX - gesture.x;
|
|
||||||
const dy = event.clientY - gesture.y;
|
|
||||||
if (!gesture.dragging) {
|
|
||||||
if (Math.abs(dy) > 7 && Math.abs(dy) > Math.abs(dx)) {
|
|
||||||
releaseGesture();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (Math.abs(dx) < 7) return;
|
|
||||||
gesture.dragging = true;
|
|
||||||
suppressClickRef.current = true;
|
|
||||||
event.currentTarget.setPointerCapture(event.pointerId);
|
|
||||||
}
|
|
||||||
queuePose(gesture.startPose + (dx / Math.max(gesture.width, 1)) * 3, 0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (event.pointerType !== "mouse" || event.buttons !== 0) return;
|
|
||||||
const bounds = event.currentTarget.getBoundingClientRect();
|
|
||||||
queuePose(
|
|
||||||
((event.clientX - bounds.left) / Math.max(bounds.width, 1)) * 2 - 1,
|
|
||||||
((event.clientY - bounds.top) / Math.max(bounds.height, 1)) * 2 - 1,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const cancelGesture = () => {
|
|
||||||
releaseGesture();
|
|
||||||
if (sceneActive && visible) queuePose(0, 0);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="hero-system min-w-0 w-full">
|
|
||||||
<div
|
|
||||||
ref={viewportRef}
|
|
||||||
className="hero-system-viewport relative isolate h-[350px] w-full overflow-hidden sm:h-[400px] lg:h-[440px]"
|
|
||||||
style={{
|
|
||||||
touchAction: "pan-y",
|
|
||||||
cursor: sceneActive ? "grab" : undefined,
|
|
||||||
}}
|
|
||||||
onPointerDown={handlePointerDown}
|
|
||||||
onPointerMove={handlePointerMove}
|
|
||||||
onPointerUp={releaseGesture}
|
|
||||||
onPointerCancel={cancelGesture}
|
|
||||||
onLostPointerCapture={releaseGesture}
|
|
||||||
onPointerLeave={() => {
|
|
||||||
if (!gestureRef.current?.dragging) cancelGesture();
|
|
||||||
}}
|
|
||||||
onClickCapture={(event) => {
|
|
||||||
if (suppressClickRef.current) event.stopPropagation();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`absolute inset-0 flex items-center px-sp-3 ${sceneActive ? "pointer-events-none opacity-0" : "opacity-100"}`}
|
|
||||||
aria-hidden={sceneActive}
|
|
||||||
>
|
|
||||||
<div className="w-full">
|
|
||||||
<div className="mb-sp-3 flex items-center justify-between gap-sp-3 text-[12px] text-white/65">
|
|
||||||
<span className="font-medium text-[#f4f2ed]">
|
|
||||||
Interface real: NutriScan
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="relative block aspect-[2/1] overflow-hidden rounded-lg border border-white/15 bg-[#151a16] shadow-[0_24px_70px_rgba(0,0,0,0.45)]">
|
|
||||||
<Image
|
|
||||||
src="/assets/notebook/nutriscan.webp"
|
|
||||||
alt="Tela real do projeto NutriScan."
|
|
||||||
fill
|
|
||||||
priority
|
|
||||||
sizes="(min-width: 1024px) 44vw, 92vw"
|
|
||||||
className="object-cover object-top"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{renderScene && (
|
|
||||||
<div
|
|
||||||
aria-hidden="true"
|
|
||||||
className={`absolute inset-0 ${sceneActive ? "opacity-100" : "pointer-events-none opacity-0"}`}
|
|
||||||
>
|
|
||||||
<DynamicScene
|
|
||||||
activeLayer={activeLayer}
|
|
||||||
pose={pose}
|
|
||||||
visible={visible}
|
|
||||||
onReady={handleReady}
|
|
||||||
onError={handleError}
|
|
||||||
onSelectLayer={selectSceneLayer}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{sceneActive && (
|
|
||||||
<p className="pointer-events-none absolute inset-x-0 bottom-sp-3 text-center text-[12px] text-white/60">
|
|
||||||
<span className="hidden sm:inline">
|
|
||||||
Mova ou arraste para explorar
|
|
||||||
</span>
|
|
||||||
<span className="sm:hidden">
|
|
||||||
Arraste para os lados para explorar
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative px-sp-1 sm:px-sp-3">
|
|
||||||
<fieldset
|
|
||||||
aria-label="Explorar camadas do sistema"
|
|
||||||
className="grid grid-cols-2 gap-1 min-[400px]:grid-cols-4"
|
|
||||||
>
|
|
||||||
{LAYERS.map((layer) => (
|
|
||||||
<button
|
|
||||||
key={layer.id}
|
|
||||||
type="button"
|
|
||||||
aria-pressed={activeLayer === layer.id}
|
|
||||||
aria-controls={descriptionId}
|
|
||||||
onClick={() => {
|
|
||||||
suppressClickRef.current = false;
|
|
||||||
setActiveLayer(layer.id);
|
|
||||||
if (layer.id === "overview" && sceneActive && visible)
|
|
||||||
queuePose(0, 0);
|
|
||||||
}}
|
|
||||||
className={`min-h-11 px-sp-2 py-sp-2 text-[12px] font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--slash)] motion-reduce:transition-none sm:text-[13px] ${activeLayer === layer.id ? "bg-[var(--slash)] text-[var(--ink)]" : "bg-white/5 text-white/75 hover:bg-white/10 hover:text-white"}`}
|
|
||||||
>
|
|
||||||
{layer.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</fieldset>
|
|
||||||
<p
|
|
||||||
id={descriptionId}
|
|
||||||
aria-live="polite"
|
|
||||||
aria-atomic="true"
|
|
||||||
className="mt-sp-3 min-h-[4.5em] text-[13px] leading-[1.5] text-white/75 sm:min-h-[3em]"
|
|
||||||
>
|
|
||||||
{LAYERS.find((layer) => layer.id === activeLayer)?.description}
|
|
||||||
</p>
|
|
||||||
<div className="mt-sp-1 flex flex-wrap items-center justify-between gap-x-sp-3">
|
|
||||||
<p className="max-w-[230px] text-[11px] leading-[1.5] text-white/55">
|
|
||||||
Camadas ilustrativas de um sistema
|
|
||||||
</p>
|
|
||||||
<Link
|
|
||||||
href="/cases/nutriscan"
|
|
||||||
className="inline-flex min-h-11 items-center text-[12px] font-medium text-[#f4f2ed] underline decoration-white/35 underline-offset-4 hover:decoration-[var(--slash)] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[var(--slash)]"
|
|
||||||
>
|
|
||||||
Interface real: NutriScan
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,577 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { RoundedBox } from "@react-three/drei";
|
|
||||||
import { Canvas, useFrame, useThree } from "@react-three/fiber";
|
|
||||||
import {
|
|
||||||
Component,
|
|
||||||
type ReactNode,
|
|
||||||
Suspense,
|
|
||||||
useEffect,
|
|
||||||
useLayoutEffect,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import * as THREE from "three";
|
|
||||||
|
|
||||||
export type HeroSystemLayer =
|
|
||||||
| "overview"
|
|
||||||
| "interface"
|
|
||||||
| "integrations"
|
|
||||||
| "data";
|
|
||||||
|
|
||||||
export type HeroSystemSceneProps = {
|
|
||||||
activeLayer: HeroSystemLayer;
|
|
||||||
pose: { x: number; y: number };
|
|
||||||
visible: boolean;
|
|
||||||
onReady: () => void;
|
|
||||||
onError: () => void;
|
|
||||||
onSelectLayer: (layer: HeroSystemLayer) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
type LayerPose = readonly [number, number, number, number];
|
|
||||||
type SceneTextures = {
|
|
||||||
screen: THREE.CanvasTexture;
|
|
||||||
integrations: THREE.CanvasTexture;
|
|
||||||
data: THREE.CanvasTexture;
|
|
||||||
};
|
|
||||||
|
|
||||||
const LIME = "#ccff00";
|
|
||||||
const LAYERS = ["interface", "integrations", "data"] as const;
|
|
||||||
const LAYER_POSES: Record<HeroSystemLayer, readonly LayerPose[]> = {
|
|
||||||
overview: [
|
|
||||||
[-0.38, -0.42, 0.9, 1],
|
|
||||||
[0.12, 0.25, 0, 1],
|
|
||||||
[0.65, 0.92, -0.9, 1],
|
|
||||||
],
|
|
||||||
interface: [
|
|
||||||
[-0.1, -0.15, 1.3, 1.04],
|
|
||||||
[0.36, 0.6, -0.45, 0.91],
|
|
||||||
[0.8, 1.08, -1.25, 0.88],
|
|
||||||
],
|
|
||||||
integrations: [
|
|
||||||
[-0.82, -1.05, 1.05, 0.77],
|
|
||||||
[0.12, 0.55, 0.55, 1.02],
|
|
||||||
[0.76, 1.16, -1, 0.88],
|
|
||||||
],
|
|
||||||
data: [
|
|
||||||
[-0.9, -1.25, 1.02, 0.68],
|
|
||||||
[-0.44, -0.48, 0.18, 0.81],
|
|
||||||
[0.38, 1.08, 0.55, 1.02],
|
|
||||||
],
|
|
||||||
};
|
|
||||||
const NODE_X = [-1.28, 0, 1.28];
|
|
||||||
const SERVER_ROWS = [-0.47, -0.17, 0.13];
|
|
||||||
|
|
||||||
function canvasContext(width: number, height: number) {
|
|
||||||
const canvas = document.createElement("canvas");
|
|
||||||
canvas.width = width;
|
|
||||||
canvas.height = height;
|
|
||||||
const context = canvas.getContext("2d");
|
|
||||||
if (!context) throw new Error("Canvas 2D is unavailable");
|
|
||||||
return { canvas, context };
|
|
||||||
}
|
|
||||||
|
|
||||||
function asTexture(canvas: HTMLCanvasElement) {
|
|
||||||
const texture = new THREE.CanvasTexture(canvas);
|
|
||||||
texture.colorSpace = THREE.SRGBColorSpace;
|
|
||||||
texture.anisotropy = 4;
|
|
||||||
return texture;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createBoardTexture(layer: "integrations" | "data") {
|
|
||||||
const { canvas, context: ctx } = canvasContext(1024, 640);
|
|
||||||
ctx.fillStyle = "#121a1b";
|
|
||||||
ctx.fillRect(0, 0, 1024, 640);
|
|
||||||
ctx.strokeStyle = "#293737";
|
|
||||||
ctx.lineWidth = 1;
|
|
||||||
for (let x = 32; x < 1024; x += 40) {
|
|
||||||
for (let y = 32; y < 640; y += 40) {
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(x - 2, y);
|
|
||||||
ctx.lineTo(x + 2, y);
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ctx.fillStyle = "#edf0e6";
|
|
||||||
ctx.font = "500 32px sans-serif";
|
|
||||||
ctx.fillText(layer === "integrations" ? "Integrações" : "Dados", 46, 61);
|
|
||||||
ctx.fillStyle = LIME;
|
|
||||||
ctx.fillRect(932, 43, 42, 5);
|
|
||||||
ctx.strokeStyle = "#455c50";
|
|
||||||
ctx.lineWidth = 3;
|
|
||||||
const endpoints = [175, 512, 849];
|
|
||||||
for (const x of endpoints) {
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(x, 132);
|
|
||||||
ctx.lineTo(x, 231);
|
|
||||||
ctx.lineTo(512, 300);
|
|
||||||
ctx.lineTo(512, 490);
|
|
||||||
ctx.lineTo(x, 490);
|
|
||||||
ctx.lineTo(x, 556);
|
|
||||||
ctx.stroke();
|
|
||||||
ctx.fillStyle = LIME;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(x, 556, 6, 0, Math.PI * 2);
|
|
||||||
ctx.fill();
|
|
||||||
}
|
|
||||||
ctx.strokeStyle = "#a6c748";
|
|
||||||
ctx.lineWidth = 2;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(52, 342);
|
|
||||||
ctx.lineTo(125, 342);
|
|
||||||
ctx.lineTo(206, 422);
|
|
||||||
ctx.lineTo(820, 422);
|
|
||||||
ctx.lineTo(899, 342);
|
|
||||||
ctx.lineTo(972, 342);
|
|
||||||
ctx.stroke();
|
|
||||||
return asTexture(canvas);
|
|
||||||
}
|
|
||||||
|
|
||||||
function useSceneTextures(onError: () => void) {
|
|
||||||
const [textures, setTextures] = useState<SceneTextures | null>(null);
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
const owned: THREE.Texture[] = [];
|
|
||||||
const image = new Image();
|
|
||||||
image.decoding = "async";
|
|
||||||
image.onload = () => {
|
|
||||||
if (cancelled) return;
|
|
||||||
try {
|
|
||||||
const { canvas, context } = canvasContext(1600, 800);
|
|
||||||
// Keep the actual product interface; exclude the old metrics footer.
|
|
||||||
context.drawImage(image, 0, 0, 1600, 800, 0, 0, 1600, 800);
|
|
||||||
const screen = asTexture(canvas);
|
|
||||||
owned.push(screen);
|
|
||||||
const integrations = createBoardTexture("integrations");
|
|
||||||
owned.push(integrations);
|
|
||||||
const data = createBoardTexture("data");
|
|
||||||
owned.push(data);
|
|
||||||
setTextures({ screen, integrations, data });
|
|
||||||
} catch {
|
|
||||||
onError();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
image.onerror = () => {
|
|
||||||
if (!cancelled) onError();
|
|
||||||
};
|
|
||||||
image.src = "/assets/notebook/nutriscan.webp";
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
image.onload = null;
|
|
||||||
image.onerror = null;
|
|
||||||
image.removeAttribute("src");
|
|
||||||
for (const texture of owned) texture.dispose();
|
|
||||||
};
|
|
||||||
}, [onError]);
|
|
||||||
return textures;
|
|
||||||
}
|
|
||||||
|
|
||||||
function Plate({ selected }: { selected: boolean }) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<RoundedBox args={[4.28, 2.72, 0.14]} radius={0.06} smoothness={4}>
|
|
||||||
<meshStandardMaterial
|
|
||||||
color={selected ? "#93ad76" : "#687579"}
|
|
||||||
metalness={0.62}
|
|
||||||
roughness={0.29}
|
|
||||||
/>
|
|
||||||
</RoundedBox>
|
|
||||||
<RoundedBox
|
|
||||||
args={[4.2, 2.64, 0.08]}
|
|
||||||
radius={0.03}
|
|
||||||
smoothness={4}
|
|
||||||
position={[0, 0, 0.075]}
|
|
||||||
>
|
|
||||||
<meshStandardMaterial color="#111919" metalness={0.3} roughness={0.4} />
|
|
||||||
</RoundedBox>
|
|
||||||
<mesh position={[0, -1.3, 0.124]}>
|
|
||||||
<boxGeometry args={[3.8, 0.018, 0.014]} />
|
|
||||||
<meshBasicMaterial color={selected ? LIME : "#667451"} />
|
|
||||||
</mesh>
|
|
||||||
{[-1.96, 1.96].map((x) => (
|
|
||||||
<mesh key={x} position={[x, 1.18, 0.13]}>
|
|
||||||
<circleGeometry args={[0.025, 12]} />
|
|
||||||
<meshStandardMaterial
|
|
||||||
color="#b6c0b7"
|
|
||||||
metalness={0.8}
|
|
||||||
roughness={0.3}
|
|
||||||
/>
|
|
||||||
</mesh>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function InterfacePlate({ texture }: { texture: THREE.Texture }) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<RoundedBox
|
|
||||||
args={[4.02, 2.09, 0.035]}
|
|
||||||
position={[0, -0.075, 0.139]}
|
|
||||||
radius={0.012}
|
|
||||||
smoothness={3}
|
|
||||||
>
|
|
||||||
<meshStandardMaterial
|
|
||||||
color="#dfe5d7"
|
|
||||||
metalness={0.2}
|
|
||||||
roughness={0.38}
|
|
||||||
/>
|
|
||||||
</RoundedBox>
|
|
||||||
<mesh position={[0, -0.075, 0.16]}>
|
|
||||||
<planeGeometry args={[3.94, 1.97]} />
|
|
||||||
<meshBasicMaterial map={texture} toneMapped={false} />
|
|
||||||
</mesh>
|
|
||||||
<mesh position={[-1.84, 1.16, 0.13]}>
|
|
||||||
<circleGeometry args={[0.025, 16]} />
|
|
||||||
<meshBasicMaterial color={LIME} />
|
|
||||||
</mesh>
|
|
||||||
<mesh position={[-1.6, 1.16, 0.13]}>
|
|
||||||
<boxGeometry args={[0.28, 0.018, 0.012]} />
|
|
||||||
<meshBasicMaterial color="#b3bcb3" />
|
|
||||||
</mesh>
|
|
||||||
<mesh position={[0, 1.16, 0.13]}>
|
|
||||||
<boxGeometry args={[1.35, 0.12, 0.015]} />
|
|
||||||
<meshStandardMaterial color="#2b3633" roughness={0.7} />
|
|
||||||
</mesh>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function IntegrationNodes() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{NODE_X.map((x) => (
|
|
||||||
<group key={x} position={[x, 0.45, 0.21]}>
|
|
||||||
<RoundedBox args={[0.7, 0.52, 0.14]} radius={0.06} smoothness={3}>
|
|
||||||
<meshStandardMaterial
|
|
||||||
color="#d6ddcf"
|
|
||||||
metalness={0.5}
|
|
||||||
roughness={0.28}
|
|
||||||
/>
|
|
||||||
</RoundedBox>
|
|
||||||
<mesh position={[0, 0, 0.077]}>
|
|
||||||
<planeGeometry args={[0.53, 0.35]} />
|
|
||||||
<meshStandardMaterial
|
|
||||||
color="#25382d"
|
|
||||||
roughness={0.36}
|
|
||||||
metalness={0.35}
|
|
||||||
/>
|
|
||||||
</mesh>
|
|
||||||
<mesh position={[0, 0, 0.083]}>
|
|
||||||
<boxGeometry args={[0.2, 0.035, 0.01]} />
|
|
||||||
<meshBasicMaterial color={LIME} />
|
|
||||||
</mesh>
|
|
||||||
</group>
|
|
||||||
))}
|
|
||||||
<RoundedBox
|
|
||||||
args={[1.03, 0.68, 0.2]}
|
|
||||||
radius={0.055}
|
|
||||||
smoothness={3}
|
|
||||||
position={[0, -0.38, 0.24]}
|
|
||||||
>
|
|
||||||
<meshStandardMaterial
|
|
||||||
color="#89a962"
|
|
||||||
metalness={0.55}
|
|
||||||
roughness={0.32}
|
|
||||||
/>
|
|
||||||
</RoundedBox>
|
|
||||||
<mesh position={[0, -0.38, 0.345]}>
|
|
||||||
<planeGeometry args={[0.77, 0.44]} />
|
|
||||||
<meshStandardMaterial color="#15251c" metalness={0.2} roughness={0.5} />
|
|
||||||
</mesh>
|
|
||||||
{[-0.15, 0, 0.15].map((y) => (
|
|
||||||
<mesh key={y} position={[0, -0.38 + y, 0.352]}>
|
|
||||||
<boxGeometry args={[0.43, 0.022, 0.01]} />
|
|
||||||
<meshBasicMaterial color={LIME} />
|
|
||||||
</mesh>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DataVolumes() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{[-1.25, 0].map((x) => (
|
|
||||||
<group key={x} position={[x, 0.03, 0.3]}>
|
|
||||||
<RoundedBox args={[0.86, 1.24, 0.3]} radius={0.065} smoothness={3}>
|
|
||||||
<meshStandardMaterial
|
|
||||||
color="#51645f"
|
|
||||||
metalness={0.6}
|
|
||||||
roughness={0.3}
|
|
||||||
/>
|
|
||||||
</RoundedBox>
|
|
||||||
{SERVER_ROWS.map((y) => (
|
|
||||||
<group key={y} position={[0, y + 0.17, 0.159]}>
|
|
||||||
<mesh>
|
|
||||||
<boxGeometry args={[0.7, 0.21, 0.035]} />
|
|
||||||
<meshStandardMaterial
|
|
||||||
color="#142020"
|
|
||||||
metalness={0.3}
|
|
||||||
roughness={0.45}
|
|
||||||
/>
|
|
||||||
</mesh>
|
|
||||||
<mesh position={[-0.25, 0, 0.022]}>
|
|
||||||
<circleGeometry args={[0.021, 12]} />
|
|
||||||
<meshBasicMaterial color={LIME} />
|
|
||||||
</mesh>
|
|
||||||
<mesh position={[0.06, 0, 0.022]}>
|
|
||||||
<boxGeometry args={[0.34, 0.025, 0.013]} />
|
|
||||||
<meshStandardMaterial
|
|
||||||
color="#a6b3a5"
|
|
||||||
metalness={0.6}
|
|
||||||
roughness={0.35}
|
|
||||||
/>
|
|
||||||
</mesh>
|
|
||||||
</group>
|
|
||||||
))}
|
|
||||||
</group>
|
|
||||||
))}
|
|
||||||
<group position={[1.28, 0.02, 0.59]}>
|
|
||||||
{[-0.2, 0, 0.2].map((y) => (
|
|
||||||
<group key={y} position={[0, y, 0]}>
|
|
||||||
<mesh>
|
|
||||||
<cylinderGeometry args={[0.43, 0.43, 0.17, 40]} />
|
|
||||||
<meshStandardMaterial
|
|
||||||
color="#a8b6a4"
|
|
||||||
metalness={0.62}
|
|
||||||
roughness={0.29}
|
|
||||||
/>
|
|
||||||
</mesh>
|
|
||||||
<mesh position={[0, 0.078, 0]}>
|
|
||||||
<cylinderGeometry args={[0.435, 0.435, 0.015, 40]} />
|
|
||||||
<meshBasicMaterial color={LIME} />
|
|
||||||
</mesh>
|
|
||||||
</group>
|
|
||||||
))}
|
|
||||||
</group>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SystemAssembly(
|
|
||||||
props: HeroSystemSceneProps & {
|
|
||||||
textures: SceneTextures;
|
|
||||||
reducedMotion: boolean;
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
const { textures, reducedMotion } = props;
|
|
||||||
const root = useRef<THREE.Group>(null);
|
|
||||||
const plates = useRef<(THREE.Group | null)[]>([]);
|
|
||||||
const entrance = useRef(0);
|
|
||||||
const ready = useRef(false);
|
|
||||||
const failed = useRef(false);
|
|
||||||
const { camera, gl, invalidate, size } = useThree();
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
if (!(camera instanceof THREE.PerspectiveCamera)) return;
|
|
||||||
camera.zoom = Math.min(1.1, size.width / Math.max(size.height, 1) / 1.12);
|
|
||||||
camera.updateProjectionMatrix();
|
|
||||||
if (props.visible) invalidate();
|
|
||||||
}, [camera, invalidate, props.visible, size.height, size.width]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (props.visible) invalidate();
|
|
||||||
}, [invalidate, props]);
|
|
||||||
|
|
||||||
// Render on demand and stop scheduling as soon as every transform settles.
|
|
||||||
useFrame((state, delta) => {
|
|
||||||
if (
|
|
||||||
!props.visible ||
|
|
||||||
document.hidden ||
|
|
||||||
failed.current ||
|
|
||||||
!root.current ||
|
|
||||||
gl.getContext().isContextLost()
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
const dt = Math.min(delta, 1 / 30);
|
|
||||||
entrance.current = reducedMotion
|
|
||||||
? 1
|
|
||||||
: Math.min(1, entrance.current + dt / 1.05);
|
|
||||||
const assembly = (1 - entrance.current) ** 3;
|
|
||||||
const alpha = reducedMotion ? 1 : 1 - Math.exp(-11 * dt);
|
|
||||||
const targetX = -0.12 + Math.max(-1, Math.min(1, props.pose.y)) * 0.11;
|
|
||||||
const targetY = -0.36 + Math.max(-1, Math.min(1, props.pose.x)) * 0.16;
|
|
||||||
let moving = entrance.current < 1;
|
|
||||||
const rotation = root.current.rotation;
|
|
||||||
rotation.x += (targetX - rotation.x) * alpha;
|
|
||||||
rotation.y += (targetY - rotation.y) * alpha;
|
|
||||||
if (
|
|
||||||
Math.abs(targetX - rotation.x) + Math.abs(targetY - rotation.y) >
|
|
||||||
0.0004
|
|
||||||
)
|
|
||||||
moving = true;
|
|
||||||
else {
|
|
||||||
rotation.x = targetX;
|
|
||||||
rotation.y = targetY;
|
|
||||||
}
|
|
||||||
const targets = LAYER_POSES[props.activeLayer];
|
|
||||||
for (let index = 0; index < LAYERS.length; index++) {
|
|
||||||
const plate = plates.current[index];
|
|
||||||
if (!plate) continue;
|
|
||||||
const target = targets[index];
|
|
||||||
const x = target[0] + (index - 1) * assembly * 0.65;
|
|
||||||
const y = target[1] + (index - 1) * assembly * 0.5;
|
|
||||||
const z = target[2] - (3 - index) * assembly * 0.8;
|
|
||||||
plate.position.x += (x - plate.position.x) * alpha;
|
|
||||||
plate.position.y += (y - plate.position.y) * alpha;
|
|
||||||
plate.position.z += (z - plate.position.z) * alpha;
|
|
||||||
const scale = plate.scale.x + (target[3] - plate.scale.x) * alpha;
|
|
||||||
plate.scale.setScalar(scale);
|
|
||||||
const remaining =
|
|
||||||
Math.abs(x - plate.position.x) +
|
|
||||||
Math.abs(y - plate.position.y) +
|
|
||||||
Math.abs(z - plate.position.z) +
|
|
||||||
Math.abs(target[3] - scale);
|
|
||||||
if (remaining > 0.0008) moving = true;
|
|
||||||
else {
|
|
||||||
plate.position.set(x, y, z);
|
|
||||||
plate.scale.setScalar(target[3]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
gl.render(state.scene, camera);
|
|
||||||
if (!ready.current && !gl.getContext().isContextLost()) {
|
|
||||||
ready.current = true;
|
|
||||||
props.onReady();
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
failed.current = true;
|
|
||||||
props.onError();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (moving) invalidate();
|
|
||||||
}, 1);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<ambientLight intensity={1.15} />
|
|
||||||
<hemisphereLight args={["#f6faed", "#283b30", 1.8]} />
|
|
||||||
<directionalLight position={[1, 5, 6]} color="#f6f9ef" intensity={3.7} />
|
|
||||||
<directionalLight position={[-5, 1, 2]} color={LIME} intensity={2.1} />
|
|
||||||
<directionalLight position={[4, -1, -3]} color="#e0e8df" intensity={3} />
|
|
||||||
<group
|
|
||||||
ref={root}
|
|
||||||
rotation={[-0.12, -0.36, -0.055]}
|
|
||||||
position={[0, -0.07, 0]}
|
|
||||||
>
|
|
||||||
{LAYERS.map((layer, index) => (
|
|
||||||
<group
|
|
||||||
key={layer}
|
|
||||||
ref={(group) => {
|
|
||||||
plates.current[index] = group;
|
|
||||||
}}
|
|
||||||
position={[
|
|
||||||
LAYER_POSES.overview[index][0],
|
|
||||||
LAYER_POSES.overview[index][1] + (index - 1) * 0.5,
|
|
||||||
LAYER_POSES.overview[index][2] - (3 - index) * 0.8,
|
|
||||||
]}
|
|
||||||
onPointerUp={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
props.onSelectLayer(layer);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Plate
|
|
||||||
selected={
|
|
||||||
props.activeLayer === layer || props.activeLayer === "overview"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{layer === "interface" ? (
|
|
||||||
<InterfacePlate texture={textures.screen} />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<mesh position={[0, 0, 0.12]}>
|
|
||||||
<planeGeometry args={[4.02, 2.5]} />
|
|
||||||
<meshBasicMaterial map={textures[layer]} toneMapped={false} />
|
|
||||||
</mesh>
|
|
||||||
{layer === "integrations" ? (
|
|
||||||
<IntegrationNodes />
|
|
||||||
) : (
|
|
||||||
<DataVolumes />
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</group>
|
|
||||||
))}
|
|
||||||
</group>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SceneContent(
|
|
||||||
props: HeroSystemSceneProps & { reducedMotion: boolean },
|
|
||||||
) {
|
|
||||||
const textures = useSceneTextures(props.onError);
|
|
||||||
const gl = useThree((state) => state.gl);
|
|
||||||
useEffect(() => {
|
|
||||||
const canvas = gl.domElement;
|
|
||||||
const handleContextLost = (event: Event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
props.onError();
|
|
||||||
};
|
|
||||||
canvas.addEventListener("webglcontextlost", handleContextLost);
|
|
||||||
return () =>
|
|
||||||
canvas.removeEventListener("webglcontextlost", handleContextLost);
|
|
||||||
}, [gl, props.onError]);
|
|
||||||
return textures ? <SystemAssembly {...props} textures={textures} /> : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
class SceneBoundary extends Component<
|
|
||||||
{ children: ReactNode; onError: () => void },
|
|
||||||
{ failed: boolean }
|
|
||||||
> {
|
|
||||||
state = { failed: false };
|
|
||||||
static getDerivedStateFromError() {
|
|
||||||
return { failed: true };
|
|
||||||
}
|
|
||||||
componentDidCatch() {
|
|
||||||
this.props.onError();
|
|
||||||
}
|
|
||||||
render() {
|
|
||||||
return this.state.failed ? null : this.props.children;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function HeroSystemScene(props: HeroSystemSceneProps) {
|
|
||||||
const [tabVisible, setTabVisible] = useState(false);
|
|
||||||
const [started, setStarted] = useState(false);
|
|
||||||
const [reducedMotion, setReducedMotion] = useState(false);
|
|
||||||
useEffect(() => {
|
|
||||||
const media = window.matchMedia("(prefers-reduced-motion: reduce)");
|
|
||||||
const updateVisibility = () => setTabVisible(!document.hidden);
|
|
||||||
const updateMotion = () => setReducedMotion(media.matches);
|
|
||||||
updateVisibility();
|
|
||||||
updateMotion();
|
|
||||||
document.addEventListener("visibilitychange", updateVisibility);
|
|
||||||
media.addEventListener("change", updateMotion);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("visibilitychange", updateVisibility);
|
|
||||||
media.removeEventListener("change", updateMotion);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
const visible = props.visible && tabVisible;
|
|
||||||
useEffect(() => {
|
|
||||||
if (visible) setStarted(true);
|
|
||||||
}, [visible]);
|
|
||||||
if (!started) return null;
|
|
||||||
return (
|
|
||||||
<SceneBoundary onError={props.onError}>
|
|
||||||
<Canvas
|
|
||||||
aria-hidden="true"
|
|
||||||
style={{ width: "100%", height: "100%" }}
|
|
||||||
camera={{ fov: 32, position: [0, 0, 10], near: 0.1, far: 40 }}
|
|
||||||
dpr={[1, 1.5]}
|
|
||||||
frameloop={visible ? "demand" : "never"}
|
|
||||||
gl={{ alpha: true, antialias: true, powerPreference: "low-power" }}
|
|
||||||
>
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<SceneContent
|
|
||||||
{...props}
|
|
||||||
visible={visible}
|
|
||||||
reducedMotion={reducedMotion}
|
|
||||||
/>
|
|
||||||
</Suspense>
|
|
||||||
</Canvas>
|
|
||||||
</SceneBoundary>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+157
-10
@@ -255,6 +255,7 @@ textarea {
|
|||||||
|
|
||||||
.ds-section {
|
.ds-section {
|
||||||
padding: clamp(72px, 9vw, 112px) clamp(24px, 6vw, 64px);
|
padding: clamp(72px, 9vw, 112px) clamp(24px, 6vw, 64px);
|
||||||
|
background: var(--paper);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -986,30 +987,176 @@ textarea {
|
|||||||
radial-gradient(40% 50% at 6% 92%, rgba(204, 255, 0, 0.05), transparent 70%);
|
radial-gradient(40% 50% at 6% 92%, rgba(204, 255, 0, 0.05), transparent 70%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-rise > * {
|
/* Hero: palco de 100svh; em telas grandes ele fica fixo enquanto a rolagem conduz o mergulho da cena 3D.
|
||||||
animation: hero-rise 0.8s var(--ease-out) both;
|
--hero-p (0 a 1) vem de cinematic/HeroField.tsx. */
|
||||||
|
.hero {
|
||||||
|
--hero-p: 0;
|
||||||
|
isolation: isolate;
|
||||||
|
}
|
||||||
|
|
||||||
&:nth-child(2) {
|
.hero-stage {
|
||||||
animation-delay: 0.08s;
|
position: relative;
|
||||||
}
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100svh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
&:nth-child(3) {
|
/* Sem WebGL (ou antes de a cena carregar): malha de pontos estática no lugar das camadas */
|
||||||
animation-delay: 0.16s;
|
.hero-glow::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: radial-gradient(circle, rgba(244, 242, 237, 0.22) 1px, transparent 1.5px) 0 0 / 22px 22px;
|
||||||
|
mask: radial-gradient(38% 46% at 74% 50%, #000, transparent 75%);
|
||||||
|
transition: opacity 1.2s var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero:has(.hero-field[data-ready]) .hero-glow::after {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-field {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 1.6s var(--ease-out);
|
||||||
|
|
||||||
|
&[data-ready] {
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes hero-rise {
|
.hero-scrim {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
background:
|
||||||
|
linear-gradient(90deg, rgba(10, 10, 10, 0.86) 0%, rgba(10, 10, 10, 0.55) 36%, transparent 62%),
|
||||||
|
linear-gradient(0deg, rgba(10, 10, 10, 0.9), transparent 28%);
|
||||||
|
|
||||||
|
@media (max-width: 1023px) {
|
||||||
|
background: linear-gradient(0deg, rgba(10, 10, 10, 0.92) 0 30%, rgba(10, 10, 10, 0.5) 65%, rgba(10, 10, 10, 0.7));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Entrada: as palavras do título já pintam no primeiro quadro (sem opacity:0, bom para o LCP) e ganham foco em cascata */
|
||||||
|
.hero-word {
|
||||||
|
display: inline-block;
|
||||||
|
animation: hero-word 1.1s var(--ease-out) both;
|
||||||
|
animation-delay: calc(0.12s + var(--i) * 45ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes hero-word {
|
||||||
from {
|
from {
|
||||||
transform: translateY(16px);
|
transform: translateY(0.42em) rotate(2deg);
|
||||||
|
filter: blur(12px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-in,
|
||||||
|
.hero-foot > * {
|
||||||
|
animation: hero-in 1s var(--ease-out) both;
|
||||||
|
animation-delay: var(--d, 0.05s);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-foot > * {
|
||||||
|
--d: 0.85s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes hero-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(18px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-cue-line {
|
||||||
|
width: 56px;
|
||||||
|
height: 1px;
|
||||||
|
background: currentColor;
|
||||||
|
transform-origin: left;
|
||||||
|
animation: hero-cue 2.4s var(--ease-out) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes hero-cue {
|
||||||
|
0% {
|
||||||
|
transform: scaleX(0);
|
||||||
|
}
|
||||||
|
60% {
|
||||||
|
transform: scaleX(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scaleX(1);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) and (min-height: 700px) and (prefers-reduced-motion: no-preference) {
|
||||||
|
.hero {
|
||||||
|
height: 220svh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-stage {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
height: 100svh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* o texto sai de cena enquanto a câmera mergulha: sobe, encolhe e perde o foco */
|
||||||
|
.hero-content {
|
||||||
|
transform: translate3d(0, calc(var(--hero-p) * -16vh), 0) scale(calc(1 - var(--hero-p) * 0.08));
|
||||||
|
transform-origin: 0 50%;
|
||||||
|
opacity: calc(1 - var(--hero-p) * 2.6);
|
||||||
|
filter: blur(calc(var(--hero-p) * 18px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-foot {
|
||||||
|
opacity: calc(1 - var(--hero-p) * 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-scrim {
|
||||||
|
opacity: calc(1 - var(--hero-p) * 1.4);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.hero-rise > * {
|
.hero-word,
|
||||||
|
.hero-in,
|
||||||
|
.hero-foot > *,
|
||||||
|
.hero-cue-line {
|
||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Transição entre seções: cada seção sobe como um cartão e abre até a borda da tela.
|
||||||
|
CSS puro com linha do tempo de rolagem (Chrome, Edge, Safari 26+); sem suporte, a seção só aparece. */
|
||||||
|
.ds-main {
|
||||||
|
background: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes section-rise {
|
||||||
|
from {
|
||||||
|
clip-path: inset(8vh 4vw 0 4vw round 28px);
|
||||||
|
translate: 0 12vh;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
clip-path: inset(0 0 0 0 round 0);
|
||||||
|
translate: 0 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@supports (animation-timeline: view()) {
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
.ds-main > section:not(.hero, .notebook-story) {
|
||||||
|
animation: section-rise linear both;
|
||||||
|
animation-timeline: view();
|
||||||
|
animation-range: entry 0% entry 85vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Capa dos cards de projeto: captura real ou painel gerado em codigo */
|
/* Capa dos cards de projeto: captura real ou painel gerado em codigo */
|
||||||
.ds-cover {
|
.ds-cover {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
Reference in new issue
Block a user