/** * Plexus 3D Hero - Red de Nodos Conectados para Hero * Integración con Three.js y soporte dinámico para Modo Claro / Oscuro vía CSS Variables. */ (function () { // Asegurarse de que el DOM esté listo if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initPlexus); } else { initPlexus(); } function initPlexus() { // 1. Validar dependencia de Three.js if (typeof THREE === 'undefined') { console.warn('[Plexus 3D] Three.js no está cargado. Asegúrate de incluir Three.js antes de este script.'); return; } const container = document.getElementById('hero-3d-container'); if (!container) { // Si el contenedor no existe en esta página, no ejecutar nada return; } // ========================================================================= // CONFIGURACIÓN GENERAL // ========================================================================= const CONFIG = { nodeCount: 100, // Cantidad de nodos (puntos) radius: 120, // Radio general de la esfera/nube 3D maxDistance: 60, // Distancia máxima para conectar dos nodos con línea nodeSize: 6, // Tamaño del punto en píxeles driftSpeed: 0.0005, // Velocidad de flotación orgánica propia driftAmplitude: 12, // Rango máximo de oscilación (mantiene la forma) mouseFactor: 0.35, // Grado de rotación en respuesta al cursor lerpSpeed: 0.05, // Suavidad de la inercia del movimiento (0.01 - 0.1) // Variables CSS que el script vigilará en tu :root / .dark cssVars: { nodeColor: '--ide-primary', lineColor: '--ide-primary' }, // Colores de respaldo en caso de que las variables no existan en el CSS fallbacks: { nodeColor: '#0a2540', lineColor: '#008080' }, lineOpacity: 0.35 // Opacidad de las líneas }; // ========================================================================= // LECTURA REACTIVA DE VARIABLES CSS // ========================================================================= function getCSSVariable(varName, fallback) { const val = getComputedStyle(document.documentElement) .getPropertyValue(varName) .trim(); return val || fallback; } // ========================================================================= // CONFIGURACIÓN THREE.JS (Escena, Cámara y Renderer) // ========================================================================= const scene = new THREE.Scene(); const width = container.clientWidth || 400; const height = container.clientHeight || 450; const camera = new THREE.PerspectiveCamera(50, width / height, 1, 1000); camera.position.z = 320; const renderer = new THREE.WebGLRenderer({ alpha: true, // Fondo 100% transparente para integrarse con el Hero antialias: true, // Suavizado de bordes powerPreference: 'high-performance' }); renderer.setSize(width, height); renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); container.appendChild(renderer.domElement); // Grupo principal para aplicar rotación y control de inercia const cloudGroup = new THREE.Group(); scene.add(cloudGroup); // ========================================================================= // GENERACIÓN DE NODOS (Distribución Esférica Uniforme) // ========================================================================= const nodes = []; const nodePositions = new Float32Array(CONFIG.nodeCount * 3); for (let i = 0; i < CONFIG.nodeCount; i++) { // Distribución equidistante en esfera (Espiral de Fibonacci 3D) const phi = Math.acos(-1 + (2 * i) / CONFIG.nodeCount); const theta = Math.sqrt(CONFIG.nodeCount * Math.PI) * phi; // Variación radial controlada para darle volumen tridimensional const r = CONFIG.radius * (0.65 + 0.35 * Math.random()); const baseX = r * Math.cos(theta) * Math.sin(phi); const baseY = r * Math.sin(theta) * Math.sin(phi); const baseZ = r * Math.cos(phi); nodes.push({ base: new THREE.Vector3(baseX, baseY, baseZ), current: new THREE.Vector3(baseX, baseY, baseZ), speedOffset: Math.random() * Math.PI * 2, axisFactors: [ 0.8 + Math.random() * 0.4, 0.8 + Math.random() * 0.4, 0.8 + Math.random() * 0.4 ] }); nodePositions[i * 3] = baseX; nodePositions[i * 3 + 1] = baseY; nodePositions[i * 3 + 2] = baseZ; } // Geometría y textura circular suave para los nodos const pointsGeometry = new THREE.BufferGeometry(); pointsGeometry.setAttribute('position', new THREE.BufferAttribute(nodePositions, 3)); const canvas = document.createElement('canvas'); canvas.width = 64; canvas.height = 64; const ctx = canvas.getContext('2d'); ctx.fillStyle = '#ffffff'; ctx.beginPath(); ctx.arc(32, 32, 28, 0, Math.PI * 2); ctx.fill(); const pointTexture = new THREE.CanvasTexture(canvas); const pointsMaterial = new THREE.PointsMaterial({ size: CONFIG.nodeSize, map: pointTexture, transparent: true, alphaTest: 0.05 }); const pointCloud = new THREE.Points(pointsGeometry, pointsMaterial); cloudGroup.add(pointCloud); // ========================================================================= // LÍNEAS DE CONEXIÓN DINÁMICAS // ========================================================================= const maxPossibleLines = (CONFIG.nodeCount * (CONFIG.nodeCount - 1)) / 2; const linePositions = new Float32Array(maxPossibleLines * 6); const linesGeometry = new THREE.BufferGeometry(); linesGeometry.setAttribute( 'position', new THREE.BufferAttribute(linePositions, 3).setUsage(THREE.DynamicDrawUsage) ); const linesMaterial = new THREE.LineBasicMaterial({ transparent: true, opacity: CONFIG.lineOpacity }); const lines = new THREE.LineSegments(linesGeometry, linesMaterial); cloudGroup.add(lines); // ========================================================================= // GESTIÓN DE COLORES Y OBSERVADORES DE TEMA (DARK / LIGHT MODE) // ========================================================================= function updateThemeColors() { const nodeColorStr = getCSSVariable(CONFIG.cssVars.nodeColor, CONFIG.fallbacks.nodeColor); const lineColorStr = getCSSVariable(CONFIG.cssVars.lineColor, CONFIG.fallbacks.lineColor); pointsMaterial.color.set(nodeColorStr); linesMaterial.color.set(lineColorStr); } // Aplicar colores al arrancar updateThemeColors(); // 1. Observar cambios en atributos de y (classes, data-theme, etc.) const observer = new MutationObserver(() => { updateThemeColors(); }); observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'data-theme', 'data-mode', 'style'] }); observer.observe(document.body, { attributes: true, attributeFilter: ['class', 'data-theme', 'data-mode', 'style'] }); // 2. Escuchar cambios de preferencia de color del sistema operativo if (window.matchMedia) { window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', updateThemeColors); } // Exponer función de refresco manual por si tu toggle dispara eventos personalizados window.refreshPlexusTheme = updateThemeColors; // ========================================================================= // INTERACCIÓN CON EL MOUSE (PARALAJE E INCLINACIÓN 3D) // ========================================================================= let targetRotationX = 0; let targetRotationY = 0; window.addEventListener('mousemove', (e) => { const rect = container.getBoundingClientRect(); // Cálculo de posición normalizada respecto al centro de la ventana o hero const centerX = rect.left + rect.width / 2; const centerY = rect.top + rect.height / 2; const normX = (e.clientX - centerX) / (window.innerWidth / 2); const normY = (e.clientY - centerY) / (window.innerHeight / 2); targetRotationY = normX * CONFIG.mouseFactor; targetRotationX = -normY * CONFIG.mouseFactor; }, { passive: true }); // ========================================================================= // BUCLE DE ANIMACIÓN // ========================================================================= let time = 0; function animate() { requestAnimationFrame(animate); time += CONFIG.driftSpeed * 1000; // 1. Oscilación armónica de los nodos (flotación sin desarme) const currentPosArray = pointsGeometry.attributes.position.array; for (let i = 0; i < CONFIG.nodeCount; i++) { const node = nodes[i]; const offset = time * 0.0015 + node.speedOffset; node.current.x = node.base.x + Math.sin(offset * node.axisFactors[0]) * CONFIG.driftAmplitude; node.current.y = node.base.y + Math.cos(offset * node.axisFactors[1]) * CONFIG.driftAmplitude; node.current.z = node.base.z + Math.sin(offset * node.axisFactors[2]) * CONFIG.driftAmplitude; currentPosArray[i * 3] = node.current.x; currentPosArray[i * 3 + 1] = node.current.y; currentPosArray[i * 3 + 2] = node.current.z; } pointsGeometry.attributes.position.needsUpdate = true; // 2. Conectar nodos próximos let lineIndex = 0; const lineArray = linesGeometry.attributes.position.array; for (let i = 0; i < CONFIG.nodeCount; i++) { for (let j = i + 1; j < CONFIG.nodeCount; j++) { const dist = nodes[i].current.distanceTo(nodes[j].current); if (dist < CONFIG.maxDistance) { lineArray[lineIndex++] = nodes[i].current.x; lineArray[lineIndex++] = nodes[i].current.y; lineArray[lineIndex++] = nodes[i].current.z; lineArray[lineIndex++] = nodes[j].current.x; lineArray[lineIndex++] = nodes[j].current.y; lineArray[lineIndex++] = nodes[j].current.z; } } } linesGeometry.setDrawRange(0, lineIndex / 3); linesGeometry.attributes.position.needsUpdate = true; // 3. Rotación base continua + interpolación suave hacia el mouse (Lerp) cloudGroup.rotation.y += 0.0012; cloudGroup.rotation.y += (targetRotationY - cloudGroup.rotation.y) * CONFIG.lerpSpeed; cloudGroup.rotation.x += (targetRotationX - cloudGroup.rotation.x) * CONFIG.lerpSpeed; renderer.render(scene, camera); } animate(); // ========================================================================= // RESPONSIVE RESIZING (Evita deformaciones y recortes) // ========================================================================= function handleResize() { if (!container) return; const newWidth = container.clientWidth; const newHeight = container.clientHeight; if (newWidth === 0 || newHeight === 0) return; camera.aspect = newWidth / newHeight; camera.updateProjectionMatrix(); renderer.setSize(newWidth, newHeight); } // ResizeObserver para cambios precisos en el contenedor del Hero if (window.ResizeObserver) { const resizeObserver = new ResizeObserver(handleResize); resizeObserver.observe(container); } else { window.addEventListener('resize', handleResize); } } })();