"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(null); const canvasRef = useRef(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 ( ); }