diff --git a/app/components/Hero.tsx b/app/components/Hero.tsx index 552ecea..2f8671c 100644 --- a/app/components/Hero.tsx +++ b/app/components/Hero.tsx @@ -8,7 +8,7 @@ import { CV_URL } from "@/lib/site-navigation"; import { buildWhatsAppUrl } from "@/lib/whatsapp"; import { ControlRoomGlobe } from "./cinematic/ControlRoomGlobe"; import MagneticButton from "./cinematic/MagneticButton"; -import { ParticleBuildText } from "./cinematic/ParticleBuildText"; +import { HeroStarfield } from "./cinematic/HeroStarfield"; import { TelemetryStrip } from "./cinematic/TelemetryStrip"; const HeroScene = dynamic(() => import("./cinematic/HeroScene"), { @@ -318,8 +318,8 @@ function SideVisual({ if (variant.scene === "command") { return (
- {/* Partículas decorativas atrás da cena: o h1 real permanece o conteúdo acessível */} - + {/* Starfield decorativo: grade uniforme com jitter cobrindo o card inteiro */} + {/* autoRotate off em reduced-motion: globo vira âncora estática */} diff --git a/app/components/Projects.tsx b/app/components/Projects.tsx index 836e2ff..8b57e6e 100644 --- a/app/components/Projects.tsx +++ b/app/components/Projects.tsx @@ -70,7 +70,7 @@ export default function Projects({ whileTap={shouldReduceMotion ? undefined : { scale: 0.99 }} transition={{ type: "spring", stiffness: 170, damping: 18 }} > -
+
{project.category}
@@ -81,7 +81,12 @@ export default function Projects({

)}
- + {project.status}
diff --git a/app/components/cinematic/ParticleBuildText.tsx b/app/components/cinematic/HeroStarfield.tsx similarity index 53% rename from app/components/cinematic/ParticleBuildText.tsx rename to app/components/cinematic/HeroStarfield.tsx index a153589..b3d8e65 100644 --- a/app/components/cinematic/ParticleBuildText.tsx +++ b/app/components/cinematic/HeroStarfield.tsx @@ -1,28 +1,26 @@ -"use client"; +"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; +// 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; + sx: number; // origem da assemblei (espalhada) no primeiro load sy: number; - tx: number; + tx: number; // âncora na grade com jitter ty: number; - ox: number; + ox: number; // deslocamento acumulado do repel do ponteiro oy: number; delay: number; fillStyle: string; baseAlpha: number; - ambient: boolean; size: number; phase: number; speed: number; @@ -31,10 +29,6 @@ type Particle = { birth: number; }; -type TrackingCapableContext = CanvasRenderingContext2D & { - letterSpacing?: string; -}; - type Rgb = [number, number, number]; const FALLBACK_SLASH: Rgb = [204, 255, 0]; @@ -61,27 +55,7 @@ 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 }) { +export function HeroStarfield() { const containerRef = useRef(null); const canvasRef = useRef(null); @@ -118,19 +92,47 @@ export function ParticleBuildText({ text = DEFAULT_TEXT }: { text?: string }) { 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); + // 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 fontsReady = - typeof document.fonts === "undefined" - ? Promise.resolve() - : document.fonts.ready; + 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; @@ -142,120 +144,33 @@ export function ParticleBuildText({ text = DEFAULT_TEXT }: { text?: string }) { 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]); - } - } - } - // Ambient stars cover the whole card so the background reads as a living - // starfield even where the globe covers the headline text shape - const makeParticle = ( - tx: number, - ty: number, - ambient: boolean, - ): Particle => { - const useSlash = Math.random() < (ambient ? 0.45 : 0.72); - 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: ambient - ? 0.6 + Math.random() * 0.4 - : useSlash - ? 0.45 + Math.random() * 0.3 - : 0.4 + Math.random() * 0.2, - ambient, - size: - (ambient - ? Math.random() < 0.7 - ? 2 - : 3 - : Math.random() < 0.85 - ? 1 - : 2) * dpr, - phase: Math.random() * Math.PI * 2, - speed: 0.0005 + Math.random() * 0.0007, - amp: (ambient ? 2 + Math.random() * 4 : 0.6 + Math.random()) * dpr, - ttl: 4000 + Math.random() * 6000, - // birth negativo: estagia os ciclos de vida para o spawn/death ja rodar disperso no load - birth: -Math.random() * 10000, - }; - }; - - const desired = Math.round( + const count = Math.round( Math.min( - MAX_PARTICLES, - Math.max(MIN_PARTICLES, (width * height) / 180), + MAX_STARS, + Math.max(MIN_STARS, (width * height) / STAR_AREA_DIVISOR), ), ); - - const textParticles = Array.from({ length: desired }, () => { - const target = targets[Math.floor(Math.random() * targets.length)]; - return makeParticle(target[0], target[1], false); - }); - - const ambientCount = Math.round( - Math.min(520, Math.max(200, (width * height) / 2500)), + // células quadradas-ish para a proporção do card + grid.cols = Math.max( + 1, + Math.round(Math.sqrt(count * (canvas.width / canvas.height))), ); - const ambient: Particle[] = Array.from({ length: ambientCount }, () => { - const tx = Math.random() * canvas.width; - const ty = Math.random() * canvas.height; - return makeParticle(tx, ty, true); - }); + grid.rows = Math.max(1, Math.ceil(count / grid.cols)); + grid.cellW = canvas.width / grid.cols; + grid.cellH = canvas.height / grid.rows; - particles = [...textParticles, ...ambient]; + // 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 = () => { @@ -280,12 +195,17 @@ export function ParticleBuildText({ text = DEFAULT_TEXT }: { text?: string }) { 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(elapsed * particle.speed + particle.phase) * + (Math.sin(t + particle.phase) + + 0.5 * Math.sin(t * 0.43 + particle.phase * 1.7)) * particle.amp * progress; const driftY = - Math.cos(elapsed * particle.speed * 0.9 + particle.phase) * + (Math.cos(t * 0.9 + particle.phase) + + 0.5 * Math.cos(t * 0.51 + particle.phase * 2.3)) * particle.amp * progress; @@ -302,27 +222,27 @@ export function ParticleBuildText({ text = DEFAULT_TEXT }: { text?: string }) { particle.oy *= 0.86; const fadeIn = Math.min(elapsed / 1200, 1); - const twinkle = particle.ambient - ? 0.55 + 0.45 * Math.sin(elapsed * 0.004 + particle.phase) - : 0.82 + 0.18 * Math.sin(elapsed * particle.speed + particle.phase); - let lifeFade = 1; - if (particle.ambient) { - let life = elapsed - particle.birth; - if (life > particle.ttl) { - particle.tx = Math.random() * canvas.width; - particle.ty = Math.random() * canvas.height; - particle.sx = particle.tx; - particle.sy = particle.ty; - particle.phase = Math.random() * Math.PI * 2; - particle.baseAlpha = 0.6 + Math.random() * 0.4; - particle.size = (Math.random() < 0.7 ? 2 : 3) * dpr; - particle.ttl = 4000 + Math.random() * 6000; - particle.birth = elapsed; - life = 0; - } - // fade-in de 800ms ao nascer, fade-out nos ultimos 800ms de vida - lifeFade = Math.min(life / 800, (particle.ttl - life) / 800, 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, @@ -371,8 +291,8 @@ export function ParticleBuildText({ text = DEFAULT_TEXT }: { text?: string }) { } }; - // Warm-up determinístico: corrida entre fontes/layout pode deixar o - // container com 0px no primeiro sample; re-tenta até existirem partículas + // 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) { @@ -414,12 +334,9 @@ export function ParticleBuildText({ text = DEFAULT_TEXT }: { text?: string }) { 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; - sampleUntilBuilt(() => drawStaticFrame()); - }); + // Variante estática: quadro final montado, sem rAF e sem listeners de ponteiro + hasAssembled = true; + sampleUntilBuilt(() => drawStaticFrame()); return () => { cancelled = true; @@ -429,8 +346,8 @@ export function ParticleBuildText({ text = DEFAULT_TEXT }: { text?: string }) { }; } - // Start determinístico: intersecting derivado de geometry no setup (o callback do IO - // pode chegar tarde/fora de ordem com fontsReady e deixar o loop rAF morto na carga) + // 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 = @@ -440,7 +357,7 @@ export function ParticleBuildText({ text = DEFAULT_TEXT }: { text?: string }) { current.left < window.innerWidth; }; syncIntersecting(); - syncLoop(); + sampleUntilBuilt(() => syncLoop()); const intersectionObserver = new IntersectionObserver((entries) => { intersecting = entries.some((entry) => entry.isIntersecting); @@ -458,11 +375,6 @@ export function ParticleBuildText({ text = DEFAULT_TEXT }: { text?: string }) { window.addEventListener("scroll", handleScroll, { passive: true }); document.addEventListener("visibilitychange", handleVisibilityChange); - fontsReady.then(() => { - if (cancelled) return; - sampleUntilBuilt(() => syncLoop()); - }); - return () => { cancelled = true; window.cancelAnimationFrame(rafId); @@ -478,7 +390,7 @@ export function ParticleBuildText({ text = DEFAULT_TEXT }: { text?: string }) { window.removeEventListener("scroll", handleScroll); document.removeEventListener("visibilitychange", handleVisibilityChange); }; - }, [text]); + }, []); return (