feat(hero): camadas reconheciveis (tela, integracoes, tabela de dados), etiquetas e legendas do mergulho
A cena passa de discos abstratos para formas que um leigo reconhece. A camera enquadra cada camada enquanto a legenda em linguagem simples explica o que e, e so entao atravessa para a proxima. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
a3a206fb7a
commit
92c602c0ad
4 files changed
+383
-86
No files matched your search
@@ -37,6 +37,13 @@ function statItems(stats?: HeroStats) {
|
|||||||
const TITLE = "Sistemas sob medida para operações que não cabem em soluções prontas.".split(" ");
|
const TITLE = "Sistemas sob medida para operações que não cabem em soluções prontas.".split(" ");
|
||||||
const ACCENT = new Set([1, 2]);
|
const ACCENT = new Set([1, 2]);
|
||||||
|
|
||||||
|
/** O que cada camada da cena significa, em linguagem simples. */
|
||||||
|
const CAPTIONS = [
|
||||||
|
["A tela", "O que sua equipe e seus clientes usam no dia a dia."],
|
||||||
|
["As integrações", "Os programas que você já usa conversando entre si, sem retrabalho."],
|
||||||
|
["Os dados", "Suas informações guardadas, organizadas e seguras."],
|
||||||
|
] as const;
|
||||||
|
|
||||||
export default function Hero({ stats }: { stats?: HeroStats }) {
|
export default function Hero({ stats }: { stats?: HeroStats }) {
|
||||||
const items = statItems(stats);
|
const items = statItems(stats);
|
||||||
|
|
||||||
@@ -49,6 +56,15 @@ export default function Hero({ stats }: { stats?: HeroStats }) {
|
|||||||
<div className="hero-glow" aria-hidden="true" />
|
<div className="hero-glow" aria-hidden="true" />
|
||||||
<HeroField />
|
<HeroField />
|
||||||
<div className="hero-scrim" aria-hidden="true" />
|
<div className="hero-scrim" aria-hidden="true" />
|
||||||
|
{/* Legendas do mergulho: aparecem enquanto a câmera atravessa cada camada (só no hero fixo, telas grandes). */}
|
||||||
|
<ol className="hero-captions">
|
||||||
|
{CAPTIONS.map(([title, text], index) => (
|
||||||
|
<li key={title} style={{ "--i": index } as CSSProperties}>
|
||||||
|
<span className="hero-caption-title">{title}</span>
|
||||||
|
{text}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
|
||||||
<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">
|
<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]">
|
<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]">
|
||||||
|
|||||||
@@ -4,6 +4,13 @@ import dynamic from "next/dynamic";
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import type { HeroFieldSceneProps } from "./HeroFieldScene";
|
import type { HeroFieldSceneProps } from "./HeroFieldScene";
|
||||||
|
|
||||||
|
/** Etiquetas presas às camadas da cena (a cena calcula a posição de cada uma a cada quadro). */
|
||||||
|
const TAGS = [
|
||||||
|
["A tela", "o que sua equipe e seus clientes usam"],
|
||||||
|
["As integrações", "seus sistemas conversando entre si"],
|
||||||
|
["Os dados", "suas informações organizadas"],
|
||||||
|
] as const;
|
||||||
|
|
||||||
const Scene = dynamic<HeroFieldSceneProps>(() => import("./HeroFieldScene"), {
|
const Scene = dynamic<HeroFieldSceneProps>(() => import("./HeroFieldScene"), {
|
||||||
ssr: false,
|
ssr: false,
|
||||||
});
|
});
|
||||||
@@ -26,6 +33,7 @@ function supportsWebGL2() {
|
|||||||
export default function HeroField() {
|
export default function HeroField() {
|
||||||
const rootRef = useRef<HTMLDivElement>(null);
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
const progressRef = useRef(0);
|
const progressRef = useRef(0);
|
||||||
|
const labelsRef = useRef<(HTMLElement | null)[]>([]);
|
||||||
const [mount, setMount] = useState(false);
|
const [mount, setMount] = useState(false);
|
||||||
const [visible, setVisible] = useState(false);
|
const [visible, setVisible] = useState(false);
|
||||||
const [ready, setReady] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
@@ -95,11 +103,26 @@ export default function HeroField() {
|
|||||||
{mount && !failed && (
|
{mount && !failed && (
|
||||||
<Scene
|
<Scene
|
||||||
progressRef={progressRef}
|
progressRef={progressRef}
|
||||||
|
labelsRef={labelsRef}
|
||||||
visible={visible && !done}
|
visible={visible && !done}
|
||||||
onReady={handleReady}
|
onReady={handleReady}
|
||||||
onError={handleError}
|
onError={handleError}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{mount &&
|
||||||
|
!failed &&
|
||||||
|
TAGS.map(([title, text], index) => (
|
||||||
|
<span
|
||||||
|
key={title}
|
||||||
|
ref={(element) => {
|
||||||
|
labelsRef.current[index] = element;
|
||||||
|
}}
|
||||||
|
className="hero-tag"
|
||||||
|
>
|
||||||
|
<span className="hero-tag-title">{title}</span>
|
||||||
|
{text}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -13,54 +13,158 @@ import * as THREE from "three";
|
|||||||
|
|
||||||
export type HeroFieldSceneProps = {
|
export type HeroFieldSceneProps = {
|
||||||
progressRef: RefObject<number>;
|
progressRef: RefObject<number>;
|
||||||
|
/** etiquetas em HTML presas às camadas (tela, integrações, dados), posicionadas a cada quadro */
|
||||||
|
labelsRef: RefObject<(HTMLElement | null)[]>;
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
onReady: () => void;
|
onReady: () => void;
|
||||||
onError: () => void;
|
onError: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Três camadas de pontos (interface, integrações, dados) ligadas por colunas de partículas que sobem
|
* Um sistema desenhado com pontos, em três camadas que um leigo reconhece:
|
||||||
* como dados. Na entrada, os pontos saem de uma nuvem e se organizam; ao rolar, as camadas se afastam,
|
* em cima, a TELA de um aplicativo (janela, título, botão, cards); no meio, as INTEGRAÇÕES (sistemas
|
||||||
* ficam de frente e a câmera atravessa as três até o escuro da próxima seção.
|
* ligados a uma central, com mensagens correndo pelas linhas); embaixo, os DADOS (uma tabela).
|
||||||
* Tudo acontece no vertex shader: o JavaScript só atualiza meia dúzia de uniforms por quadro.
|
* Colunas de partículas sobem da tabela até a tela: a informação chegando a quem usa.
|
||||||
|
* 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 uma a uma (as legendas de HeroField explicam cada camada nessa hora).
|
||||||
|
* Tudo se move no vertex shader; o JavaScript só atualiza alguns uniforms por quadro.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const RADIUS = 2.35;
|
type Layer = 1 | 0 | -1; // 1 = tela, 0 = integrações, -1 = dados
|
||||||
|
type PointStyle = { tone?: number; size?: number; alpha?: number };
|
||||||
|
|
||||||
|
class FieldBuilder {
|
||||||
|
base: number[] = [];
|
||||||
|
to: number[] = [];
|
||||||
|
move: number[] = [];
|
||||||
|
tone: number[] = [];
|
||||||
|
size: number[] = [];
|
||||||
|
alpha: number[] = [];
|
||||||
|
constructor(private density: number) {}
|
||||||
|
|
||||||
|
point(x: number, y: number, z: number, style: PointStyle = {}, to?: [number, number, number]) {
|
||||||
|
this.base.push(x, y, z);
|
||||||
|
this.to.push(...(to ?? [x, y, z]));
|
||||||
|
this.move.push(to ? 1 : 0);
|
||||||
|
this.tone.push(style.tone ?? 0);
|
||||||
|
this.size.push(style.size ?? 0.02);
|
||||||
|
this.alpha.push(style.alpha ?? 0.7);
|
||||||
|
}
|
||||||
|
line(y: Layer, x0: number, z0: number, x1: number, z1: number, step: number, style?: PointStyle) {
|
||||||
|
const s = step * this.density;
|
||||||
|
const n = Math.max(1, Math.round(Math.hypot(x1 - x0, z1 - z0) / s));
|
||||||
|
for (let i = 0; i <= n; i++) this.point(x0 + ((x1 - x0) * i) / n, y, z0 + ((z1 - z0) * i) / n, style);
|
||||||
|
}
|
||||||
|
rect(y: Layer, x0: number, z0: number, x1: number, z1: number, step: number, style?: PointStyle) {
|
||||||
|
this.line(y, x0, z0, x1, z0, step, style);
|
||||||
|
this.line(y, x1, z0, x1, z1, step, style);
|
||||||
|
this.line(y, x1, z1, x0, z1, step, style);
|
||||||
|
this.line(y, x0, z1, x0, z0, step, style);
|
||||||
|
}
|
||||||
|
fill(y: Layer, x0: number, z0: number, x1: number, z1: number, step: number, style?: PointStyle) {
|
||||||
|
const s = step * this.density;
|
||||||
|
for (let x = x0; x <= x1 + 1e-6; x += s)
|
||||||
|
for (let z = z0; z <= z1 + 1e-6; z += s)
|
||||||
|
this.point(x + (Math.random() - 0.5) * s * 0.3, y, z + (Math.random() - 0.5) * s * 0.3, style);
|
||||||
|
}
|
||||||
|
ring(y: Layer, cx: number, cz: number, r: number, step: number, style?: PointStyle) {
|
||||||
|
const n = Math.max(8, Math.round((Math.PI * 2 * r) / (step * this.density)));
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const a = (i / n) * Math.PI * 2;
|
||||||
|
this.point(cx + Math.cos(a) * r, y, cz + Math.sin(a) * r, style);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
disc(y: Layer, cx: number, cz: number, r: number, step: number, style?: PointStyle) {
|
||||||
|
for (let rr = r; rr > 0.005; rr -= step * this.density) this.ring(y, cx, cz, rr, step, style);
|
||||||
|
this.point(cx, y, cz, style);
|
||||||
|
}
|
||||||
|
/** partículas que correm de a até b (a posição é calculada no shader) */
|
||||||
|
flow(a: [number, number, number], b: [number, number, number], count: number, style?: PointStyle) {
|
||||||
|
const n = Math.max(2, Math.round(count / this.density));
|
||||||
|
for (let i = 0; i < n; i++) this.point(...a, style, b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// z negativo = topo da tela quando a camada fica de frente para a câmera
|
||||||
|
function drawScreen(f: FieldBuilder) {
|
||||||
|
const y: Layer = 1;
|
||||||
|
f.fill(y, -2.1, -1.4, 2.1, 1.4, 0.1, { alpha: 0.1 }); // superfície da tela
|
||||||
|
f.rect(y, -2.1, -1.4, 2.1, 1.4, 0.026, { alpha: 0.95 });
|
||||||
|
f.line(y, -2.1, -1.05, 2.1, -1.05, 0.03, { alpha: 0.6 }); // barra do navegador
|
||||||
|
for (const x of [-1.9, -1.72, -1.54]) f.disc(y, x, -1.23, 0.05, 0.025, { alpha: 0.8 });
|
||||||
|
f.rect(y, -1.1, -1.32, 1.3, -1.14, 0.035, { alpha: 0.45 }); // endereço
|
||||||
|
f.fill(y, -1.8, -0.82, 0.25, -0.58, 0.03, { alpha: 0.85 }); // título
|
||||||
|
f.line(y, -1.8, -0.38, 0.75, -0.38, 0.04, { alpha: 0.5 }); // texto
|
||||||
|
f.line(y, -1.8, -0.24, 0.35, -0.24, 0.04, { alpha: 0.5 });
|
||||||
|
f.fill(y, -1.8, -0.02, -0.75, 0.2, 0.028, { tone: 1, alpha: 0.95, size: 0.022 }); // botão
|
||||||
|
f.rect(y, 0.6, -0.82, 1.8, 0.2, 0.035, { alpha: 0.6 }); // imagem
|
||||||
|
f.fill(y, 0.6, -0.82, 1.8, 0.2, 0.08, { alpha: 0.25 });
|
||||||
|
for (const [x0, x1] of [
|
||||||
|
[-1.8, -0.7],
|
||||||
|
[-0.55, 0.55],
|
||||||
|
[0.7, 1.8],
|
||||||
|
]) {
|
||||||
|
f.rect(y, x0, 0.45, x1, 1.2, 0.035, { alpha: 0.55 }); // cards
|
||||||
|
f.line(y, x0 + 0.12, 0.62, x1 - 0.3, 0.62, 0.045, { alpha: 0.5 });
|
||||||
|
f.line(y, x0 + 0.12, 0.76, x1 - 0.5, 0.76, 0.045, { alpha: 0.35 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** posições dos sistemas ligados à central; as colunas de dados sobem por eles */
|
||||||
|
const NODES: [number, number][] = Array.from({ length: 6 }, (_, k) => {
|
||||||
|
const a = (k / 6) * Math.PI * 2 + Math.PI / 6;
|
||||||
|
return [Math.cos(a) * 1.7, Math.sin(a) * 1.05];
|
||||||
|
});
|
||||||
|
|
||||||
|
function drawIntegrations(f: FieldBuilder) {
|
||||||
|
const y: Layer = 0;
|
||||||
|
f.ring(y, 0, 0, 0.34, 0.02, { tone: 1, alpha: 0.95, size: 0.024 }); // central
|
||||||
|
f.disc(y, 0, 0, 0.2, 0.035, { tone: 1, alpha: 0.5 });
|
||||||
|
NODES.forEach(([x, z], k) => {
|
||||||
|
f.ring(y, x, z, 0.2, 0.02, { tone: 1, alpha: 0.9, size: 0.022 });
|
||||||
|
f.disc(y, x, z, 0.1, 0.03, { alpha: 0.6 });
|
||||||
|
const len = Math.hypot(x, z);
|
||||||
|
const ux = x / len;
|
||||||
|
const uz = z / len;
|
||||||
|
// linha da central até o sistema, e mensagens correndo nos dois sentidos
|
||||||
|
f.line(y, ux * 0.38, uz * 0.38, x - ux * 0.24, z - uz * 0.24, 0.06, { alpha: 0.35 });
|
||||||
|
f.flow([ux * 0.38, y, uz * 0.38], [x - ux * 0.24, y, z - uz * 0.24], 10, { tone: 1, alpha: 0.95, size: 0.026 });
|
||||||
|
f.flow([x - ux * 0.24, y, z - uz * 0.24], [ux * 0.38, y, uz * 0.38], 6, { alpha: 0.9, size: 0.024 });
|
||||||
|
const [nx, nz] = NODES[(k + 1) % NODES.length];
|
||||||
|
f.line(y, x, z, nx, nz, 0.08, { alpha: 0.15 });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawData(f: FieldBuilder) {
|
||||||
|
const y: Layer = -1;
|
||||||
|
const x0 = -2;
|
||||||
|
const x1 = 2;
|
||||||
|
const z0 = -1.3;
|
||||||
|
const z1 = 1.3;
|
||||||
|
const cols = [x0, -0.9, 0.2, 1.1, x1];
|
||||||
|
f.rect(y, x0, z0, x1, z1, 0.03, { alpha: 0.8 });
|
||||||
|
f.fill(y, x0, z0, x1, z0 + 0.32, 0.045, { tone: 0.6, alpha: 0.45 }); // cabeçalho da tabela
|
||||||
|
for (let z = z0 + 0.32; z < z1; z += 0.33) f.line(y, x0, z, x1, z, 0.04, { alpha: 0.4 });
|
||||||
|
for (const x of cols.slice(1, -1)) f.line(y, x, z0, x, z1, 0.04, { alpha: 0.4 });
|
||||||
|
for (let z = z0 + 0.48; z < z1; z += 0.33) {
|
||||||
|
for (let c = 0; c < cols.length - 1; c++) {
|
||||||
|
const start = cols[c] + 0.12;
|
||||||
|
const width = (cols[c + 1] - cols[c] - 0.24) * (0.35 + Math.random() * 0.6);
|
||||||
|
f.line(y, start, z, start + width, z, 0.045, { alpha: 0.55 }); // valores nas células
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function buildField(dense: boolean) {
|
function buildField(dense: boolean) {
|
||||||
const grid = dense ? 76 : 46;
|
const f = new FieldBuilder(dense ? 1 : 1.5);
|
||||||
const columns = dense ? 18 : 10;
|
drawScreen(f);
|
||||||
const perColumn = dense ? 64 : 40;
|
drawIntegrations(f);
|
||||||
const base: number[] = [];
|
drawData(f);
|
||||||
const kind: number[] = [];
|
// colunas: a informação sobe dos dados, passa pelos sistemas e chega à tela
|
||||||
const step = (RADIUS * 2) / (grid - 1);
|
for (const [x, z] of [[0, 0] as [number, number], ...NODES])
|
||||||
for (const layer of [-1, 0, 1]) {
|
f.flow([x, -1, z], [x, 1, z], 40, { tone: 1, alpha: 0.6, size: 0.022 });
|
||||||
for (let i = 0; i < grid; i++) {
|
|
||||||
for (let j = 0; j < grid; j++) {
|
const count = f.move.length;
|
||||||
// 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 scatter = new Float32Array(count * 3);
|
||||||
const rand = new Float32Array(count * 4);
|
const rand = new Float32Array(count * 4);
|
||||||
for (let n = 0; n < count; n++) {
|
for (let n = 0; n < count; n++) {
|
||||||
@@ -72,10 +176,14 @@ function buildField(dense: boolean) {
|
|||||||
rand.set([Math.random(), Math.random(), Math.random(), Math.random()], n * 4);
|
rand.set([Math.random(), Math.random(), Math.random(), Math.random()], n * 4);
|
||||||
}
|
}
|
||||||
const geometry = new THREE.BufferGeometry();
|
const geometry = new THREE.BufferGeometry();
|
||||||
geometry.setAttribute("position", new THREE.Float32BufferAttribute(base, 3));
|
geometry.setAttribute("position", new THREE.Float32BufferAttribute(f.base, 3));
|
||||||
|
geometry.setAttribute("aTo", new THREE.Float32BufferAttribute(f.to, 3));
|
||||||
|
geometry.setAttribute("aMove", new THREE.Float32BufferAttribute(f.move, 1));
|
||||||
|
geometry.setAttribute("aTone", new THREE.Float32BufferAttribute(f.tone, 1));
|
||||||
|
geometry.setAttribute("aSize", new THREE.Float32BufferAttribute(f.size, 1));
|
||||||
|
geometry.setAttribute("aAlpha", new THREE.Float32BufferAttribute(f.alpha, 1));
|
||||||
geometry.setAttribute("aScatter", new THREE.BufferAttribute(scatter, 3));
|
geometry.setAttribute("aScatter", new THREE.BufferAttribute(scatter, 3));
|
||||||
geometry.setAttribute("aRand", new THREE.BufferAttribute(rand, 4));
|
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);
|
geometry.boundingSphere = new THREE.Sphere(new THREE.Vector3(), 12);
|
||||||
return geometry;
|
return geometry;
|
||||||
}
|
}
|
||||||
@@ -90,9 +198,13 @@ const vertexShader = /* glsl */ `
|
|||||||
uniform float uFade;
|
uniform float uFade;
|
||||||
uniform vec2 uMouse;
|
uniform vec2 uMouse;
|
||||||
uniform float uMouseAmt;
|
uniform float uMouseAmt;
|
||||||
|
attribute vec3 aTo;
|
||||||
|
attribute float aMove;
|
||||||
|
attribute float aTone;
|
||||||
|
attribute float aSize;
|
||||||
|
attribute float aAlpha;
|
||||||
attribute vec3 aScatter;
|
attribute vec3 aScatter;
|
||||||
attribute vec4 aRand;
|
attribute vec4 aRand;
|
||||||
attribute float aKind;
|
|
||||||
varying vec3 vColor;
|
varying vec3 vColor;
|
||||||
varying float vAlpha;
|
varying float vAlpha;
|
||||||
varying float vSoft;
|
varying float vSoft;
|
||||||
@@ -101,24 +213,19 @@ const vertexShader = /* glsl */ `
|
|||||||
const vec3 LIME = vec3(0.8, 1.0, 0.0);
|
const vec3 LIME = vec3(0.8, 1.0, 0.0);
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
float layer = position.y;
|
// partículas em movimento correm de position até aTo e recomeçam
|
||||||
float isPlane = 1.0 - step(0.5, aKind);
|
float f = fract(uTime * (0.1 + aRand.x * 0.08) + aRand.y);
|
||||||
float isStream = step(0.5, aKind) * (1.0 - step(1.5, aKind));
|
vec3 b = mix(position, aTo, aMove * f);
|
||||||
float isNode = step(1.5, aKind);
|
vec3 p = vec3(b.x, b.y * uSpread, b.z);
|
||||||
|
float still = 1.0 - aMove;
|
||||||
|
|
||||||
// colunas: cada partícula sobe da camada de dados até a interface e recomeça
|
float wave = sin(b.x * 1.7 + uTime * 0.55 + b.y * 2.1) * cos(b.z * 1.3 - uTime * 0.4);
|
||||||
float flow = fract(uTime * (0.08 + aRand.x * 0.07) + aRand.y);
|
p.y += wave * (0.02 + uScroll * 0.2) * still;
|
||||||
layer = mix(layer, flow * 2.0 - 1.0, isStream);
|
|
||||||
|
|
||||||
vec3 p = vec3(position.x, layer * uSpread, position.z);
|
// cursor: uma onda que levanta a camada e acende os pontos em verde
|
||||||
float wave = sin(position.x * 1.7 + uTime * 0.55 + layer * 2.1) * cos(position.z * 1.3 - uTime * 0.4);
|
float d = length(b.xz - uMouse);
|
||||||
p.y += wave * (0.045 + uScroll * 0.25) * isPlane;
|
float lift = exp(-d * d * 2.2) * uMouseAmt * still;
|
||||||
p.xz += (aRand.zw - 0.5) * 0.05 * isStream;
|
p.y += lift * 0.3 + sin(d * 7.0 - uTime * 3.5) * lift * 0.05;
|
||||||
|
|
||||||
// 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
|
// 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 t = clamp((uIntro - aRand.z * 0.4) / 0.6, 0.0, 1.0);
|
||||||
@@ -129,24 +236,18 @@ const vertexShader = /* glsl */ `
|
|||||||
gl_Position = projectionMatrix * mv;
|
gl_Position = projectionMatrix * mv;
|
||||||
float depth = -mv.z;
|
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.3, 0.0, 1.6);
|
||||||
float blur = clamp(abs(depth - uFocus) * 0.32, 0.0, 1.6);
|
float size = aSize * (0.8 + aRand.w * 0.4);
|
||||||
gl_PointSize = min(size * uScale / max(depth, 0.05) * (1.0 + blur * 1.4), 64.0);
|
gl_PointSize = min(size * uScale / max(depth, 0.05) * (1.0 + blur * 1.3), 64.0);
|
||||||
|
|
||||||
// cada camada é um disco: miolo que esmaece para a borda e um aro fino que desenha o contorno
|
float alpha = aAlpha * mix(1.0, sin(f * 3.14159), aMove);
|
||||||
float radial = length(position.xz);
|
alpha *= (0.25 + 0.75 * e) / (1.0 + blur * 1.5);
|
||||||
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;
|
alpha *= smoothstep(0.25, 1.6, depth) * uFade;
|
||||||
vAlpha = alpha;
|
vAlpha = alpha;
|
||||||
vSoft = clamp(0.35 + blur * 0.45, 0.35, 1.0);
|
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));
|
vColor = mix(PAPER, LIME, clamp(aTone + lift * 1.6, 0.0, 1.0));
|
||||||
}
|
}
|
||||||
`
|
`;
|
||||||
.replaceAll("RADIUS_IN", (RADIUS * 0.62).toFixed(2))
|
|
||||||
.replaceAll("RADIUS_OUT", RADIUS.toFixed(2));
|
|
||||||
|
|
||||||
const fragmentShader = /* glsl */ `
|
const fragmentShader = /* glsl */ `
|
||||||
varying vec3 vColor;
|
varying vec3 vColor;
|
||||||
@@ -165,7 +266,48 @@ const smooth = (a: number, b: number, x: number) => {
|
|||||||
return t * t * (3 - 2 * t);
|
return t * t * (3 - 2 * t);
|
||||||
};
|
};
|
||||||
|
|
||||||
function Field({ progressRef, onReady, onError }: HeroFieldSceneProps) {
|
/** Interpola entre quadros-chave [progresso, valor] com suavização em cada trecho. */
|
||||||
|
function keyframes(frames: readonly (readonly [number, number])[], p: number) {
|
||||||
|
if (p <= frames[0][0]) return frames[0][1];
|
||||||
|
for (let i = 1; i < frames.length; i++) {
|
||||||
|
const [p1, v1] = frames[i];
|
||||||
|
if (p <= p1) {
|
||||||
|
const [p0, v0] = frames[i - 1];
|
||||||
|
return v0 + (v1 - v0) * smooth(p0, p1, p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return frames[frames.length - 1][1];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Distância entre as camadas no fim do mergulho: cada uma vira um "cômodo" que a câmera visita. */
|
||||||
|
const SPREAD = 5.5;
|
||||||
|
/*
|
||||||
|
* Coreografia da câmera (z): afasta para enquadrar a tela inteira, atravessa, enquadra as integrações,
|
||||||
|
* atravessa, enquadra os dados, atravessa. As legendas de Hero.tsx (--from/--to em globals.css) seguem
|
||||||
|
* estes mesmos intervalos: tela 0,26–0,46; integrações 0,52–0,71; dados 0,76–0,92.
|
||||||
|
*/
|
||||||
|
const CAMERA_Z = [
|
||||||
|
[0, 9],
|
||||||
|
[0.28, SPREAD + 5.2],
|
||||||
|
[0.44, SPREAD + 4.5],
|
||||||
|
[0.5, SPREAD - 0.3],
|
||||||
|
[0.55, 5.2],
|
||||||
|
[0.69, 4.5],
|
||||||
|
[0.74, -0.3],
|
||||||
|
[0.78, -SPREAD + 5.2],
|
||||||
|
[0.89, -SPREAD + 4.5],
|
||||||
|
[0.95, -SPREAD - 0.3],
|
||||||
|
[1, -SPREAD - 2],
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// ponto de cada camada onde a etiqueta se prende (canto direito da frente), na ordem tela, integrações, dados
|
||||||
|
const LABEL_ANCHORS: [number, Layer, number][] = [
|
||||||
|
[2.1, 1, 1.4],
|
||||||
|
[1.95, 0, 0.9],
|
||||||
|
[2, -1, 1.3],
|
||||||
|
];
|
||||||
|
|
||||||
|
function Field({ progressRef, labelsRef, onReady, onError }: HeroFieldSceneProps) {
|
||||||
const { gl, camera, size } = useThree();
|
const { gl, camera, size } = useThree();
|
||||||
const group = useRef<THREE.Group>(null);
|
const group = useRef<THREE.Group>(null);
|
||||||
const dense = useMemo(
|
const dense = useMemo(
|
||||||
@@ -208,6 +350,9 @@ function Field({ progressRef, onReady, onError }: HeroFieldSceneProps) {
|
|||||||
ray: new THREE.Raycaster(),
|
ray: new THREE.Raycaster(),
|
||||||
plane: new THREE.Plane(),
|
plane: new THREE.Plane(),
|
||||||
hit: new THREE.Vector3(),
|
hit: new THREE.Vector3(),
|
||||||
|
anchor: new THREE.Vector3(),
|
||||||
|
zero: new THREE.Vector2(),
|
||||||
|
target: new THREE.Vector2(),
|
||||||
}),
|
}),
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
@@ -252,27 +397,31 @@ function Field({ progressRef, onReady, onError }: HeroFieldSceneProps) {
|
|||||||
u.uTime.value = time;
|
u.uTime.value = time;
|
||||||
u.uIntro.value = Math.min(1, u.uIntro.value + dt / 2.6);
|
u.uIntro.value = Math.min(1, u.uIntro.value + dt / 2.6);
|
||||||
u.uScroll.value = p;
|
u.uScroll.value = p;
|
||||||
u.uSpread.value = 1.05 + 1.3 * smooth(0, 0.55, p);
|
// as camadas só se afastam enquanto ficam de frente: inclinadas e afastadas sairiam do quadro
|
||||||
u.uFade.value = 1 - smooth(0.94, 1, p);
|
const face = smooth(0.02, 0.28, p);
|
||||||
|
u.uSpread.value = 1.05 + (SPREAD - 1.05) * face * face;
|
||||||
|
u.uFade.value = 1 - smooth(0.93, 1, p);
|
||||||
u.uScale.value =
|
u.uScale.value =
|
||||||
(gl.getPixelRatio() * size.height * 0.5) /
|
(gl.getPixelRatio() * size.height * 0.5) /
|
||||||
Math.tan(THREE.MathUtils.degToRad((camera as THREE.PerspectiveCamera).fov / 2));
|
Math.tan(THREE.MathUtils.degToRad((camera as THREE.PerspectiveCamera).fov / 2));
|
||||||
|
|
||||||
// sem mouse (toque, ou mouse parado), um cursor fantasma percorre a malha
|
// sem mouse (toque, ou mouse parado), um cursor fantasma percorre a tela
|
||||||
const mouseActive = time - s.lastMove < 2.5 && s.lastMove > 0 ? 1 : 0;
|
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));
|
s.tilt.lerp(mouseActive ? s.pointer : helpers.zero, 1 - Math.exp(-dt * 3));
|
||||||
|
|
||||||
const face = smooth(0.04, 0.6, p);
|
// em repouso, vista de três quartos que balança devagar; ao mergulhar, as camadas ficam de frente e retas
|
||||||
g.rotation.set(
|
g.rotation.set(
|
||||||
0.42 + (Math.PI / 2 - 0.42) * face - s.tilt.y * 0.08,
|
0.42 + (Math.PI / 2 - 0.42) * face - s.tilt.y * 0.08,
|
||||||
time * 0.045 + s.tilt.x * 0.18,
|
(-0.45 + Math.sin(time * 0.2) * 0.08) * (1 - face) + s.tilt.x * 0.15 * (1 - face),
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
const settle = 1 - smooth(0, 0.4, p);
|
const settle = 1 - smooth(0, 0.28, p);
|
||||||
g.position.set(wide ? 1.85 * settle : 0, wide ? -0.15 * settle : -1.25 * settle, 0);
|
g.position.set(wide ? 1.7 * settle : 0, wide ? -0.1 * settle : -1.25 * settle, 0);
|
||||||
const dive = smooth(0.22, 1, p);
|
camera.position.z = keyframes(CAMERA_Z, p);
|
||||||
camera.position.z = 9 - 12.5 * dive * dive;
|
// foco na próxima camada à frente da câmera (as de trás desfocam como em uma lente)
|
||||||
u.uFocus.value = Math.max(1.5, camera.position.z - g.position.z);
|
const spread = u.uSpread.value;
|
||||||
|
const ahead = [spread, 0, -spread].find((z) => z < camera.position.z - 0.2) ?? -spread;
|
||||||
|
u.uFocus.value = Math.max(1.5, camera.position.z - ahead);
|
||||||
g.updateMatrixWorld();
|
g.updateMatrixWorld();
|
||||||
|
|
||||||
if (mouseActive) {
|
if (mouseActive) {
|
||||||
@@ -282,18 +431,31 @@ function Field({ progressRef, onReady, onError }: HeroFieldSceneProps) {
|
|||||||
.applyMatrix4(g.matrixWorld);
|
.applyMatrix4(g.matrixWorld);
|
||||||
if (helpers.ray.ray.intersectPlane(helpers.plane, helpers.hit)) {
|
if (helpers.ray.ray.intersectPlane(helpers.plane, helpers.hit)) {
|
||||||
g.worldToLocal(helpers.hit);
|
g.worldToLocal(helpers.hit);
|
||||||
s.mouse.lerp(new THREE.Vector2(helpers.hit.x, helpers.hit.z), 1 - Math.exp(-dt * 10));
|
s.mouse.lerp(helpers.target.set(helpers.hit.x, helpers.hit.z), 1 - Math.exp(-dt * 10));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
s.mouse.lerp(
|
s.mouse.lerp(
|
||||||
new THREE.Vector2(Math.sin(time * 0.31) * 1.5, Math.sin(time * 0.23 + 1.3) * 1.3),
|
helpers.target.set(Math.sin(time * 0.31) * 1.5, Math.sin(time * 0.23 + 1.3) * 1.1),
|
||||||
1 - Math.exp(-dt * 1.5),
|
1 - Math.exp(-dt * 1.5),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
u.uMouse.value.copy(s.mouse);
|
u.uMouse.value.copy(s.mouse);
|
||||||
const targetAmt = (mouseActive ? 1 : 0.55) * (1 - smooth(0.05, 0.3, p));
|
const targetAmt = (mouseActive ? 0.8 : 0.4) * (1 - smooth(0.05, 0.3, p));
|
||||||
u.uMouseAmt.value += (targetAmt - u.uMouseAmt.value) * (1 - Math.exp(-dt * 4));
|
u.uMouseAmt.value += (targetAmt - u.uMouseAmt.value) * (1 - Math.exp(-dt * 4));
|
||||||
|
|
||||||
|
// etiquetas: projeta o canto de cada camada na tela e as esconde quando o mergulho começa
|
||||||
|
const labels = labelsRef.current ?? [];
|
||||||
|
const shown = smooth(0.75, 1, u.uIntro.value) * (1 - smooth(0.04, 0.16, p));
|
||||||
|
LABEL_ANCHORS.forEach(([x, layer, z], index) => {
|
||||||
|
const label = labels[index];
|
||||||
|
if (!label) return;
|
||||||
|
helpers.anchor.set(x, layer * u.uSpread.value, z).applyMatrix4(g.matrixWorld).project(camera);
|
||||||
|
const px = ((helpers.anchor.x + 1) / 2) * size.width;
|
||||||
|
const py = ((1 - helpers.anchor.y) / 2) * size.height;
|
||||||
|
label.style.transform = `translate3d(${px.toFixed(1)}px, ${py.toFixed(1)}px, 0)`;
|
||||||
|
label.style.opacity = shown.toFixed(3);
|
||||||
|
});
|
||||||
|
|
||||||
if (++s.frames === 2) onReady();
|
if (++s.frames === 2) onReady();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+97
-1
@@ -1093,11 +1093,107 @@ textarea {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Etiquetas presas às camadas da cena: a posição vem da cena a cada quadro (transform), o recuo para a
|
||||||
|
esquerda do ponto de ancoragem fica no translate */
|
||||||
|
.hero-tag {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
display: none;
|
||||||
|
max-width: 230px;
|
||||||
|
translate: calc(-100% - 14px) -50%;
|
||||||
|
border: 1px solid rgba(244, 242, 237, 0.14);
|
||||||
|
background: rgba(10, 10, 10, 0.62);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
padding: 7px 11px;
|
||||||
|
color: rgba(244, 242, 237, 0.78);
|
||||||
|
font: 13px / 1.35 var(--font-body);
|
||||||
|
text-align: right;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
right: -14px;
|
||||||
|
width: 14px;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--slash);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-tag-title,
|
||||||
|
.hero-caption-title {
|
||||||
|
display: block;
|
||||||
|
color: var(--slash);
|
||||||
|
font: 600 11px / 1.4 var(--font-mono);
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-captions {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
@media (min-width: 1024px) and (min-height: 700px) and (prefers-reduced-motion: no-preference) {
|
@media (min-width: 1024px) and (min-height: 700px) and (prefers-reduced-motion: no-preference) {
|
||||||
.hero {
|
.hero {
|
||||||
height: 220svh;
|
height: 220svh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Legendas do mergulho: cada uma aparece enquanto a câmera atravessa a sua camada (--hero-p) */
|
||||||
|
.hero-captions {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: end center;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 var(--sp-6) 11svh;
|
||||||
|
list-style: none;
|
||||||
|
pointer-events: none;
|
||||||
|
/* faixa escura atrás da legenda, como em legenda de cinema, sem cobrir o centro da cena */
|
||||||
|
background: linear-gradient(0deg, rgba(10, 10, 10, 0.75), transparent 38%);
|
||||||
|
opacity: clamp(0, min((var(--hero-p) - 0.24) * 14, (0.94 - var(--hero-p)) * 14), 1);
|
||||||
|
|
||||||
|
& li {
|
||||||
|
--mid: calc((var(--from) + var(--to)) / 2);
|
||||||
|
grid-area: 1 / 1;
|
||||||
|
max-width: 26ch;
|
||||||
|
color: #f4f2ed;
|
||||||
|
font: 600 clamp(28px, 2.8vw, 48px) / 1.1 var(--font-display);
|
||||||
|
letter-spacing: -0.035em;
|
||||||
|
text-align: center;
|
||||||
|
text-shadow: 0 2px 40px rgba(10, 10, 10, 0.9);
|
||||||
|
opacity: clamp(0, min((var(--hero-p) - var(--from)) * 14, (var(--to) - var(--hero-p)) * 14), 1);
|
||||||
|
translate: 0 calc((var(--hero-p) - var(--mid)) * -160px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* mesmos intervalos em que a câmera enquadra cada camada (CAMERA_Z em HeroFieldScene.tsx) */
|
||||||
|
& li:nth-child(1) {
|
||||||
|
--from: 0.26;
|
||||||
|
--to: 0.46;
|
||||||
|
}
|
||||||
|
|
||||||
|
& li:nth-child(2) {
|
||||||
|
--from: 0.52;
|
||||||
|
--to: 0.71;
|
||||||
|
}
|
||||||
|
|
||||||
|
& li:nth-child(3) {
|
||||||
|
--from: 0.76;
|
||||||
|
--to: 0.92;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .hero-caption-title {
|
||||||
|
margin-bottom: var(--sp-3);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.hero-stage {
|
.hero-stage {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -1108,7 +1204,7 @@ textarea {
|
|||||||
.hero-content {
|
.hero-content {
|
||||||
transform: translate3d(0, calc(var(--hero-p) * -16vh), 0) scale(calc(1 - var(--hero-p) * 0.08));
|
transform: translate3d(0, calc(var(--hero-p) * -16vh), 0) scale(calc(1 - var(--hero-p) * 0.08));
|
||||||
transform-origin: 0 50%;
|
transform-origin: 0 50%;
|
||||||
opacity: calc(1 - var(--hero-p) * 2.6);
|
opacity: calc(1 - var(--hero-p) * 4.2);
|
||||||
filter: blur(calc(var(--hero-p) * 18px));
|
filter: blur(calc(var(--hero-p) * 18px));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user