feat: rebuild landing experience, content and analytics
This commit is contained in:
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 />);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user