"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(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 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 ( ); }