feat: control room hero, SEO/AEO optimization and design system fixes
Hero: replace particle field with cobe globe (Capricorn view, multi-axis drag, reduced-motion safe), telemetry HUD with pointer parallax, particle text build effect, Space Grotesk display font. SEO: short titles with template, PT-BR accents, twitter cards on dynamic cases, escaped JSON-LD via lib/jsonld.ts, CSP report-only, 25 service FAQs answer-first. Design: fix TW4 layered reset killing spacing utilities, literal @theme tokens, mobile hero overflow, header overlap, empty case cards. Remove framer-motion (unified on motion/react), header version badge, phone number from hero CTA. Production build verified (24 static pages).
This commit is contained in:
1 parent
0920370f30
commit
b791b7de9e
94 files changed
+2621
-2889
No files matched your search
@@ -1,21 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { usePathname } from "next/navigation";
|
||||
import PageIntro from "./PageIntro";
|
||||
|
||||
const CustomCursor = dynamic(() => import("./CustomCursor"), { ssr: false });
|
||||
const FilmGrain = dynamic(() => import("./FilmGrain"), { ssr: false });
|
||||
const ScrollProgress = dynamic(() => import("./ScrollProgress"), { ssr: false });
|
||||
const HomeButton = dynamic(() => import("./HomeButton"), { ssr: false });
|
||||
const ScrollProgress = dynamic(() => import("./ScrollProgress"), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
export default function CinematicOverlays() {
|
||||
const pathname = usePathname();
|
||||
const isHome = pathname === "/";
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageIntro />
|
||||
<CustomCursor />
|
||||
<FilmGrain />
|
||||
<ScrollProgress />
|
||||
<HomeButton />
|
||||
{isHome ? (
|
||||
<>
|
||||
<CustomCursor />
|
||||
<FilmGrain />
|
||||
<ScrollProgress />
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useEffect, type ReactNode } from "react";
|
||||
import { type ReactNode, useEffect, useRef } from "react";
|
||||
|
||||
interface CinematicSectionProps {
|
||||
children: ReactNode;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useEffect } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
interface CinematicTextProps {
|
||||
text: string;
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
"use client";
|
||||
|
||||
import createGlobe, { type COBEOptions } from "cobe";
|
||||
import { useMotionValue, useSpring } from "motion/react";
|
||||
import { useEffect, useRef, useState } 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 [width, setWidth] = useState(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 isVisibleRef = useRef(true);
|
||||
|
||||
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 onResize = () => {
|
||||
if (canvasRef.current) {
|
||||
setWidth(canvasRef.current.offsetWidth);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("resize", onResize);
|
||||
onResize();
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
isVisibleRef.current = entry.isIntersecting;
|
||||
},
|
||||
{ rootMargin: "100px" },
|
||||
);
|
||||
|
||||
if (canvasRef.current) {
|
||||
observer.observe(canvasRef.current);
|
||||
}
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
isVisibleRef.current = !document.hidden;
|
||||
};
|
||||
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) {
|
||||
return () => {
|
||||
window.removeEventListener("resize", onResize);
|
||||
document.removeEventListener(
|
||||
"visibilitychange",
|
||||
handleVisibilityChange,
|
||||
);
|
||||
observer.disconnect();
|
||||
};
|
||||
}
|
||||
const globe = createGlobe(canvas, {
|
||||
...config,
|
||||
width: width * 2,
|
||||
height: width * 2,
|
||||
onRender: (state) => {
|
||||
if (!isVisibleRef.current || document.hidden) return;
|
||||
if (autoRotate && !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;
|
||||
state.width = width * 2;
|
||||
state.height = width * 2;
|
||||
},
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
if (canvasRef.current) {
|
||||
canvasRef.current.style.opacity = "1";
|
||||
}
|
||||
}, 0);
|
||||
|
||||
return () => {
|
||||
globe.destroy();
|
||||
window.removeEventListener("resize", onResize);
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [rs, config, autoRotate, rotationSpeed, width]);
|
||||
|
||||
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,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import { motion, useMotionValue } from "framer-motion";
|
||||
import { motion, useMotionValue } from "motion/react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
export default function CustomCursor() {
|
||||
const [isHovering, setIsHovering] = useState(false);
|
||||
|
||||
@@ -9,6 +9,12 @@ export default function FilmGrain() {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const reducedMotion = window.matchMedia(
|
||||
"(prefers-reduced-motion: reduce)",
|
||||
).matches;
|
||||
const coarsePointer = window.matchMedia("(pointer: coarse)").matches;
|
||||
if (reducedMotion || coarsePointer) return;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
@@ -57,9 +63,8 @@ export default function FilmGrain() {
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="pointer-events-none fixed inset-0 z-[9990] h-full w-full opacity-[0.35] mix-blend-overlay"
|
||||
className="film-grain pointer-events-none fixed inset-0 z-[9990] h-full w-full opacity-[0.35] mix-blend-overlay"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useMemo } from "react";
|
||||
import {
|
||||
Float,
|
||||
MeshDistortMaterial,
|
||||
MeshTransmissionMaterial,
|
||||
} from "@react-three/drei";
|
||||
import { Canvas, useFrame } from "@react-three/fiber";
|
||||
import { Float, MeshDistortMaterial, MeshTransmissionMaterial } from "@react-three/drei";
|
||||
import { useMemo, useRef } from "react";
|
||||
import * as THREE from "three";
|
||||
|
||||
function GlassSphere({ position, scale = 1 }: { position: [number, number, number]; scale?: number }) {
|
||||
function GlassSphere({
|
||||
position,
|
||||
scale = 1,
|
||||
}: {
|
||||
position: [number, number, number];
|
||||
scale?: number;
|
||||
}) {
|
||||
const meshRef = useRef<THREE.Mesh>(null);
|
||||
|
||||
useFrame((state) => {
|
||||
@@ -32,7 +42,13 @@ function GlassSphere({ position, scale = 1 }: { position: [number, number, numbe
|
||||
);
|
||||
}
|
||||
|
||||
function WireframeRing({ position, scale = 1 }: { position: [number, number, number]; scale?: number }) {
|
||||
function WireframeRing({
|
||||
position,
|
||||
scale = 1,
|
||||
}: {
|
||||
position: [number, number, number];
|
||||
scale?: number;
|
||||
}) {
|
||||
const meshRef = useRef<THREE.Mesh>(null);
|
||||
|
||||
useFrame((state) => {
|
||||
@@ -80,7 +96,9 @@ function DataStream({ count = 50 }: { count?: number }) {
|
||||
const t = state.clock.elapsedTime;
|
||||
pointsRef.current.rotation.y = t * 0.05;
|
||||
|
||||
const posAttr = pointsRef.current.geometry.getAttribute("position") as THREE.BufferAttribute;
|
||||
const posAttr = pointsRef.current.geometry.getAttribute(
|
||||
"position",
|
||||
) as THREE.BufferAttribute;
|
||||
const arr = posAttr.array as Float32Array;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
@@ -127,7 +145,11 @@ export default function FloatingElements({
|
||||
<Canvas
|
||||
camera={{ position: [0, 0, 6], fov: 50 }}
|
||||
dpr={[1, 1.5]}
|
||||
gl={{ antialias: false, alpha: true, powerPreference: "high-performance" }}
|
||||
gl={{
|
||||
antialias: false,
|
||||
alpha: true,
|
||||
powerPreference: "high-performance",
|
||||
}}
|
||||
style={{ background: "transparent" }}
|
||||
>
|
||||
<ambientLight intensity={0.2} />
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Float, MeshDistortMaterial } from "@react-three/drei";
|
||||
import { Canvas, useFrame, useThree } from "@react-three/fiber";
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import * as THREE from "three";
|
||||
|
||||
type HeroSceneVariant =
|
||||
@@ -13,236 +12,6 @@ type HeroSceneVariant =
|
||||
| "diagnostic"
|
||||
| "portrait";
|
||||
|
||||
function ParticleField({ count = 2000 }: { count?: number }) {
|
||||
const mesh = useRef<THREE.Points>(null);
|
||||
const mouseRef = useRef({ x: 0, y: 0 });
|
||||
|
||||
const [positions, velocities] = useMemo(() => {
|
||||
const pos = new Float32Array(count * 3);
|
||||
const vel = new Float32Array(count * 3);
|
||||
for (let i = 0; i < count; i++) {
|
||||
const i3 = i * 3;
|
||||
pos[i3] = (Math.random() - 0.5) * 20;
|
||||
pos[i3 + 1] = (Math.random() - 0.5) * 20;
|
||||
pos[i3 + 2] = (Math.random() - 0.5) * 15;
|
||||
vel[i3] = (Math.random() - 0.5) * 0.002;
|
||||
vel[i3 + 1] = (Math.random() - 0.5) * 0.002;
|
||||
vel[i3 + 2] = (Math.random() - 0.5) * 0.001;
|
||||
}
|
||||
return [pos, vel];
|
||||
}, [count]);
|
||||
|
||||
const sizes = useMemo(() => {
|
||||
const s = new Float32Array(count);
|
||||
for (let i = 0; i < count; i++) {
|
||||
s[i] = Math.random() * 2 + 0.5;
|
||||
}
|
||||
return s;
|
||||
}, [count]);
|
||||
|
||||
const handlePointerMove = useCallback(
|
||||
(e: { clientX: number; clientY: number }) => {
|
||||
mouseRef.current.x = (e.clientX / window.innerWidth) * 2 - 1;
|
||||
mouseRef.current.y = -(e.clientY / window.innerHeight) * 2 + 1;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useFrame((state) => {
|
||||
if (!mesh.current) return;
|
||||
const geo = mesh.current.geometry;
|
||||
const posAttr = geo.getAttribute("position") as THREE.BufferAttribute;
|
||||
const arr = posAttr.array as Float32Array;
|
||||
const time = state.clock.elapsedTime;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const i3 = i * 3;
|
||||
arr[i3] += velocities[i3] + Math.sin(time * 0.3 + i) * 0.001;
|
||||
arr[i3 + 1] += velocities[i3 + 1] + Math.cos(time * 0.2 + i) * 0.001;
|
||||
arr[i3 + 2] += velocities[i3 + 2];
|
||||
|
||||
// Mouse influence
|
||||
const dx = mouseRef.current.x * 5 - arr[i3];
|
||||
const dy = mouseRef.current.y * 5 - arr[i3 + 1];
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
if (dist < 3) {
|
||||
const force = (3 - dist) * 0.0003;
|
||||
arr[i3] += dx * force;
|
||||
arr[i3 + 1] += dy * force;
|
||||
}
|
||||
|
||||
// Wrap around
|
||||
if (arr[i3] > 10) arr[i3] = -10;
|
||||
if (arr[i3] < -10) arr[i3] = 10;
|
||||
if (arr[i3 + 1] > 10) arr[i3 + 1] = -10;
|
||||
if (arr[i3 + 1] < -10) arr[i3 + 1] = 10;
|
||||
if (arr[i3 + 2] > 7.5) arr[i3 + 2] = -7.5;
|
||||
if (arr[i3 + 2] < -7.5) arr[i3 + 2] = 7.5;
|
||||
}
|
||||
posAttr.needsUpdate = true;
|
||||
|
||||
// Slow rotation
|
||||
mesh.current.rotation.y = time * 0.02;
|
||||
mesh.current.rotation.x = Math.sin(time * 0.01) * 0.1;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("pointermove", handlePointerMove);
|
||||
return () => window.removeEventListener("pointermove", handlePointerMove);
|
||||
}, [handlePointerMove]);
|
||||
|
||||
return (
|
||||
<points ref={mesh}>
|
||||
<bufferGeometry>
|
||||
<bufferAttribute
|
||||
attach="attributes-position"
|
||||
args={[positions, 3]}
|
||||
count={count}
|
||||
/>
|
||||
<bufferAttribute
|
||||
attach="attributes-size"
|
||||
args={[sizes, 1]}
|
||||
count={count}
|
||||
/>
|
||||
</bufferGeometry>
|
||||
<pointsMaterial
|
||||
color="#ccff00"
|
||||
size={0.03}
|
||||
transparent
|
||||
opacity={0.6}
|
||||
sizeAttenuation
|
||||
blending={THREE.AdditiveBlending}
|
||||
depthWrite={false}
|
||||
/>
|
||||
</points>
|
||||
);
|
||||
}
|
||||
|
||||
function FloatingGeometry({ variant }: { variant: HeroSceneVariant }) {
|
||||
const meshRef = useRef<THREE.Mesh>(null);
|
||||
const mesh2Ref = useRef<THREE.Mesh>(null);
|
||||
const mesh3Ref = useRef<THREE.Mesh>(null);
|
||||
|
||||
const layout = {
|
||||
command: {
|
||||
a: [4, 1.5, -3] as [number, number, number],
|
||||
b: [-5, -2, -4] as [number, number, number],
|
||||
c: [2, -3, -5] as [number, number, number],
|
||||
scale: [1.2, 0.8, 1.5] as [number, number, number],
|
||||
},
|
||||
launch: {
|
||||
a: [3.8, 2.1, -3] as [number, number, number],
|
||||
b: [0, -1.4, -4] as [number, number, number],
|
||||
c: [-3.8, -2.6, -5] as [number, number, number],
|
||||
scale: [0.9, 0.9, 1.8] as [number, number, number],
|
||||
},
|
||||
rescue: {
|
||||
a: [-3.6, 1.2, -3] as [number, number, number],
|
||||
b: [3.6, 1.2, -4] as [number, number, number],
|
||||
c: [0, -2.8, -5] as [number, number, number],
|
||||
scale: [1, 1, 1.4] as [number, number, number],
|
||||
},
|
||||
signal: {
|
||||
a: [-4.2, -1, -3] as [number, number, number],
|
||||
b: [0.2, 1.6, -4] as [number, number, number],
|
||||
c: [4.2, -0.6, -5] as [number, number, number],
|
||||
scale: [0.7, 0.7, 1.2] as [number, number, number],
|
||||
},
|
||||
diagnostic: {
|
||||
a: [4.6, 0.6, -3] as [number, number, number],
|
||||
b: [-3.5, 1.8, -4] as [number, number, number],
|
||||
c: [-1.2, -2.8, -5] as [number, number, number],
|
||||
scale: [1.4, 0.7, 1.1] as [number, number, number],
|
||||
},
|
||||
portrait: {
|
||||
a: [3.2, 0.4, -3] as [number, number, number],
|
||||
b: [4.8, 2.1, -4] as [number, number, number],
|
||||
c: [-3.6, -2.4, -5] as [number, number, number],
|
||||
scale: [1.8, 0.5, 1.1] as [number, number, number],
|
||||
},
|
||||
}[variant];
|
||||
|
||||
useFrame((state) => {
|
||||
const t = state.clock.elapsedTime;
|
||||
if (meshRef.current) {
|
||||
meshRef.current.rotation.x = t * 0.15;
|
||||
meshRef.current.rotation.y = t * 0.1;
|
||||
meshRef.current.position.y = layout.a[1] + Math.sin(t * 0.4) * 0.3;
|
||||
}
|
||||
if (mesh2Ref.current) {
|
||||
mesh2Ref.current.rotation.x = t * 0.1;
|
||||
mesh2Ref.current.rotation.z = t * 0.12;
|
||||
mesh2Ref.current.position.y = layout.b[1] + Math.cos(t * 0.3) * 0.5;
|
||||
}
|
||||
if (mesh3Ref.current) {
|
||||
mesh3Ref.current.rotation.y = t * 0.08;
|
||||
mesh3Ref.current.rotation.z = t * 0.06;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Float speed={1.5} rotationIntensity={0.4} floatIntensity={0.6}>
|
||||
<mesh ref={meshRef} position={layout.a} scale={layout.scale[0]}>
|
||||
{variant === "diagnostic" ? (
|
||||
<boxGeometry args={[1.4, 1.8, 0.08, 3, 3, 1]} />
|
||||
) : variant === "portrait" ? (
|
||||
<capsuleGeometry args={[0.55, 1.25, 6, 12]} />
|
||||
) : (
|
||||
<icosahedronGeometry args={[1, 1]} />
|
||||
)}
|
||||
<MeshDistortMaterial
|
||||
color="#ccff00"
|
||||
wireframe
|
||||
transparent
|
||||
opacity={variant === "portrait" ? 0.1 : 0.15}
|
||||
distort={0.3}
|
||||
speed={2}
|
||||
/>
|
||||
</mesh>
|
||||
</Float>
|
||||
|
||||
<Float speed={1.2} rotationIntensity={0.3} floatIntensity={0.8}>
|
||||
<mesh ref={mesh2Ref} position={layout.b} scale={layout.scale[1]}>
|
||||
{variant === "launch" ? (
|
||||
<coneGeometry args={[0.8, 1.6, 4]} />
|
||||
) : variant === "signal" ? (
|
||||
<sphereGeometry args={[0.55, 16, 16]} />
|
||||
) : (
|
||||
<octahedronGeometry args={[1, 0]} />
|
||||
)}
|
||||
<MeshDistortMaterial
|
||||
color="#ffffff"
|
||||
wireframe
|
||||
transparent
|
||||
opacity={0.08}
|
||||
distort={0.2}
|
||||
speed={1.5}
|
||||
/>
|
||||
</mesh>
|
||||
</Float>
|
||||
|
||||
<Float speed={0.8} rotationIntensity={0.2} floatIntensity={0.5}>
|
||||
<mesh ref={mesh3Ref} position={layout.c} scale={layout.scale[2]}>
|
||||
{variant === "rescue" ? (
|
||||
<boxGeometry args={[1.4, 1.4, 1.4, 2, 2, 2]} />
|
||||
) : variant === "signal" ? (
|
||||
<torusKnotGeometry args={[0.7, 0.16, 80, 8]} />
|
||||
) : (
|
||||
<torusGeometry args={[1, 0.3, 16, 32]} />
|
||||
)}
|
||||
<meshStandardMaterial
|
||||
color="#ccff00"
|
||||
wireframe
|
||||
transparent
|
||||
opacity={0.06}
|
||||
/>
|
||||
</mesh>
|
||||
</Float>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function GridFloor() {
|
||||
const meshRef = useRef<THREE.Mesh>(null);
|
||||
|
||||
@@ -334,11 +103,8 @@ function CameraRig() {
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function HeroScene({
|
||||
variant = "command",
|
||||
}: {
|
||||
variant?: HeroSceneVariant;
|
||||
}) {
|
||||
export default function HeroScene({ variant }: { variant?: HeroSceneVariant }) {
|
||||
void variant;
|
||||
return (
|
||||
<div
|
||||
className="absolute inset-0 z-0"
|
||||
@@ -362,18 +128,16 @@ export default function HeroScene({
|
||||
display: "block",
|
||||
}}
|
||||
>
|
||||
<fog attach="fog" args={["#0a0a0a", 5, 25]} />
|
||||
<ambientLight intensity={0.3} />
|
||||
<fog attach="fog" args={["#0a0a0a", 8, 30]} />
|
||||
<ambientLight intensity={0.25} />
|
||||
<directionalLight
|
||||
position={[5, 5, 5]}
|
||||
intensity={0.5}
|
||||
intensity={0.4}
|
||||
color="#ccff00"
|
||||
/>
|
||||
<pointLight position={[-5, 3, -5]} intensity={0.3} color="#ffffff" />
|
||||
<pointLight position={[-5, 3, -5]} intensity={0.2} color="#ffffff" />
|
||||
|
||||
<CameraRig />
|
||||
<ParticleField count={1500} />
|
||||
<FloatingGeometry variant={variant} />
|
||||
<GridFloor />
|
||||
</Canvas>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import MagneticButton from "./MagneticButton";
|
||||
|
||||
export default function HomeButton() {
|
||||
@@ -29,7 +29,8 @@ export default function HomeButton() {
|
||||
<span
|
||||
className="pointer-events-none absolute inset-0 rounded-full opacity-0 transition-opacity duration-300 group-hover:opacity-100"
|
||||
style={{
|
||||
boxShadow: "inset 0 0 20px rgba(204,255,0,0.06), 0 0 24px rgba(204,255,0,0.15)",
|
||||
boxShadow:
|
||||
"inset 0 0 20px rgba(204,255,0,0.06), 0 0 24px rgba(204,255,0,0.15)",
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -63,4 +64,3 @@ export default function HomeButton() {
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useEffect, type ReactNode } from "react";
|
||||
import { type ReactNode, useEffect, useRef } from "react";
|
||||
|
||||
interface HorizontalScrollProps {
|
||||
children: ReactNode;
|
||||
@@ -70,13 +70,17 @@ export default function HorizontalScroll({
|
||||
end: "left 30%",
|
||||
scrub: true,
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
cleanup = () => {
|
||||
ScrollTrigger.getAll().forEach((st) => {
|
||||
if (st.trigger === container || (st.vars as { containerAnimation?: unknown }).containerAnimation === tween) {
|
||||
if (
|
||||
st.trigger === container ||
|
||||
(st.vars as { containerAnimation?: unknown }).containerAnimation ===
|
||||
tween
|
||||
) {
|
||||
st.kill();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import MagneticButton from "./MagneticButton";
|
||||
|
||||
jest.mock("motion/react", () => {
|
||||
const React = require("react");
|
||||
|
||||
const motion = new Proxy(
|
||||
{},
|
||||
{
|
||||
get: (_, tag: string) => {
|
||||
const Component = ({
|
||||
children,
|
||||
animate: _animate,
|
||||
transition: _transition,
|
||||
...props
|
||||
}: { children?: ReactNode } & Record<string, unknown>) =>
|
||||
React.createElement(tag, props, children);
|
||||
Component.displayName = `motion.${tag}`;
|
||||
return Component;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return { motion };
|
||||
});
|
||||
|
||||
describe("MagneticButton semantics", () => {
|
||||
it("renders a list item directly when used inside a list", () => {
|
||||
const { container } = render(
|
||||
<ul>
|
||||
<MagneticButton as="li" className="ds-tag">
|
||||
TypeScript
|
||||
</MagneticButton>
|
||||
</ul>,
|
||||
);
|
||||
|
||||
expect(container.querySelector("ul > li.ds-tag")).toBeInTheDocument();
|
||||
expect(container.querySelector("ul > div > li")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, type ReactNode } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { motion } from "motion/react";
|
||||
import { type MouseEvent, type ReactNode, useState } from "react";
|
||||
|
||||
interface MagneticButtonProps {
|
||||
children: ReactNode;
|
||||
@@ -34,14 +34,10 @@ export default function MagneticButton({
|
||||
wrapperClassName = "",
|
||||
"aria-label": ariaLabel,
|
||||
}: MagneticButtonProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||
|
||||
const handleMouse = (e: React.MouseEvent) => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
|
||||
const rect = el.getBoundingClientRect();
|
||||
const handleMouse = (e: MouseEvent<HTMLElement>) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const centerX = rect.left + rect.width / 2;
|
||||
const centerY = rect.top + rect.height / 2;
|
||||
|
||||
@@ -55,31 +51,59 @@ export default function MagneticButton({
|
||||
setPosition({ x: 0, y: 0 });
|
||||
};
|
||||
|
||||
const Tag = as === "a" ? motion.a : as === "button" ? motion.button : as === "div" ? motion.div : as === "span" ? motion.span : motion.li;
|
||||
const Tag =
|
||||
as === "a"
|
||||
? motion.a
|
||||
: as === "button"
|
||||
? motion.button
|
||||
: as === "div"
|
||||
? motion.div
|
||||
: as === "span"
|
||||
? motion.span
|
||||
: motion.li;
|
||||
const linkProps = as === "a" ? { href, target, rel, download } : {};
|
||||
const buttonProps = as === "button" ? { type } : {};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
onMouseMove={handleMouse}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
className={`inline-block ${wrapperClassName}`.trim()}
|
||||
>
|
||||
const motionProps = {
|
||||
animate: {
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
},
|
||||
transition: {
|
||||
type: "spring" as const,
|
||||
stiffness: 350,
|
||||
damping: 15,
|
||||
mass: 0.5,
|
||||
},
|
||||
};
|
||||
|
||||
if (as === "li") {
|
||||
return (
|
||||
<Tag
|
||||
className={className}
|
||||
className={`${className} ${wrapperClassName}`.trim()}
|
||||
onMouseMove={handleMouse}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onClick={onClick}
|
||||
data-cursor={dataCursor}
|
||||
animate={{
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
}}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 350,
|
||||
damping: 15,
|
||||
mass: 0.5,
|
||||
}}
|
||||
{...motionProps}
|
||||
{...linkProps}
|
||||
{...buttonProps}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{children}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`inline-block ${wrapperClassName}`.trim()}>
|
||||
<Tag
|
||||
className={className}
|
||||
onMouseMove={handleMouse}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onClick={onClick}
|
||||
data-cursor={dataCursor}
|
||||
{...motionProps}
|
||||
{...linkProps}
|
||||
{...buttonProps}
|
||||
aria-label={ariaLabel}
|
||||
|
||||
@@ -8,7 +8,7 @@ jest.mock("react-dom", () => ({
|
||||
createPortal: (children: ReactNode) => children,
|
||||
}));
|
||||
|
||||
jest.mock("framer-motion", () => {
|
||||
jest.mock("motion/react", () => {
|
||||
const React = require("react");
|
||||
const motion = new Proxy(
|
||||
{},
|
||||
@@ -31,9 +31,7 @@ describe("PageIntro", () => {
|
||||
it("renders the compact signal loading state without a fake percentage", () => {
|
||||
render(<PageIntro />);
|
||||
|
||||
expect(
|
||||
screen.getByRole("heading", { name: /EVANDRO.*DEV/ }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(/EVANDRO.*DEV/)).toBeInTheDocument();
|
||||
expect(screen.getByText("SINAL DE ENTRADA")).toBeInTheDocument();
|
||||
expect(screen.getByText("Preparando o portfólio")).toBeInTheDocument();
|
||||
expect(screen.queryByText("000")).not.toBeInTheDocument();
|
||||
@@ -47,7 +45,9 @@ describe("PageIntro", () => {
|
||||
expect(screen.getByText("Preparando o portfólio")).toBeInTheDocument();
|
||||
|
||||
act(() => jest.runAllTimers());
|
||||
expect(screen.queryByText("Preparando o portfólio")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("Preparando o portfólio"),
|
||||
).not.toBeInTheDocument();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||
@@ -42,7 +42,7 @@ export default function PageIntro() {
|
||||
aria-label="Preparando o portfólio"
|
||||
>
|
||||
<div className="pointer-events-none absolute inset-0 opacity-30 [background-image:linear-gradient(rgba(255,255,255,.06)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,.06)_1px,transparent_1px)] [background-size:48px_48px]" />
|
||||
<div className="pointer-events-none absolute inset-x-0 top-[42%] h-px bg-[var(--slash)] opacity-70" />
|
||||
<div className="pointer-events-none absolute inset-x-0 top-[68%] h-px bg-[var(--slash)] opacity-70" />
|
||||
|
||||
<div className="relative m-auto w-[min(88vw,680px)]">
|
||||
<motion.p
|
||||
@@ -53,8 +53,9 @@ export default function PageIntro() {
|
||||
>
|
||||
SINAL DE ENTRADA
|
||||
</motion.p>
|
||||
<motion.h1
|
||||
className="mt-sp-4 font-display text-[clamp(3rem,12vw,8rem)] font-extrabold leading-[0.82] tracking-[-0.08em]"
|
||||
{/* Wordmark decorativo do overlay: NÃO é heading — o h1 real vive na página */}
|
||||
<motion.p
|
||||
className="mt-sp-4 font-display text-[clamp(2rem,12vw,8rem)] font-extrabold leading-[0.82] tracking-[-0.04em] lg:tracking-[-0.08em]"
|
||||
initial={reduceMotion ? false : { opacity: 0, y: 28 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{
|
||||
@@ -64,7 +65,7 @@ export default function PageIntro() {
|
||||
}}
|
||||
>
|
||||
EVANDRO<span className="text-[var(--slash)]">.</span>DEV
|
||||
</motion.h1>
|
||||
</motion.p>
|
||||
<motion.div
|
||||
className="mt-sp-8 flex items-center gap-sp-4"
|
||||
initial={reduceMotion ? false : { opacity: 0 }}
|
||||
@@ -94,7 +95,7 @@ export default function PageIntro() {
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-8 left-6 right-6 flex justify-between font-mono text-[9px] uppercase tracking-[0.12em] text-[rgba(244,242,237,.45)] lg:bottom-10 lg:left-12 lg:right-12">
|
||||
<div className="absolute bottom-8 left-6 right-6 flex justify-between font-mono text-[8px] uppercase tracking-[0.12em] text-[rgba(244,242,237,.32)] lg:bottom-10 lg:left-12 lg:right-12 lg:text-[9px] lg:text-[rgba(244,242,237,.45)]">
|
||||
<span>Engenharia de software</span>
|
||||
<span>Garuva · Joinville · Curitiba</span>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
const DEFAULT_TEXT =
|
||||
"Engenharia de software para sistemas que precisam operar de verdade.";
|
||||
|
||||
const MAX_DPR = 1.5;
|
||||
const ASSEMBLE_MS = 2000;
|
||||
const REPEL_RADIUS_PX = 80;
|
||||
const MIN_PARTICLES = 1200;
|
||||
const MAX_PARTICLES = 2500;
|
||||
const OFFSCREEN_POINTER = -100000;
|
||||
|
||||
type Particle = {
|
||||
sx: number;
|
||||
sy: number;
|
||||
tx: number;
|
||||
ty: number;
|
||||
ox: number;
|
||||
oy: number;
|
||||
delay: number;
|
||||
color: string;
|
||||
size: number;
|
||||
phase: number;
|
||||
speed: number;
|
||||
amp: number;
|
||||
};
|
||||
|
||||
type TrackingCapableContext = CanvasRenderingContext2D & {
|
||||
letterSpacing?: string;
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function wrapLines(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
text: string,
|
||||
maxWidth: number,
|
||||
) {
|
||||
const lines: string[] = [];
|
||||
let current = "";
|
||||
for (const word of text.split(" ")) {
|
||||
const candidate = current ? `${current} ${word}` : word;
|
||||
if (!current || ctx.measureText(candidate).width <= maxWidth) {
|
||||
current = candidate;
|
||||
} else {
|
||||
lines.push(current);
|
||||
current = word;
|
||||
}
|
||||
}
|
||||
if (current) lines.push(current);
|
||||
return lines;
|
||||
}
|
||||
|
||||
export function ParticleBuildText({ text = DEFAULT_TEXT }: { text?: string }) {
|
||||
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 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;
|
||||
|
||||
// next/font gera nome de família com hash: resolver pela classe font-display real
|
||||
const probe = document.createElement("span");
|
||||
probe.className = "font-display";
|
||||
probe.style.position = "absolute";
|
||||
probe.style.opacity = "0";
|
||||
document.body.appendChild(probe);
|
||||
const fontFamily = getComputedStyle(probe).fontFamily || "sans-serif";
|
||||
document.body.removeChild(probe);
|
||||
|
||||
const fontsReady =
|
||||
typeof document.fonts === "undefined"
|
||||
? Promise.resolve()
|
||||
: document.fonts.ready;
|
||||
|
||||
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 offscreen = document.createElement("canvas");
|
||||
offscreen.width = canvas.width;
|
||||
offscreen.height = canvas.height;
|
||||
const offCtx = offscreen.getContext("2d");
|
||||
if (!offCtx) return;
|
||||
// Backing store é device-pixel: escala p/ que coordenadas CSS preencham o painel inteiro
|
||||
offCtx.scale(dpr, dpr);
|
||||
|
||||
const maxWidth = width * 0.92;
|
||||
let fontSize = Math.min(height * 0.22, width / 7);
|
||||
let lines: string[] = [];
|
||||
while (fontSize >= 12) {
|
||||
offCtx.font = `800 ${fontSize}px ${fontFamily}`;
|
||||
const trackingCtx = offCtx as TrackingCapableContext;
|
||||
if (trackingCtx.letterSpacing !== undefined) {
|
||||
trackingCtx.letterSpacing = "-0.04em";
|
||||
}
|
||||
lines = wrapLines(offCtx, text, maxWidth);
|
||||
if (lines.length * fontSize * 0.98 <= height * 0.8) break;
|
||||
fontSize *= 0.94;
|
||||
}
|
||||
fontSize = Math.max(fontSize, 12);
|
||||
|
||||
const lineHeight = fontSize * 0.98;
|
||||
const startY = (height - lines.length * lineHeight) / 2 + fontSize * 0.74;
|
||||
offCtx.fillStyle = "#ffffff";
|
||||
offCtx.textBaseline = "alphabetic";
|
||||
lines.forEach((line, index) => {
|
||||
const lineWidth = offCtx.measureText(line).width;
|
||||
offCtx.fillText(
|
||||
line,
|
||||
(width - lineWidth) / 2,
|
||||
startY + index * lineHeight,
|
||||
);
|
||||
});
|
||||
|
||||
const pixels = offCtx.getImageData(
|
||||
0,
|
||||
0,
|
||||
offscreen.width,
|
||||
offscreen.height,
|
||||
).data;
|
||||
const targets: Array<[number, number]> = [];
|
||||
const stride = 3;
|
||||
for (let py = 0; py < offscreen.height; py += stride) {
|
||||
for (let px = 0; px < offscreen.width; px += stride) {
|
||||
if (pixels[(py * offscreen.width + px) * 4 + 3] > 128) {
|
||||
targets.push([px, py]);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (targets.length === 0) return;
|
||||
|
||||
const desired = Math.round(
|
||||
Math.min(
|
||||
MAX_PARTICLES,
|
||||
Math.max(MIN_PARTICLES, (width * height) / 180),
|
||||
),
|
||||
);
|
||||
|
||||
particles = Array.from({ length: desired }, () => {
|
||||
const target = targets[Math.floor(Math.random() * targets.length)];
|
||||
const useSlash = Math.random() < 0.72;
|
||||
const rgb = useSlash ? slashRgb : paperRgb;
|
||||
const alpha = useSlash
|
||||
? 0.28 + Math.random() * 0.22
|
||||
: 0.25 + Math.random() * 0.15;
|
||||
return {
|
||||
tx: target[0],
|
||||
ty: target[1],
|
||||
sx: hasAssembled
|
||||
? target[0]
|
||||
: (Math.random() * 1.3 - 0.15) * canvas.width,
|
||||
sy: hasAssembled
|
||||
? target[1]
|
||||
: (Math.random() * 1.3 - 0.15) * canvas.height,
|
||||
ox: 0,
|
||||
oy: 0,
|
||||
delay: Math.random() * 0.35,
|
||||
color: `rgba(${rgb[0]},${rgb[1]},${rgb[2]},${alpha.toFixed(2)})`,
|
||||
size: (Math.random() < 0.85 ? 1 : 2) * dpr,
|
||||
phase: Math.random() * Math.PI * 2,
|
||||
speed: 0.0005 + Math.random() * 0.0007,
|
||||
amp: (0.6 + Math.random()) * dpr,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
const driftX =
|
||||
Math.sin(elapsed * particle.speed + particle.phase) *
|
||||
particle.amp *
|
||||
progress;
|
||||
const driftY =
|
||||
Math.cos(elapsed * particle.speed * 0.9 + particle.phase) *
|
||||
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;
|
||||
|
||||
ctx.fillStyle = particle.color;
|
||||
ctx.fillRect(
|
||||
baseX + driftX + particle.ox,
|
||||
baseY + driftY + particle.oy,
|
||||
particle.size,
|
||||
particle.size,
|
||||
);
|
||||
}
|
||||
|
||||
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.color;
|
||||
ctx.fillRect(particle.tx, particle.ty, particle.size, particle.size);
|
||||
}
|
||||
};
|
||||
|
||||
const tick = (now: number) => {
|
||||
elapsed += Math.min(now - lastTime, 50);
|
||||
lastTime = now;
|
||||
render();
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleResample = () => {
|
||||
window.clearTimeout(resampleTimer);
|
||||
resampleTimer = window.setTimeout(() => {
|
||||
sample();
|
||||
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
|
||||
fontsReady.then(() => {
|
||||
if (cancelled) return;
|
||||
hasAssembled = true;
|
||||
sample();
|
||||
drawStaticFrame();
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearTimeout(resampleTimer);
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
fontsReady.then(() => {
|
||||
if (cancelled) return;
|
||||
sample();
|
||||
syncLoop();
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.cancelAnimationFrame(rafId);
|
||||
window.clearTimeout(resampleTimer);
|
||||
resizeObserver.disconnect();
|
||||
intersectionObserver.disconnect();
|
||||
window.removeEventListener("pointermove", handlePointerMove);
|
||||
document.documentElement.removeEventListener(
|
||||
"pointerleave",
|
||||
handlePointerLeave,
|
||||
);
|
||||
window.removeEventListener("scroll", handleScroll);
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
};
|
||||
}, [text]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0"
|
||||
>
|
||||
<canvas ref={canvasRef} className="h-full w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { motion, useScroll, useSpring, useTransform } from "framer-motion";
|
||||
import { motion, useScroll, useSpring, useTransform } from "motion/react";
|
||||
|
||||
export default function ScrollProgress() {
|
||||
const { scrollYProgress } = useScroll();
|
||||
@@ -10,7 +10,11 @@ export default function ScrollProgress() {
|
||||
restDelta: 0.001,
|
||||
});
|
||||
|
||||
const opacity = useTransform(scrollYProgress, [0, 0.02, 0.98, 1], [0, 1, 1, 0]);
|
||||
const opacity = useTransform(
|
||||
scrollYProgress,
|
||||
[0, 0.02, 0.98, 1],
|
||||
[0, 1, 1, 0],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useEffect, useState } from "react";
|
||||
import {
|
||||
motion,
|
||||
useAnimationFrame,
|
||||
useMotionValue,
|
||||
useTransform,
|
||||
useSpring,
|
||||
useScroll,
|
||||
} from "framer-motion";
|
||||
useSpring,
|
||||
useTransform,
|
||||
} from "motion/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
interface ScrollVelocityTextProps {
|
||||
text: string;
|
||||
@@ -89,7 +89,9 @@ export default function ScrollVelocityText({
|
||||
className="mr-8 inline-block font-display text-[clamp(3rem,10vw,8rem)] font-extrabold uppercase leading-none tracking-[-0.04em]"
|
||||
>
|
||||
{text}
|
||||
<span className="mx-8 inline-block text-[var(--slash)]">•</span>
|
||||
<span className="mx-8 inline-block text-[var(--slash)]">
|
||||
•
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useEffect } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
interface SectionDividerProps {
|
||||
variant?: "gradient" | "pulse" | "waveform";
|
||||
@@ -84,7 +84,9 @@ export default function SectionDivider({
|
||||
|
||||
if (variant === "pulse") {
|
||||
return (
|
||||
<div className={`flex items-center justify-center gap-2 py-8 ${className}`}>
|
||||
<div
|
||||
className={`flex items-center justify-center gap-2 py-8 ${className}`}
|
||||
>
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user