feat: rebuild landing experience, content and analytics

This commit is contained in:
Evandro Fonseca Junior committed 2026-09-13 04:30:58 -03:00
1 parent d24ee12299
commit 9aff4d1035
51 files changed
+3023 -2595

No files matched your search

@@ -28,6 +28,7 @@ beforeEach(() => {
});
afterEach(() => {
jest.restoreAllMocks();
window.history.replaceState(null, "", "/");
if (originalScrollIntoView) {
HTMLElement.prototype.scrollIntoView = originalScrollIntoView;
@@ -37,6 +38,37 @@ afterEach(() => {
});
describe("home fragment navigation", () => {
it("starts an unfragmented visit at the top after the notebook establishes layout", () => {
let restoredPosition = 3200;
jest
.spyOn(window, "scrollTo")
.mockImplementation((options: ScrollToOptions | number) => {
if (typeof options === "object")
restoredPosition = options.top ?? restoredPosition;
});
render(<HomeNavigation />);
fireEvent.click(
screen.getByRole("button", { name: "Establish notebook layout" }),
);
expect(restoredPosition).toBe(0);
});
it("does not reset the visitor's position after intentional scrolling during startup", () => {
let position = 0;
jest
.spyOn(window, "scrollTo")
.mockImplementation((options: ScrollToOptions | number) => {
if (typeof options === "object") position = options.top ?? position;
});
render(<HomeNavigation />);
position = 600;
fireEvent.wheel(window, { deltaY: 600 });
fireEvent.click(
screen.getByRole("button", { name: "Establish notebook layout" }),
);
expect(position).toBe(600);
});
it("waits for the notebook layout on fragment arrival, then never steals focus on refresh", () => {
window.history.replaceState(null, "", "/#contact");
render(<HomeNavigation />);
+15 -18
View File
@@ -7,6 +7,7 @@ import type {
} from "@/lib/portfolio-data";
import About from "../About";
import Contact from "../Contact";
import EngineeringWithAI from "../EngineeringWithAI";
import Experience from "../Experience";
import Footer from "../Footer";
import Hero from "../Hero";
@@ -35,40 +36,36 @@ export default function CinematicHome({
<main className="ds-main">
<Hero />
{/* Marquee divider after hero */}
<div className="overflow-hidden bg-[var(--ink)] py-6 text-[rgba(244,242,237,0.06)]">
<ScrollVelocityText text="SOFTWARE ENGINEERING" direction="left" />
<ScrollVelocityText text="SOFTWARE SOB MEDIDA" direction="left" />
</div>
<NotebookStory projects={projects} onLayoutReady={onLayoutReady} />
<About />
<Projects projects={projects} />
<SectionDivider variant="waveform" />
<Skills categories={skillCategories} />
<WorkModalities />
<div className="overflow-hidden bg-[var(--ink)] py-4 text-[rgba(204,255,0,0.04)]">
<ScrollVelocityText
text="NEXT.JS TAURI RUST NODE.JS TYPESCRIPT"
direction="right"
/>
</div>
<Experience experiences={experiences} />
<SectionDivider variant="gradient" />
<Projects projects={projects} />
<EngineeringWithAI />
<LighthouseMetrics />
<SectionDivider variant="pulse" />
<WorkModalities />
<About />
<details className="ds-section !pt-0">
<summary className="ds-section-shell cursor-pointer py-sp-5 text-lg font-semibold">
Experiência e tecnologias por trás das entregas
</summary>
<Skills categories={skillCategories} />
<Experience experiences={experiences} />
</details>
<div className="overflow-hidden bg-[var(--ink)] py-6 text-[rgba(244,242,237,0.05)]">
<ScrollVelocityText text="VAMOS CONVERSAR" direction="left" />
<ScrollVelocityText text="VAMOS CONSTRUIR" direction="left" />
</div>
<Contact />
@@ -1,146 +0,0 @@
import { act, render, waitFor } from "@testing-library/react";
import createGlobe from "cobe";
import { ControlRoomGlobe } from "./ControlRoomGlobe";
const mockDestroy = jest.fn();
jest.mock("cobe", () => ({
__esModule: true,
default: jest.fn(() => ({ destroy: mockDestroy })),
}));
jest.mock("motion/react", () => ({
useMotionValue: (initial: number) => ({
get: () => initial,
set: jest.fn(),
}),
useSpring: (value: unknown) => value,
}));
let intersectionCallback: IntersectionObserverCallback | undefined;
let resizeCallback: ResizeObserverCallback | undefined;
let measuredWidth = 400;
function setDocumentHidden(hidden: boolean) {
Object.defineProperty(document, "hidden", {
configurable: true,
value: hidden,
});
}
function setGlobeIntersects(isIntersecting: boolean) {
const entry = { isIntersecting } as IntersectionObserverEntry;
act(() => intersectionCallback?.([entry], {} as IntersectionObserver));
}
function renderCallback() {
expect(createGlobe).toHaveBeenCalledTimes(1);
const call = jest.mocked(createGlobe).mock.calls[0];
if (!call) throw new Error("Globe was not initialized");
return call[1].onRender;
}
describe("ControlRoomGlobe lifecycle", () => {
beforeEach(() => {
jest.clearAllMocks();
setDocumentHidden(false);
intersectionCallback = undefined;
resizeCallback = undefined;
measuredWidth = 400;
jest
.spyOn(HTMLElement.prototype, "offsetWidth", "get")
.mockImplementation(() => measuredWidth);
jest
.spyOn(HTMLCanvasElement.prototype, "getContext")
.mockReturnValue({} as unknown as GPUCanvasContext);
class MockIntersectionObserver {
readonly root = null;
readonly rootMargin = "";
readonly thresholds = [];
constructor(callback: IntersectionObserverCallback) {
intersectionCallback = callback;
}
disconnect = jest.fn();
observe = jest.fn();
takeRecords = () => [];
unobserve = jest.fn();
}
global.IntersectionObserver =
MockIntersectionObserver as unknown as typeof IntersectionObserver;
class MockResizeObserver {
constructor(callback: ResizeObserverCallback) {
resizeCallback = callback;
}
disconnect = jest.fn();
observe = jest.fn();
unobserve = jest.fn();
}
global.ResizeObserver =
MockResizeObserver as unknown as typeof ResizeObserver;
});
afterEach(() => {
jest.restoreAllMocks();
});
it("pauses for a hidden tab without losing the intersection flag", async () => {
render(<ControlRoomGlobe />);
await waitFor(() => expect(createGlobe).toHaveBeenCalledTimes(1));
const renderFrame = renderCallback();
const state = { phi: 0, theta: 0, width: 0, height: 0 };
setGlobeIntersects(true);
renderFrame(state);
expect(state.phi).not.toBe(0);
setDocumentHidden(true);
const pausedPhi = state.phi;
renderFrame(state);
expect(state.phi).toBe(pausedPhi);
setDocumentHidden(false);
renderFrame(state);
expect(state.phi).not.toBe(pausedPhi);
setGlobeIntersects(false);
const offscreenPhi = state.phi;
setDocumentHidden(true);
setDocumentHidden(false);
renderFrame(state);
expect(state.phi).toBe(offscreenPhi);
});
it("keeps one Cobe instance while ResizeObserver updates render dimensions", async () => {
render(<ControlRoomGlobe />);
await waitFor(() => expect(createGlobe).toHaveBeenCalledTimes(1));
measuredWidth = 520;
const entry = {
contentRect: { width: measuredWidth },
} as ResizeObserverEntry;
act(() => resizeCallback?.([entry], {} as ResizeObserver));
setGlobeIntersects(true);
const state = { phi: 0, theta: 0, width: 0, height: 0 };
renderCallback()(state);
expect(createGlobe).toHaveBeenCalledTimes(1);
expect(state.width).toBe(1040);
expect(state.height).toBe(1040);
});
it("keeps the reduced-motion globe static while tracking dimensions", async () => {
render(<ControlRoomGlobe autoRotate={false} />);
await waitFor(() => expect(createGlobe).toHaveBeenCalled());
setGlobeIntersects(true);
const state = { phi: 0, theta: 0, width: 0, height: 0 };
renderCallback()(state);
expect(state).toEqual({ phi: 0, theta: 0, width: 800, height: 800 });
});
});
@@ -1,210 +0,0 @@
"use client";
import createGlobe, { type COBEOptions } from "cobe";
import { useMotionValue, useSpring } from "motion/react";
import { useEffect, useRef } from "react";
import { cn } from "@/lib/utils";
const MOVEMENT_DAMPING = 1400;
const JOINVILLE_LAT = -26.3044;
const JOINVILLE_LNG = -48.8456;
// cobe espera radianos: theta = latitude da banda de frente (Trópico de Capricórnio), phi = longitude de frente (Joinville)
const TROPIC_OF_CAPRICORN_RAD = (-23.4364 * Math.PI) / 180;
const JOINVILLE_LNG_RAD = (JOINVILLE_LNG * Math.PI) / 180;
const CONTROL_ROOM_GLOBE_CONFIG: COBEOptions = {
width: 800,
height: 800,
onRender: () => {},
devicePixelRatio: 2,
phi: JOINVILLE_LNG_RAD,
theta: TROPIC_OF_CAPRICORN_RAD,
dark: 0,
diffuse: 1.2,
mapSamples: 16000,
mapBrightness: 6,
baseColor: [0.32, 0.32, 0.32],
markerColor: [204 / 255, 255 / 255, 0 / 255],
glowColor: [204 / 255, 255 / 255, 0 / 255],
markers: [
{ location: [JOINVILLE_LAT, JOINVILLE_LNG], size: 0.12 },
{ location: [-25.4284, -49.2733], size: 0.06 },
{ location: [-26.0167, -48.5], size: 0.05 },
{ location: [-25.9333, -48.7833], size: 0.04 },
],
} as const satisfies COBEOptions;
export function ControlRoomGlobe({
className,
config = CONTROL_ROOM_GLOBE_CONFIG,
autoRotate = true,
rotationSpeed = 0.003,
}: {
className?: string;
config?: COBEOptions;
autoRotate?: boolean;
rotationSpeed?: number;
}) {
const widthRef = useRef(0);
const phiRef = useRef(config.phi ?? JOINVILLE_LNG_RAD);
// Rotação livre: oscilação de theta (inclinação multi-eixo) + arraste vertical
const thetaPhaseRef = useRef(0);
const thetaDragRef = useRef(0);
const canvasRef = useRef<HTMLCanvasElement>(null);
const pointerInteracting = useRef<number | null>(null);
const pointerYOrigin = useRef<number | null>(null);
const intersectsRef = useRef(false);
const r = useMotionValue(0);
const rs = useSpring(r, {
mass: 1,
damping: 30,
stiffness: 100,
});
const updatePointerInteraction = (value: number | null) => {
pointerInteracting.current = value;
if (canvasRef.current) {
canvasRef.current.style.cursor = value !== null ? "grabbing" : "grab";
}
};
const updateMovement = (clientX: number, clientY?: number) => {
if (pointerInteracting.current !== null) {
const delta = clientX - pointerInteracting.current;
r.set(r.get() + delta / MOVEMENT_DAMPING);
// Eixo vertical: arrasto inclina o globo (limitado p/ não virar de cabeça pra baixo)
if (clientY !== undefined && pointerYOrigin.current !== null) {
const deltaY = clientY - pointerYOrigin.current;
thetaDragRef.current = Math.max(
-0.9,
Math.min(0.9, thetaDragRef.current + deltaY / MOVEMENT_DAMPING),
);
pointerYOrigin.current = clientY;
}
}
};
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return undefined;
const dpr = config.devicePixelRatio;
const updateWidth = (entry?: ResizeObserverEntry) => {
const nextWidth =
entry?.contentRect.width || canvas.offsetWidth || config.width / dpr;
if (nextWidth > 0) widthRef.current = nextWidth;
};
updateWidth();
const resizeObserver = new ResizeObserver(([entry]) => updateWidth(entry));
resizeObserver.observe(canvas);
const intersectionObserver = new IntersectionObserver(
([entry]) => {
intersectsRef.current = entry.isIntersecting;
},
{ rootMargin: "100px" },
);
intersectionObserver.observe(canvas);
// Falha suave: sem WebGL (ou canvas já vinculado a outro tipo de contexto) o phenomenon
// lança "reading 'enable' of null" e derrubaria a árvore React inteira — card ficaria preto.
let globe: { destroy(): void } | null = null;
try {
const supportsWebGL =
canvas.getContext("webgl2") ?? canvas.getContext("webgl");
if (supportsWebGL) {
const initialRenderWidth = widthRef.current * dpr;
globe = createGlobe(canvas, {
...config,
width: initialRenderWidth,
height: initialRenderWidth,
onRender: (state) => {
const renderWidth = widthRef.current * dpr;
state.width = renderWidth;
state.height = renderWidth;
if (!autoRotate || !intersectsRef.current || document.hidden)
return;
if (!pointerInteracting.current) {
phiRef.current += rotationSpeed;
thetaPhaseRef.current += rotationSpeed * 0.9;
}
state.phi = phiRef.current + rs.get();
// Rotação livre em todos os eixos: spin (phi) + guinada senoidal (theta) + inclinação por arraste vertical
state.theta =
(config.theta ?? TROPIC_OF_CAPRICORN_RAD) +
Math.sin(thetaPhaseRef.current) * 0.35 +
thetaDragRef.current;
},
});
}
} catch {
globe = null;
}
if (!globe) {
// Sem globo: fallback visível (gradiente do card permanece), página viva.
canvas.style.opacity = "1";
return () => {
resizeObserver.disconnect();
intersectionObserver.disconnect();
};
}
const revealTimer = window.setTimeout(() => {
if (canvasRef.current) {
canvasRef.current.style.opacity = "1";
}
}, 0);
return () => {
window.clearTimeout(revealTimer);
globe.destroy();
resizeObserver.disconnect();
intersectionObserver.disconnect();
};
}, [rs, config, autoRotate, rotationSpeed]);
return (
<div
className={cn(
"relative mx-auto aspect-[1/1] w-full max-w-[520px]",
className,
)}
aria-hidden="true"
>
<canvas
className={cn(
"size-full opacity-0 transition-opacity duration-700 [contain:layout_paint_size]",
)}
ref={canvasRef}
onPointerDown={(e) => {
pointerInteracting.current = e.clientX;
pointerYOrigin.current = e.clientY;
updatePointerInteraction(e.clientX);
}}
onPointerUp={() => {
updatePointerInteraction(null);
pointerYOrigin.current = null;
}}
onPointerOut={() => updatePointerInteraction(null)}
onPointerLeave={() => {
updatePointerInteraction(null);
pointerYOrigin.current = null;
}}
onMouseMove={(e) => updateMovement(e.clientX, e.clientY)}
onTouchMove={(e) =>
e.touches[0] &&
updateMovement(e.touches[0].clientX, e.touches[0].clientY)
}
/>
<span className="sr-only">
Globo interativo centrado em Joinville, Santa Catarina, com marcadores
indicando presença operacional na região Norte de SC e Curitiba.
</span>
</div>
);
}
-404
View File
@@ -1,404 +0,0 @@
"use client";
import { useEffect, useRef } from "react";
const MAX_DPR = 1.5;
const ASSEMBLE_MS = 2000;
const REPEL_RADIUS_PX = 80;
// Densidade: ~1 star por STAR_AREA_DIVISOR px² CSS, entre piso e teto absolutos
const STAR_AREA_DIVISOR = 700;
const MIN_STARS = 300;
const MAX_STARS = 850;
const OFFSCREEN_POINTER = -100000;
type Particle = {
sx: number; // origem da assemblei (espalhada) no primeiro load
sy: number;
tx: number; // âncora na grade com jitter
ty: number;
ox: number; // deslocamento acumulado do repel do ponteiro
oy: number;
delay: number;
fillStyle: string;
baseAlpha: number;
size: number;
phase: number;
speed: number;
amp: number;
ttl: number;
birth: number;
};
type Rgb = [number, number, number];
const FALLBACK_SLASH: Rgb = [204, 255, 0];
const FALLBACK_PAPER: Rgb = [244, 242, 237];
function hexToRgb(hex: string): Rgb | null {
const value = hex.trim().replace("#", "");
const full =
value.length === 3
? value
.split("")
.map((char) => char + char)
.join("")
: value;
if (!/^[0-9a-fA-F]{6}$/.test(full)) return null;
return [
Number.parseInt(full.slice(0, 2), 16),
Number.parseInt(full.slice(2, 4), 16),
Number.parseInt(full.slice(4, 6), 16),
];
}
function easeOutQuint(progress: number) {
return 1 - (1 - progress) ** 5;
}
export function HeroStarfield() {
const containerRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const container = containerRef.current;
const canvas = canvasRef.current;
if (!container || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const reducedMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)",
).matches;
let rafId = 0;
let running = false;
let cancelled = false;
let resampleTimer = 0;
let sampleRetryTimer = 0;
let sampleAttempts = 0;
let particles: Particle[] = [];
let hasAssembled = false;
let elapsed = 0;
let lastTime = 0;
let dpr = 1;
let intersecting = true;
const pointer = { x: OFFSCREEN_POINTER, y: OFFSCREEN_POINTER };
let rect = container.getBoundingClientRect();
const rootStyles = getComputedStyle(document.documentElement);
const slashRgb =
hexToRgb(rootStyles.getPropertyValue("--slash")) ?? FALLBACK_SLASH;
const paperRgb =
hexToRgb(rootStyles.getPropertyValue("--paper")) ?? FALLBACK_PAPER;
// Grade estratificada: uma star por célula com jitter interno → cobertura
// uniforme do card, sem os aglomerados/vazios do random puro
const grid = {
cols: 1,
rows: 1,
cellW: canvas.width || 1,
cellH: canvas.height || 1,
};
const jitteredCellPosition = (cell: number): [number, number] => {
const cx = cell % grid.cols;
const cy = Math.floor(cell / grid.cols);
return [
(cx + Math.random()) * grid.cellW,
(cy + Math.random()) * grid.cellH,
];
};
const makeParticle = (tx: number, ty: number): Particle => {
const useSlash = Math.random() < 0.45;
const rgb = useSlash ? slashRgb : paperRgb;
return {
tx,
ty,
sx: hasAssembled ? tx : (Math.random() * 1.3 - 0.15) * canvas.width,
sy: hasAssembled ? ty : (Math.random() * 1.3 - 0.15) * canvas.height,
ox: 0,
oy: 0,
delay: Math.random() * 0.35,
fillStyle: `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`,
baseAlpha: 0.5 + Math.random() * 0.45,
size: (Math.random() < 0.72 ? 2 : 3) * dpr,
phase: Math.random() * Math.PI * 2,
// deriva lenta: período ~2–5s com amplitude 2,5–5,5px CSS → "levemente"
speed: 0.0011 + Math.random() * 0.0016,
amp: (2.5 + Math.random() * 3) * dpr,
ttl: 4000 + Math.random() * 6000,
// birth negativo: estagia os ciclos de vida para o spawn/death já rodar disperso no load
birth: -Math.random() * 10000,
};
};
const sample = () => {
const width = container.clientWidth;
const height = container.clientHeight;
if (width < 60 || height < 60) return;
dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR);
canvas.width = Math.round(width * dpr);
canvas.height = Math.round(height * dpr);
rect = container.getBoundingClientRect();
const count = Math.round(
Math.min(
MAX_STARS,
Math.max(MIN_STARS, (width * height) / STAR_AREA_DIVISOR),
),
);
// células quadradas-ish para a proporção do card
grid.cols = Math.max(
1,
Math.round(Math.sqrt(count * (canvas.width / canvas.height))),
);
grid.rows = Math.max(1, Math.ceil(count / grid.cols));
grid.cellW = canvas.width / grid.cols;
grid.cellH = canvas.height / grid.rows;
// ordem embaralhada das células: cada star ocupa uma célula distinta no spawn
const cells = Array.from({ length: grid.cols * grid.rows }, (_, i) => i);
for (let i = cells.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
const tmp = cells[i];
cells[i] = cells[j];
cells[j] = tmp;
}
particles = cells.slice(0, count).map((cell) => {
const [tx, ty] = jitteredCellPosition(cell);
return makeParticle(tx, ty);
});
};
const render = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
const pointerX = (pointer.x - rect.left) * dpr;
const pointerY = (pointer.y - rect.top) * dpr;
const radius = REPEL_RADIUS_PX * dpr;
const radiusSq = radius * radius;
let allSettled = true;
for (const particle of particles) {
const progress = Math.min(
Math.max(
(elapsed - particle.delay * ASSEMBLE_MS) / (ASSEMBLE_MS * 0.75),
0,
),
1,
);
if (progress < 1) allSettled = false;
const eased = easeOutQuint(progress);
const baseX = particle.sx + (particle.tx - particle.sx) * eased;
const baseY = particle.sy + (particle.ty - particle.sy) * eased;
// wander Lissajous: harmônico principal + secundário em razão
// não-inteira → trajetória contínua sem repetição óbvia
const t = elapsed * particle.speed;
const driftX =
(Math.sin(t + particle.phase) +
0.5 * Math.sin(t * 0.43 + particle.phase * 1.7)) *
particle.amp *
progress;
const driftY =
(Math.cos(t * 0.9 + particle.phase) +
0.5 * Math.cos(t * 0.51 + particle.phase * 2.3)) *
particle.amp *
progress;
const dx = baseX + driftX - pointerX;
const dy = baseY + driftY - pointerY;
const distSq = dx * dx + dy * dy;
if (distSq < radiusSq) {
const dist = Math.sqrt(distSq) || 1;
const force = (1 - dist / radius) * 10 * dpr;
particle.ox += (dx / dist) * force;
particle.oy += (dy / dist) * force;
}
particle.ox *= 0.86;
particle.oy *= 0.86;
const fadeIn = Math.min(elapsed / 1200, 1);
const twinkle = 0.6 + 0.4 * Math.sin(elapsed * 0.003 + particle.phase);
let life = elapsed - particle.birth;
if (life > particle.ttl) {
// renasce em outra célula da grade → uniforme também entre ciclos
const [tx, ty] = jitteredCellPosition(
Math.floor(Math.random() * grid.cols * grid.rows),
);
particle.tx = tx;
particle.ty = ty;
particle.sx = tx;
particle.sy = ty;
particle.phase = Math.random() * Math.PI * 2;
particle.baseAlpha = 0.5 + Math.random() * 0.45;
particle.size = (Math.random() < 0.72 ? 2 : 3) * dpr;
particle.ttl = 4000 + Math.random() * 6000;
particle.birth = elapsed;
life = 0;
}
// fade-in de 800ms ao nascer, fade-out nos últimos 800ms de vida
const lifeFade = Math.min(life / 800, (particle.ttl - life) / 800, 1);
ctx.globalAlpha = Math.min(
1,
particle.baseAlpha * fadeIn * twinkle * lifeFade,
);
ctx.fillStyle = particle.fillStyle;
ctx.fillRect(
baseX + driftX + particle.ox,
baseY + driftY + particle.oy,
particle.size,
particle.size,
);
}
ctx.globalAlpha = 1;
if (allSettled && particles.length > 0) hasAssembled = true;
};
const drawStaticFrame = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (const particle of particles) {
ctx.fillStyle = particle.fillStyle;
ctx.globalAlpha = particle.baseAlpha;
ctx.fillRect(particle.tx, particle.ty, particle.size, particle.size);
}
ctx.globalAlpha = 1;
};
const tick = (now: number) => {
if (!running) return;
elapsed += Math.min(now - lastTime, 50);
lastTime = now;
render();
rafId = window.requestAnimationFrame(tick);
};
const syncLoop = () => {
const shouldRun = !reducedMotion && intersecting && !document.hidden;
if (shouldRun && !running) {
running = true;
lastTime = performance.now();
rafId = window.requestAnimationFrame(tick);
} else if (!shouldRun && running) {
running = false;
window.cancelAnimationFrame(rafId);
}
};
// Warm-up determinístico: corrida de layout pode deixar o container com
// 0px no primeiro sample; re-tenta até existirem partículas
const sampleUntilBuilt = (onBuilt?: () => void) => {
sample();
if (particles.length > 0 || cancelled) {
onBuilt?.();
return;
}
if (sampleAttempts++ > 50) return;
sampleRetryTimer = window.setTimeout(
() => sampleUntilBuilt(onBuilt),
120,
);
};
const scheduleResample = () => {
window.clearTimeout(resampleTimer);
resampleTimer = window.setTimeout(() => {
sampleUntilBuilt(() => {
if (reducedMotion) drawStaticFrame();
});
}, 150);
};
const handlePointerMove = (event: PointerEvent) => {
pointer.x = event.clientX;
pointer.y = event.clientY;
};
const handlePointerLeave = () => {
pointer.x = OFFSCREEN_POINTER;
pointer.y = OFFSCREEN_POINTER;
};
const handleScroll = () => {
rect = container.getBoundingClientRect();
};
const handleVisibilityChange = () => {
syncLoop();
};
const resizeObserver = new ResizeObserver(scheduleResample);
resizeObserver.observe(container);
if (reducedMotion) {
// Variante estática: quadro final montado, sem rAF e sem listeners de ponteiro
hasAssembled = true;
sampleUntilBuilt(() => drawStaticFrame());
return () => {
cancelled = true;
window.clearTimeout(resampleTimer);
window.clearTimeout(sampleRetryTimer);
resizeObserver.disconnect();
};
}
// Start determinístico: intersecting derivado de geometry no setup (o callback do IO
// pode chegar tarde e deixar o loop rAF morto na carga)
const syncIntersecting = () => {
const current = container.getBoundingClientRect();
intersecting =
current.bottom > 0 &&
current.right > 0 &&
current.top < window.innerHeight &&
current.left < window.innerWidth;
};
syncIntersecting();
sampleUntilBuilt(() => syncLoop());
const intersectionObserver = new IntersectionObserver((entries) => {
intersecting = entries.some((entry) => entry.isIntersecting);
syncLoop();
});
intersectionObserver.observe(container);
window.addEventListener("pointermove", handlePointerMove, {
passive: true,
});
document.documentElement.addEventListener(
"pointerleave",
handlePointerLeave,
);
window.addEventListener("scroll", handleScroll, { passive: true });
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
cancelled = true;
window.cancelAnimationFrame(rafId);
window.clearTimeout(resampleTimer);
window.clearTimeout(sampleRetryTimer);
resizeObserver.disconnect();
intersectionObserver.disconnect();
window.removeEventListener("pointermove", handlePointerMove);
document.documentElement.removeEventListener(
"pointerleave",
handlePointerLeave,
);
window.removeEventListener("scroll", handleScroll);
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, []);
return (
<div
ref={containerRef}
aria-hidden="true"
className="pointer-events-none absolute inset-0"
>
<canvas ref={canvasRef} className="h-full w-full" />
</div>
);
}
+325
View File
@@ -0,0 +1,325 @@
"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;
}
}
}
setCanRender(supported);
};
updateCapability();
preference.addEventListener("change", updateCapability);
return () => 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 rounded-sm 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>
);
}
@@ -0,0 +1,577 @@
"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>
);
}
-193
View File
@@ -1,193 +0,0 @@
"use client";
import { motion, useMotionValue, useSpring, useTransform } from "motion/react";
import { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
interface TelemetryMetric {
label: string;
value: number;
unit: string;
precision?: number;
min?: number;
max?: number;
}
const METRICS_CONFIG: TelemetryMetric[] = [
{
label: "UPTIME",
value: 99.97,
unit: "%",
precision: 2,
min: 99.9,
max: 100,
},
{
label: "INTEGRAÇÕES",
value: 12,
unit: " ativas",
precision: 0,
min: 8,
max: 16,
},
{
label: "BUILDS",
value: 247,
unit: " /dia",
precision: 0,
min: 200,
max: 300,
},
{ label: "LATÊNCIA", value: 12, unit: " ms", precision: 0, min: 8, max: 28 },
];
const UPDATE_INTERVAL = 3000;
const springConfigs = [
{ stiffness: 300, damping: 30, mass: 0.8 },
{ stiffness: 200, damping: 25, mass: 1 },
{ stiffness: 150, damping: 20, mass: 1.2 },
{ stiffness: 150, damping: 20, mass: 1.2 },
];
export function TelemetryStrip({
className,
reducedMotion = false,
}: {
className?: string;
reducedMotion?: boolean;
}) {
const [_metrics, setMetrics] = useState<TelemetryMetric[]>(METRICS_CONFIG);
const pointerX = useMotionValue(0);
const pointerY = useMotionValue(0);
const isHovering = useRef(false);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const metricValue0 = useSpring(
useMotionValue(METRICS_CONFIG[0].value),
springConfigs[0],
);
const metricValue1 = useSpring(
useMotionValue(METRICS_CONFIG[1].value),
springConfigs[1],
);
const metricValue2 = useSpring(
useMotionValue(METRICS_CONFIG[2].value),
springConfigs[2],
);
const metricValue3 = useSpring(
useMotionValue(METRICS_CONFIG[3].value),
springConfigs[3],
);
const metricValues = [metricValue0, metricValue1, metricValue2, metricValue3];
// Parallax sutil em px, sinal alternado por métrica — assinatura "reage ao ponteiro"
const pointerShiftX = useTransform(pointerX, [-1, 1], [-8, 8]);
const pointerShiftY = useTransform(pointerY, [-1, 1], [-5, 5]);
const pointerShiftXNeg = useTransform(pointerShiftX, (v) => -v);
const pointerShiftYNeg = useTransform(pointerShiftY, (v) => -v);
const generateVariation = useRef((metric: TelemetryMetric): number => {
const range =
(metric.max ?? metric.value * 1.1) - (metric.min ?? metric.value * 0.9);
const variation = (Math.random() - 0.5) * range * 0.15;
const newValue = metric.value + variation;
return Math.max(
metric.min ?? -Infinity,
Math.min(metric.max ?? Infinity, newValue),
);
});
const updateMetrics = useRef(() => {
setMetrics((prev) =>
prev.map((metric) => ({
...metric,
value: generateVariation.current(metric),
})),
);
});
useEffect(() => {
if (reducedMotion) return;
intervalRef.current = setInterval(updateMetrics.current, UPDATE_INTERVAL);
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, [reducedMotion]);
useEffect(() => {
const handlePointerMove = (e: MouseEvent) => {
const x = (e.clientX / window.innerWidth) * 2 - 1;
const y = -(e.clientY / window.innerHeight) * 2 + 1;
pointerX.set(x);
pointerY.set(y);
};
window.addEventListener("pointermove", handlePointerMove);
return () => window.removeEventListener("pointermove", handlePointerMove);
}, [pointerX, pointerY]);
return (
<section
className={cn(
"cinematic-glass border-[rgba(204,255,0,0.18)] bg-[rgba(10,10,10,0.82)] backdrop-blur-[24px] rounded-[var(--r-lg)] p-sp-4 lg:p-sp-5 grid grid-cols-2 gap-sp-4 lg:gap-sp-6",
className,
)}
aria-label="Telemetria operacional em tempo real"
onPointerEnter={() => {
isHovering.current = true;
}}
onPointerLeave={() => {
isHovering.current = false;
}}
>
{METRICS_CONFIG.map((metric, index) => (
<motion.div
key={metric.label}
className="relative flex flex-col items-start gap-1"
style={
reducedMotion
? undefined
: {
x: index % 2 === 0 ? pointerShiftX : pointerShiftXNeg,
y: index % 2 === 0 ? pointerShiftY : pointerShiftYNeg,
}
}
>
<div className="font-mono text-[11px] uppercase tracking-[0.14em] text-[rgba(244,242,237,.7)]">
{metric.label}
</div>
<div
className="whitespace-nowrap font-display font-extrabold text-[clamp(1.35rem,2.6vw,1.875rem)] leading-none text-[var(--paper)] tabular-nums"
aria-hidden="true"
>
{reducedMotion
? metric.value.toFixed(metric.precision ?? 0)
: metricValues[index].get().toFixed(metric.precision ?? 0)}
<span className="font-mono text-[11px] font-normal text-[rgba(244,242,237,.58)]">
{metric.unit}
</span>
</div>
<div
className="h-px w-full bg-[linear-gradient(90deg,var(--slash),transparent)] opacity-30"
style={{
transformOrigin: "left center",
transform: reducedMotion
? "scaleX(1)"
: `scaleX(${Math.max(0.15, Math.min(1, metric.value / (metric.max ?? 100)))})`,
}}
aria-hidden="true"
/>
</motion.div>
))}
<span className="sr-only">
Painel de telemetria operacional: uptime 99,97 por cento, doze
integrações ativas, duzentos e quarenta e sete builds por dia, latência
doze milissegundos. Valores decorativos simulando uma sala de controle
real.
</span>
</section>
);
}
@@ -28,20 +28,47 @@ export function useHomeFragmentNavigation() {
// A history restoration owns its saved position, not the URL's old fragment.
let pending =
navigation?.type === "back_forward" ? "" : window.location.hash;
let pendingTop =
navigation?.type !== "back_forward" && !window.location.hash;
if (navigation?.type !== "back_forward")
window.history.scrollRestoration = "manual";
const resolve = () => {
if (!ready.current || !pending) return;
if (!ready.current) return;
if (pendingTop) {
pendingTop = false;
if (!window.location.hash)
window.scrollTo({ top: 0, left: 0, behavior: "instant" });
}
if (!pending) return;
const hash = pending;
pending = "";
if (hash !== window.location.hash) return;
const target = fragmentTarget(hash);
if (!target) return;
focusFragment(target);
target.scrollIntoView({ block: "start", behavior: "instant" });
const padding =
Number.parseFloat(
getComputedStyle(document.documentElement).scrollPaddingTop,
) || 0;
const margin =
Number.parseFloat(getComputedStyle(target).scrollMarginTop) || 0;
window.scrollTo({
top: Math.max(
0,
window.scrollY +
target.getBoundingClientRect().top -
padding -
margin,
),
left: 0,
behavior: "instant",
});
};
const cancelArrival = () => {
pending = "";
pendingTop = false;
};
const onKeyDown = (event: KeyboardEvent) => {
if (
@@ -88,11 +115,13 @@ export function useHomeFragmentNavigation() {
return;
const target = fragmentTarget(url.hash);
if (!target) return;
pendingTop = false;
pending = ready.current ? "" : url.hash;
// Capture before a mobile menu removes its activated link. Do not cancel navigation.
focusFragment(target);
};
const onHashChange = () => {
pendingTop = false;
if (pending && pending !== window.location.hash) cancelArrival();
if (!ready.current) return;
const target = fragmentTarget(window.location.hash);
@@ -109,6 +138,7 @@ export function useHomeFragmentNavigation() {
window.addEventListener("pointerdown", cancelArrival, { passive: true });
return () => {
resolveArrival.current = null;
window.history.scrollRestoration = "auto";
document.removeEventListener("click", onClick, true);
window.removeEventListener("hashchange", onHashChange);
window.removeEventListener("wheel", cancelArrival);