diff --git a/animacion.html b/animacion.html deleted file mode 100644 index 4f2a476..0000000 --- a/animacion.html +++ /dev/null @@ -1,104 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/buscar.html b/buscar.html new file mode 100644 index 0000000..1f21cc4 --- /dev/null +++ b/buscar.html @@ -0,0 +1,75 @@ + + + + + + Buscar en el sitio | IDE Consultores + + + + + + + + + + +
+ + +
+
+ BUSCAR EN EL SITIO +

¿Qué estás buscando?

+

+ Encontrá contenidos, soluciones, cursos del Ecosistema IDE e insights publicados. +

+ + + +

+
+
+ + +
+
+ + + +
+ +
+
+ +
+ + + + + \ No newline at end of file diff --git a/css/buscar.css b/css/buscar.css new file mode 100644 index 0000000..e98c8cc --- /dev/null +++ b/css/buscar.css @@ -0,0 +1,262 @@ +/* ========================================================================== + PÁGINA DE BÚSQUEDA — buscar.html + ========================================================================== */ + +/* Hero con el campo de búsqueda */ +.search-hero { + padding: 3.5rem 0 2.5rem; + background: var(--bg-surface); + border-bottom: 1px solid var(--border-color); +} + +.search-hero .eyebrow { + margin-bottom: 0.75rem; + display: inline-block; +} + +.search-hero h1 { + font-size: clamp(1.75rem, 4vw, 2.5rem); + line-height: 1.2; + margin-bottom: 1rem; +} + +.search-hero .lead-text { + color: var(--text-muted); + font-size: 1rem; + max-width: 640px; + margin: 0 auto 2rem; + line-height: 1.55; +} + +/* Form de búsqueda */ +.search-page-form { + display: flex; + gap: 0.5rem; + max-width: 640px; + margin: 0 auto; + flex-wrap: wrap; +} + +.search-page-form .search-input-wrap { + position: relative; + flex: 1; + min-width: 260px; +} + +.search-page-form .search-input-wrap svg { + position: absolute; + left: 1rem; + top: 50%; + transform: translateY(-50%); + color: var(--text-muted); + pointer-events: none; +} + +.search-page-form input[type="search"] { + width: 100%; + padding: 0.9rem 1rem 0.9rem 2.75rem; + border: 1px solid var(--border-color); + border-radius: 8px; + font-size: 1rem; + font-family: inherit; + background: var(--bg-body, #fff); + color: var(--text-body); + transition: border-color 0.2s ease, box-shadow 0.2s ease; +} + +.search-page-form input[type="search"]::placeholder { + color: var(--text-muted); +} + +.search-page-form input[type="search"]:focus { + outline: none; + border-color: var(--ide-primary); + box-shadow: 0 0 0 3px rgba(19, 167, 138, 0.15); +} + +.search-page-form button[type="submit"] { + padding: 0.9rem 1.75rem; + background: var(--ide-primary); + color: #fff; + border: none; + border-radius: 8px; + font-weight: 600; + font-size: 0.95rem; + cursor: pointer; + font-family: inherit; + transition: background 0.2s ease, transform 0.15s ease; + white-space: nowrap; +} + +.search-page-form button[type="submit"]:hover { + background: var(--ide-accent, #13A78A); + transform: translateY(-1px); +} + +.search-page-form button[type="submit"]:active { + transform: translateY(0); +} + +/* Status debajo del form */ +.search-hint { + font-size: 0.88rem; + color: var(--text-muted); + margin-top: 1rem; + min-height: 1.25rem; +} + +/* Sección de resultados */ +.search-results-section { + padding: 2.5rem 0 4rem; +} + +.search-results-list { + display: flex; + flex-direction: column; + gap: 1rem; + max-width: 800px; + margin: 0 auto; +} + +.search-result-item { + padding: 1.25rem 1.5rem; + border: 1px solid var(--border-color); + border-radius: 12px; + background: var(--bg-surface); + transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease; +} + +.search-result-item:hover { + border-color: var(--ide-primary); + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.06); + transform: translateY(-2px); +} + +.search-result-item .result-category { + font-size: 0.72rem; + font-weight: 700; + color: var(--ide-accent); + text-transform: uppercase; + letter-spacing: 0.08em; + margin-bottom: 0.4rem; +} + +.search-result-item h3 { + font-size: 1.15rem; + margin-bottom: 0.5rem; + line-height: 1.35; +} + +.search-result-item h3 a { + color: var(--ide-primary); + text-decoration: none; + transition: color 0.15s ease; +} + +.search-result-item h3 a:hover { + color: var(--ide-accent); + text-decoration: underline; +} + +.search-result-item p { + font-size: 0.9rem; + color: var(--text-muted); + line-height: 1.55; + margin: 0; +} + +.search-result-item mark { + background: rgba(19, 167, 138, 0.18); + color: var(--ide-primary); + font-weight: 600; + padding: 0 3px; + border-radius: 3px; +} + +/* Estado vacío */ +.search-empty { + text-align: center; + padding: 3.5rem 1rem; + color: var(--text-muted); + max-width: 480px; + margin: 0 auto; +} + +.search-empty svg { + opacity: 0.35; + margin-bottom: 1rem; + color: var(--ide-primary); +} + +.search-empty p { + font-size: 1rem; + line-height: 1.6; + margin-bottom: 0.5rem; +} + +.search-empty p strong { + color: var(--text-body); +} + +.search-empty .search-hint { + font-size: 0.88rem; +} + +.search-empty a { + color: var(--ide-primary); + text-decoration: underline; + text-underline-offset: 2px; +} + +/* Chips de sugerencias */ +.search-suggestions { + margin-top: 2rem; + text-align: center; + max-width: 640px; + margin-left: auto; + margin-right: auto; +} + +.search-suggestions strong { + display: block; + margin-bottom: 0.85rem; + font-size: 0.85rem; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.08em; + font-weight: 700; +} + +.search-suggestions .chip { + display: inline-block; + padding: 0.45rem 0.9rem; + margin: 0.2rem 0.25rem; + background: var(--bg-subtle); + border: 1px solid var(--border-color); + border-radius: 20px; + font-size: 0.85rem; + color: var(--text-body); + text-decoration: none; + transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease; +} + +.search-suggestions .chip:hover { + background: var(--ide-primary); + color: #fff; + border-color: var(--ide-primary); +} + +/* Responsive */ +@media (max-width: 600px) { + .search-hero { + padding: 2.5rem 0 1.75rem; + } + + .search-page-form { + flex-direction: column; + } + + .search-page-form button[type="submit"] { + width: 100%; + } +} \ No newline at end of file diff --git a/css/styles.css b/css/styles.css index 74af028..a8166e7 100644 --- a/css/styles.css +++ b/css/styles.css @@ -386,7 +386,7 @@ p, li, .lead-text, .text-body, .text-muted { } /* Switch de Tema Claro/Oscuro */ -.theme-toggle-btn { +.theme-toggle-btn, .search-link-btn { background: var(--bg-subtle); border: 1px solid var(--border-color); color: var(--text-main); @@ -400,7 +400,7 @@ p, li, .lead-text, .text-body, .text-muted { transition: all var(--transition-fast); } -.theme-toggle-btn:hover { +.theme-toggle-btn:hover, .search-link-btn:hover { background: var(--border-color); color: var(--ide-accent); } @@ -2625,6 +2625,7 @@ canvas { transform: translateY(-3px); } + /* Responsive */ @media (max-width: 768px) { .footer-bg { diff --git a/data/search-index.json b/data/search-index.json new file mode 100644 index 0000000..231bd84 --- /dev/null +++ b/data/search-index.json @@ -0,0 +1,86 @@ +[ + { + "url": "index.html", + "titulo": "Inicio — IDE Consultores", + "categoria": "Institucional", + "descripcion": "Consultoría en gestión, dirección de proyectos, formación y transformación digital con enfoque sistémico.", + "keywords": "inicio home consultora ide sistemas gestion transformacion" + }, + { + "url": "desafios.html", + "titulo": "Punto de Partida — Desafíos", + "categoria": "Diagnóstico", + "descripcion": "Identificá tu desafío actual: gestión, proyectos, equipos o tecnología. La Brújula IDE te orienta.", + "keywords": "desafios problemas diagnostico punto de partida brujula orientacion quiz" + }, + { + "url": "soluciones.html", + "titulo": "Soluciones — Excellence, Project, Digital", + "categoria": "Servicios", + "descripcion": "Soluciones en sistemas de gestión, dirección de proyectos y transformación digital adaptadas a tu contexto.", + "keywords": "soluciones iso 9001 14001 45001 pmo proyectos transformacion digital ia inteligencia artificial" + }, + { + "url": "ecosistema.html", + "titulo": "Ecosistema IDE", + "categoria": "Institucional", + "descripcion": "Excellence, Projects, Digital y Academy: un ecosistema integrado al servicio de la gestión organizacional.", + "keywords": "ecosistema excellence projects digital academy integrado" + }, + { + "url": "ecosistema.html#excellence", + "titulo": "IDE Excellence — Sistemas de gestión y calidad", + "categoria": "Ecosistema", + "descripcion": "Implementación y acompañamiento de sistemas de gestión ISO, mejora de procesos y auditorías con valor.", + "keywords": "excellence calidad iso 9001 14001 45001 sistemas gestion auditoria procesos mejora continua" + }, + { + "url": "ecosistema.html#projects", + "titulo": "IDE Project — Dirección y gestión de proyectos", + "categoria": "Ecosistema", + "descripcion": "Diseño de PMOs y acompañamiento en la dirección de proyectos para entregar en tiempo, costo y resultados.", + "keywords": "project proyectos pmo direccion gestion pmbok agile metodologias" + }, + { + "url": "ecosistema.html#digital", + "titulo": "IDE Digital — Transformación digital e IA aplicada", + "categoria": "Ecosistema", + "descripcion": "Identificamos dónde la IA y la digitalización generan valor real y acompañamos su implementación.", + "keywords": "digital transformacion ia inteligencia artificial automatizacion tecnologia innovacion" + }, + { + "url": "ide-academy.html", + "titulo": "IDE Academy — Formación profesional", + "categoria": "Formación", + "descripcion": "Programas de formación alineados con los desafíos reales de las organizaciones, en formato abierto o In-Company.", + "keywords": "academy cursos formacion capacitacion in company liderazgo calidad proyectos ia" + }, + { + "url": "somos-ide.html", + "titulo": "Somos IDE", + "categoria": "Institucional", + "descripcion": "Quiénes somos, nuestra mirada sistémica y el equipo que hace posible IDE Consultores.", + "keywords": "nosotros equipo consultores historia valores mirada sistemica" + }, + { + "url": "insights.html", + "titulo": "Insights — Artículos y perspectivas", + "categoria": "Blog", + "descripcion": "Artículos sobre gestión, calidad, proyectos y transformación digital para ampliar la mirada sobre la organización.", + "keywords": "insights blog articulos notas pensamiento sistemico perspectivas conocimiento" + }, + { + "url": "trabaja-con-nosotros.html", + "titulo": "Trabajá con nosotros", + "categoria": "Institucional", + "descripcion": "Sumate al equipo de IDE Consultores. Buscamos perfiles con mirada sistémica y vocación de transformación.", + "keywords": "empleo trabajo postulacion busqueda laboral cv curriculum sumate" + }, + { + "url": "contacto.html", + "titulo": "Contacto comercial", + "categoria": "Contacto", + "descripcion": "Coordinemos una primera conversación para entender tu contexto y encontrar el mejor punto de partida.", + "keywords": "contacto hablar consulta cotizacion presupuesto conversacion" + } +] \ No newline at end of file diff --git a/ecosistema.html b/ecosistema.html index 1c2099f..51c6ad9 100644 --- a/ecosistema.html +++ b/ecosistema.html @@ -603,7 +603,7 @@ - + diff --git a/includes/header.html b/includes/header.html index 6960338..d32a819 100644 --- a/includes/header.html +++ b/includes/header.html @@ -14,21 +14,38 @@ - + - + \ No newline at end of file diff --git a/js/main.js b/js/main.js index 74e9070..3d62497 100644 --- a/js/main.js +++ b/js/main.js @@ -17,6 +17,7 @@ async function initApp() { initInsightsFeed(); initContactForm(); initCareersForm(); + initSearchPage(); } /* ========================================================================== @@ -1177,3 +1178,167 @@ function initHeroVideoSpeed() { video.playbackRate = targetSpeed; }); } +/* ========================================================================== + 12. BÚSQUEDA EN EL SITIO — Página buscar.html + ========================================================================== */ +const SEARCH_INDEX_URL = 'data/search-index.json'; + +async function initSearchPage() { + const form = document.getElementById('search-page-form'); + const input = document.getElementById('search-page-input'); + const results = document.getElementById('search-results'); + const status = document.getElementById('search-status'); + const suggestions = document.getElementById('search-suggestions'); + + // Si no estamos en buscar.html, salir silenciosamente + if (!form || !input || !results) return; + + let index = []; + + // Cargar índice + try { + const res = await fetch(SEARCH_INDEX_URL, { cache: 'no-store' }); + if (res.ok) { + index = await res.json(); + } else { + console.warn('No se pudo cargar search-index.json:', res.status); + } + } catch (e) { + console.warn('Error cargando search-index.json:', e); + } + + // Normalizar texto (sin acentos, minúsculas) + const normalize = (str) => + (str || '') + .toString() + .toLowerCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, ''); + + // Escapar HTML + const escapeHtml = (str) => + (str || '').replace(/[&<>"']/g, (c) => ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + }[c])); + + // Scoring por relevancia + function scoreItem(item, terms) { + const title = normalize(item.titulo); + const desc = normalize(item.descripcion); + const kw = normalize(item.keywords); + let score = 0; + + terms.forEach(term => { + if (!term) return; + if (title.includes(term)) score += 10; + if (kw.includes(term)) score += 6; + if (desc.includes(term)) score += 3; + }); + + // Bonus si la frase completa aparece en el título + const fullPhrase = terms.join(' '); + if (fullPhrase && title.includes(fullPhrase)) score += 5; + + return score; + } + + // Resaltar coincidencias + function highlight(text, terms) { + let safe = escapeHtml(text); + terms.forEach(term => { + if (term.length < 2) return; + const escaped = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const regex = new RegExp(`(${escaped})`, 'gi'); + safe = safe.replace(regex, '$1'); + }); + return safe; + } + + // Render de resultados + function render(term) { + results.innerHTML = ''; + const cleanTerm = (term || '').trim(); + + if (!cleanTerm) { + if (status) status.textContent = ''; + if (suggestions) suggestions.hidden = false; + return; + } + + if (suggestions) suggestions.hidden = true; + + const terms = normalize(cleanTerm).split(/\s+/).filter(Boolean); + + const scored = index + .map(item => ({ item, score: scoreItem(item, terms) })) + .filter(r => r.score > 0) + .sort((a, b) => b.score - a.score); + + if (scored.length === 0) { + if (status) status.textContent = ''; + results.innerHTML = ` +
+ + + + + +

No encontramos resultados para ${escapeHtml(cleanTerm)}.

+

+ Probá con otros términos o contactanos directamente. +

+
+ `; + return; + } + + if (status) { + status.textContent = `${scored.length} resultado${scored.length !== 1 ? 's' : ''} para "${cleanTerm}"`; + } + + results.innerHTML = scored.map(({ item }) => ` +
+
${escapeHtml(item.categoria)}
+

${highlight(item.titulo, terms)}

+

${highlight(item.descripcion, terms)}

+
+ `).join(''); + } + + // Leer ?q= de la URL al cargar + const params = new URLSearchParams(window.location.search); + const initialQuery = params.get('q') || ''; + + if (initialQuery) { + input.value = initialQuery; + render(initialQuery); + } else { + if (suggestions) suggestions.hidden = false; + // Foco automático al llegar sin query + setTimeout(() => input.focus(), 100); + } + + // Submit del form + form.addEventListener('submit', (e) => { + e.preventDefault(); + const q = input.value.trim(); + const newUrl = q + ? `?q=${encodeURIComponent(q)}` + : window.location.pathname; + window.history.replaceState({}, '', newUrl); + render(q); + }); + + // Búsqueda en vivo con debounce + let debounceTimer; + input.addEventListener('input', () => { + clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + render(input.value); + }, 220); + }); +} diff --git a/js/particles.js b/js/particles.js deleted file mode 100644 index 137d56e..0000000 --- a/js/particles.js +++ /dev/null @@ -1,302 +0,0 @@ -/** - * 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); - } - } -})(); \ No newline at end of file diff --git a/js/ripple.js b/js/ripple.js deleted file mode 100644 index 31ddda8..0000000 --- a/js/ripple.js +++ /dev/null @@ -1,164 +0,0 @@ -/** - * Ripple Waves Background - Versión con Distorsión de Ruido Topográfico - * Reemplaza o actualiza tu archivo ripple-bg.js - */ -(() => { - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', initRipple); - } else { - initRipple(); - } - - function initRipple() { - const canvas = document.getElementById('ripple-canvas'); - if (!canvas) return; - - const ctx = canvas.getContext('2d'); - const parent = canvas.parentElement; - - // ========================================================================= - // PARÁMETROS CONFIGURABLES - // ========================================================================= - const CONFIG = { - spacing: 36, // Separación entre anillos - speed: 0.5, // Velocidad de expansión hacia afuera - lineWidth: 2, // Grosor de las líneas - maxAlpha: 0.42, // Opacidad máxima - centerOffsetX: 0.5, // Centro horizontal (50%) - centerOffsetY: 0.2, // Centro vertical (alineado con el h2) - - // --- PARÁMETROS DE RUIDO / DISTORSIÓN --- - distortionStrength: 14, // Qué tanto se deforma el círculo (en px) - distortionFrequency: 3, // Cantidad de ondulaciones en el perímetro - evolutionSpeed: 0.00001, // Velocidad con la que muta la forma de las ondas - segmentsPerRing: 25, // Calidad/resolución del contorno (70 a 100) - - cssVar: '--ripple-color', // Variable de tu CSS - fallbackColor: 'rgba(24, 123, 141, 0.4)' - }; - - let width = 0; - let height = 0; - let dpr = 1; - let maxRadius = 0; - let offset = 0; - let time = 0; - let currentColor = CONFIG.fallbackColor; - - // --- Leer variable de color desde CSS --- - function updateThemeColor() { - const color = getComputedStyle(document.documentElement) - .getPropertyValue(CONFIG.cssVar) - .trim(); - currentColor = color || CONFIG.fallbackColor; - } - - updateThemeColor(); - - const observer = new MutationObserver(updateThemeColor); - observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'data-theme'] }); - observer.observe(document.body, { attributes: true, attributeFilter: ['class', 'data-theme'] }); - if (window.matchMedia) { - window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', updateThemeColor); - } - - // --- Adaptación de resolución Retina / HiDPI --- - function resize() { - dpr = Math.min(window.devicePixelRatio || 1, 2); - width = parent.clientWidth; - height = parent.clientHeight; - - canvas.width = width * dpr; - canvas.height = height * dpr; - ctx.scale(dpr, dpr); - - const cx = width * CONFIG.centerOffsetX; - const cy = height * CONFIG.centerOffsetY; - const dx = Math.max(cx, width - cx); - const dy = Math.max(cy, height - cy); - maxRadius = Math.sqrt(dx * dx + dy * dy) + CONFIG.spacing * 2; - } - - resize(); - - if (window.ResizeObserver) { - new ResizeObserver(resize).observe(parent); - } else { - window.addEventListener('resize', resize); - } - - // --- Función de Pseudo-Ruido Armónico --- - // Combina ondas senoidales a distintas frecuencias para simular turbulencia/ruido suave - function getNoiseDeformation(angle, baseRadius, t) { - const f = CONFIG.distortionFrequency; - const w1 = Math.sin(angle * f + t + baseRadius * 0.015); - const w2 = Math.cos(angle * (f + 2) - t * 0.8 + baseRadius * 0.02); - const w3 = Math.sin(angle * 2 + t * 0.4); - - // Crece ligeramente a medida que el anillo viaja hacia afuera - const growthFactor = 0.5 + (baseRadius / maxRadius) * 0.5; - - return (w1 * 0.55 + w2 * 0.35 + w3 * 0.1) * CONFIG.distortionStrength * growthFactor; - } - - // --- Loop de Animación --- - function render() { - ctx.clearRect(0, 0, width, height); - - const cx = width * CONFIG.centerOffsetX; - const cy = height * CONFIG.centerOffsetY; - - offset = (offset + CONFIG.speed) % CONFIG.spacing; - time += CONFIG.evolutionSpeed * 1000; - - ctx.save(); - ctx.strokeStyle = currentColor; - ctx.lineWidth = CONFIG.lineWidth; - - const angleStep = (Math.PI * 2) / CONFIG.segmentsPerRing; - - // Dibujar cada anillo deformado - for (let r = offset; r < maxRadius; r += CONFIG.spacing) { - if (r <= 2) continue; - - const progress = r / maxRadius; - - // Desvanecimiento suave en centro y bordes - let alpha = 1; - if (progress < 0.12) { - alpha = progress / 0.12; - } else { - alpha = Math.cos(((progress - 0.12) / 0.88) * (Math.PI / 2)); - } - - ctx.globalAlpha = Math.max(0, Math.min(1, alpha * CONFIG.maxAlpha)); - - ctx.beginPath(); - - // Trazar el contorno deformado punto a punto - for (let i = 0; i <= CONFIG.segmentsPerRing; i++) { - const angle = i * angleStep; - const deformation = getNoiseDeformation(angle, r, time); - const currentRadius = Math.max(0, r + deformation); - - const px = cx + Math.cos(angle) * currentRadius; - const py = cy + Math.sin(angle) * currentRadius; - - if (i === 0) { - ctx.moveTo(px, py); - } else { - ctx.lineTo(px, py); - } - } - - ctx.closePath(); - ctx.stroke(); - } - - ctx.restore(); - requestAnimationFrame(render); - } - - requestAnimationFrame(render); - } -})(); \ No newline at end of file diff --git a/js/snowball-particles.js b/js/snowball-particles.js deleted file mode 100644 index 2eb4ed0..0000000 --- a/js/snowball-particles.js +++ /dev/null @@ -1,402 +0,0 @@ -/** - * SnowballBackground - * Círculos huecos flotantes que se fusionan en el mouse aumentando su radio - * y se desintegran expulsando todas las partículas. - * - * Uso: - * 1) Automático: agregá la clase .snowball-bg a cualquier contenedor - * 2) Manual: new SnowballBackground({ containerId: 'mi-id' }) - * - * Tema: lee variables CSS del contenedor o :root y reacciona a cambios de tema. - */ -class SnowballBackground { - constructor(options = {}) { - // --- Resolver contenedor (acepta elemento, ID o selector) --- - if (options.container instanceof HTMLElement) { - this.container = options.container; - } else if (options.containerId) { - this.container = document.getElementById(options.containerId); - } else if (options.containerSelector) { - this.container = document.querySelector(options.containerSelector); - } - - if (!this.container) { - console.warn('SnowballBackground: no se encontró el contenedor.', options); - return; - } - - // Evitar doble inicialización en el mismo contenedor - if (this.container._snowballInstance) { - return; - } - this.container._snowballInstance = this; - - // --- Configuración (defaults, se sobreescriben con CSS vars y options) --- - this.config = { - particleCount: 70, - maxCapacity: 20, - cooldownSeconds: 2.5, - color: '#38bdf8', - lineWidth: 1.5, - minRadius: 5, - maxRadius: 10 - }; - - // Leer variables CSS antes de aplicar options (options tienen prioridad) - this.syncThemeColors(); - - // Aplicar overrides manuales - if (options.particleCount) this.config.particleCount = options.particleCount; - if (options.maxCapacity) this.config.maxCapacity = options.maxCapacity; - if (options.cooldownSeconds) this.config.cooldownSeconds = options.cooldownSeconds; - if (options.color) this.config.color = options.color; - if (options.lineWidth) this.config.lineWidth = options.lineWidth; - if (options.minRadius) this.config.minRadius = options.minRadius; - if (options.maxRadius) this.config.maxRadius = options.maxRadius; - - // --- Estados internos --- - this.particles = []; - this.absorbedParticles = []; - this.disintegrationShards = []; - this.mouse = { x: -1000, y: -1000, active: false }; - this.isCoolingDown = false; - this.cooldownEndTime = 0; - this.currentBallRadius = 0; - - // --- Init --- - this.initCanvas(); - this.initParticles(); - this.bindEvents(); - this.observeThemeChanges(); - this.loop(); - } - - /* ============================================================= - * LECTURA DE VARIABLES CSS - * =========================================================== */ - readCSSVar(name) { - // Primero busca en el contenedor, luego en :root - let value = getComputedStyle(this.container).getPropertyValue(name).trim(); - if (!value) { - value = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); - } - return value || null; - } - - syncThemeColors() { - const map = { - '--snowball-color': { key: 'color', parse: String }, - '--snowball-particle-count': { key: 'particleCount', parse: parseInt }, - '--snowball-max-capacity': { key: 'maxCapacity', parse: parseInt }, - '--snowball-cooldown': { key: 'cooldownSeconds',parse: parseFloat }, - '--snowball-line-width': { key: 'lineWidth', parse: parseFloat }, - '--snowball-min-radius': { key: 'minRadius', parse: parseFloat }, - '--snowball-max-radius': { key: 'maxRadius', parse: parseFloat } - }; - - for (const [cssVar, { key, parse }] of Object.entries(map)) { - const raw = this.readCSSVar(cssVar); - if (raw !== null) { - const parsed = parse(raw, 10); - // Solo aplicar si el parseo dio un valor válido - if (typeof parsed === 'string' ? parsed : !isNaN(parsed)) { - this.config[key] = parsed; - } - } - } - } - - /* ============================================================= - * OBSERVADOR DE CAMBIOS DE TEMA - * =========================================================== */ - observeThemeChanges() { - this._themeObserver = new MutationObserver(() => { - this.syncThemeColors(); - }); - - // Observar (cambios de data-theme, class, style) - this._themeObserver.observe(document.documentElement, { - attributes: true, - attributeFilter: ['data-theme', 'class', 'style'] - }); - - // Observar también (algunos frameworks ponen el tema ahí) - this._themeObserver.observe(document.body, { - attributes: true, - attributeFilter: ['data-theme', 'class', 'style'] - }); - } - - /* ============================================================= - * CANVAS - * =========================================================== */ - initCanvas() { - this.canvas = document.createElement('canvas'); - this.canvas.className = 'snowball-canvas'; - this.canvas.style.cssText = - 'position:absolute;inset:0;width:100%;height:100%;pointer-events:none;z-index:0;'; - this.ctx = this.canvas.getContext('2d'); - - // El contenedor debe ser posicionable - const pos = getComputedStyle(this.container).position; - if (pos === 'static') { - this.container.style.position = 'relative'; - } - - this.container.appendChild(this.canvas); - this.resize(); - } - - resize() { - const rect = this.container.getBoundingClientRect(); - this.width = rect.width; - this.height = rect.height; - const dpr = window.devicePixelRatio || 1; - this.canvas.width = this.width * dpr; - this.canvas.height = this.height * dpr; - this.ctx.scale(dpr, dpr); - } - - /* ============================================================= - * PARTÍCULAS - * =========================================================== */ - initParticles() { - this.particles = []; - for (let i = 0; i < this.config.particleCount; i++) { - const radius = this.randomRange(this.config.minRadius, this.config.maxRadius); - this.particles.push({ - x: this.randomRange(radius, Math.max(radius + 1, this.width - radius)), - y: this.randomRange(radius, Math.max(radius + 1, this.height - radius)), - vx: (Math.random() - 0.5) * 1.6, - vy: (Math.random() - 0.5) * 1.6, - radius, - state: 'FREE' - }); - } - } - - bindEvents() { - this._onResize = () => this.resize(); - window.addEventListener('resize', this._onResize); - - this._onMouseMove = (e) => { - const rect = this.container.getBoundingClientRect(); - this.mouse.x = e.clientX - rect.left; - this.mouse.y = e.clientY - rect.top; - this.mouse.active = true; - }; - this.container.addEventListener('mousemove', this._onMouseMove); - - this._onMouseLeave = () => { this.mouse.active = false; }; - this.container.addEventListener('mouseleave', this._onMouseLeave); - } - - randomRange(min, max) { - return Math.random() * (max - min) + min; - } - - /* ============================================================= - * CONVERSIÓN DE COLOR UNIVERSAL - * =========================================================== */ - toRgba(color, alpha = 1) { - if (!color) return `rgba(0,0,0,${alpha})`; - const c = color.trim(); - - // rgba(...) o rgb(...) - if (c.startsWith('rgb')) { - const parts = c.match(/[\d.]+/g); - if (!parts || parts.length < 3) return `rgba(0,0,0,${alpha})`; - const [r, g, b] = parts; - const origA = parts[3] !== undefined ? parseFloat(parts[3]) : 1; - return `rgba(${r},${g},${b},${(origA * alpha).toFixed(3)})`; - } - - // #hex (3, 4, 6 u 8 dígitos) - if (c.startsWith('#')) { - let hex = c.slice(1); - if (hex.length === 3 || hex.length === 4) { - hex = hex.split('').map(ch => ch + ch).join(''); - } - const n = parseInt(hex.slice(0, 6), 16); - const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255; - let hexA = 1; - if (hex.length === 8) hexA = parseInt(hex.slice(6, 8), 16) / 255; - return `rgba(${r},${g},${b},${(hexA * alpha).toFixed(3)})`; - } - - // Fallback: dejar que el navegador resuelva (hsl, nombres, etc.) - try { - const tmp = document.createElement('canvas').getContext('2d'); - tmp.fillStyle = c; - const resolved = tmp.fillStyle; - if (resolved !== c) return this.toRgba(resolved, alpha); - } catch (_) {} - - return `rgba(0,0,0,${alpha})`; - } - - /* ============================================================= - * EXPLOSIÓN - * =========================================================== */ - explode() { - const ex = this.mouse.x, ey = this.mouse.y; - const br = this.currentBallRadius; - const count = this.absorbedParticles.length; - - // Fragmentos de desintegración - const shardCount = 8; - const arcStep = (Math.PI * 2) / shardCount; - for (let i = 0; i < shardCount; i++) { - const angle = i * arcStep; - this.disintegrationShards.push({ - x: ex, y: ey, radius: br, - startAngle: angle + 0.1, - endAngle: angle + arcStep - 0.1, - vx: Math.cos(angle + arcStep / 2) * 3, - vy: Math.sin(angle + arcStep / 2) * 3, - alpha: 1 - }); - } - - // Dispersar partículas - this.absorbedParticles.forEach((p, i) => { - const angle = (Math.PI * 2 / count) * i + (Math.random() - 0.5) * 0.3; - const force = this.randomRange(10, 18); - p.x = ex + Math.cos(angle) * (br * 0.8); - p.y = ey + Math.sin(angle) * (br * 0.8); - p.vx = Math.cos(angle) * force; - p.vy = Math.sin(angle) * force; - p.state = 'EXPLODED'; - }); - - this.absorbedParticles = []; - this.currentBallRadius = 0; - this.isCoolingDown = true; - this.cooldownEndTime = performance.now() + this.config.cooldownSeconds * 1000; - } - - /* ============================================================= - * UPDATE - * =========================================================== */ - update() { - const now = performance.now(); - if (this.isCoolingDown && now >= this.cooldownEndTime) { - this.isCoolingDown = false; - } - - let targetRadius = 0; - if (this.absorbedParticles.length > 0) { - targetRadius = 14 + (this.absorbedParticles.length / this.config.maxCapacity) * 46; - } - this.currentBallRadius += (targetRadius - this.currentBallRadius) * 0.15; - - const hitRadius = this.absorbedParticles.length === 0 ? 14 : this.currentBallRadius; - - this.particles.forEach((p) => { - if (p.state === 'FREE') { - p.x += p.vx; - p.y += p.vy; - if (p.x - p.radius <= 0) { p.x = p.radius; p.vx *= -1; } - if (p.x + p.radius >= this.width) { p.x = this.width - p.radius; p.vx *= -1; } - if (p.y - p.radius <= 0) { p.y = p.radius; p.vy *= -1; } - if (p.y + p.radius >= this.height) { p.y = this.height - p.radius; p.vy *= -1; } - - if (this.mouse.active && !this.isCoolingDown) { - const dx = p.x - this.mouse.x, dy = p.y - this.mouse.y; - if (Math.sqrt(dx * dx + dy * dy) < hitRadius + p.radius) { - p.state = 'ABSORBED'; - this.absorbedParticles.push(p); - if (this.absorbedParticles.length >= this.config.maxCapacity) { - this.explode(); - } - } - } - } else if (p.state === 'EXPLODED') { - p.x += p.vx; p.y += p.vy; - p.vx *= 0.96; p.vy *= 0.96; - if (p.x - p.radius <= 0) { p.x = p.radius; p.vx *= -1; } - if (p.x + p.radius >= this.width) { p.x = this.width - p.radius; p.vx *= -1; } - if (p.y - p.radius <= 0) { p.y = p.radius; p.vy *= -1; } - if (p.y + p.radius >= this.height) { p.y = this.height - p.radius; p.vy *= -1; } - if (Math.sqrt(p.vx * p.vx + p.vy * p.vy) < 1.6) p.state = 'FREE'; - } - }); - - for (let i = this.disintegrationShards.length - 1; i >= 0; i--) { - const s = this.disintegrationShards[i]; - s.x += s.vx; s.y += s.vy; - s.radius += 0.8; - s.alpha -= 0.05; - if (s.alpha <= 0) this.disintegrationShards.splice(i, 1); - } - } - - /* ============================================================= - * DRAW (todos los colores pasan por toRgba) - * =========================================================== */ - draw() { - this.ctx.clearRect(0, 0, this.width, this.height); - const color = this.config.color; - const lw = this.config.lineWidth; - - // 1. Partículas libres / explotadas - this.ctx.lineWidth = lw; - this.ctx.strokeStyle = this.toRgba(color, 1); - this.particles.forEach((p) => { - if (p.state !== 'ABSORBED') { - this.ctx.beginPath(); - this.ctx.arc(p.x, p.y, p.radius, 0, Math.PI * 2); - this.ctx.stroke(); - } - }); - - // 2. Fragmentos de desintegración - this.disintegrationShards.forEach((s) => { - this.ctx.save(); - this.ctx.lineWidth = lw + 0.5; - this.ctx.strokeStyle = this.toRgba(color, s.alpha); - this.ctx.beginPath(); - this.ctx.arc(s.x, s.y, s.radius, s.startAngle, s.endAngle); - this.ctx.stroke(); - this.ctx.restore(); - }); - - // 3. Bola en el mouse - if (this.mouse.active && !this.isCoolingDown && this.currentBallRadius > 2) { - this.ctx.save(); - this.ctx.lineWidth = lw + 0.5; - this.ctx.strokeStyle = this.toRgba(color, 1); - this.ctx.beginPath(); - this.ctx.arc(this.mouse.x, this.mouse.y, this.currentBallRadius, 0, Math.PI * 2); - this.ctx.stroke(); - this.ctx.restore(); - } - } - - /* ============================================================= - * LOOP - * =========================================================== */ - loop() { - this.update(); - this.draw(); - requestAnimationFrame(() => this.loop()); - } -} - -/* ============================================================== - * AUTO-INICIALIZACIÓN - * Busca todos los elementos con clase .snowball-bg - * ============================================================== */ -function initSnowballBackgrounds() { - document.querySelectorAll('.snowball-bg').forEach((el) => { - if (!el._snowballInstance) { - new SnowballBackground({ container: el }); - } - }); -} - -if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', initSnowballBackgrounds); -} else { - initSnowballBackgrounds(); -} \ No newline at end of file diff --git a/modal-test.html b/modal-test.html deleted file mode 100644 index 2b39e78..0000000 --- a/modal-test.html +++ /dev/null @@ -1,142 +0,0 @@ - - - - - - Diagrama de Red y Modal - - - - - -
- -
- - -
- - - - -
- - - - - - - \ No newline at end of file diff --git a/script.js b/script.js deleted file mode 100644 index 248a0b9..0000000 --- a/script.js +++ /dev/null @@ -1,73 +0,0 @@ -/* ========================================================================== - CONTROLADOR DEL MODAL - ========================================================================== */ -function openModal(modalId) { - const modal = document.getElementById(modalId); - if (!modal) return; - modal.classList.add("is-active"); - document.body.style.overflow = "hidden"; - - const scrollArea = modal.querySelector(".ide-modal-scroll"); - if (scrollArea) scrollArea.scrollTop = 0; -} - -function closeModal(modalId) { - const modal = document.getElementById(modalId); - if (!modal) return; - modal.classList.remove("is-active"); - document.body.style.overflow = ""; -} - -// Inicialización de eventos del modal una vez cargado el DOM -document.addEventListener("DOMContentLoaded", () => { - // 1. Botón externo HTML - const btnTrigger = document.getElementById("btnOpenProjectsModal"); - if (btnTrigger) { - btnTrigger.addEventListener("click", () => { - openModal("modal-ide-projects"); - }); - } - - // 2. Botón Cerrar (X) - const btnClose = document.getElementById("btnCloseModal"); - if (btnClose) { - btnClose.addEventListener("click", () => { - closeModal("modal-ide-projects"); - }); - } - - // 3. Cerrar al hacer clic en el backdrop oscuro - const modal = document.getElementById("modal-ide-projects"); - if (modal) { - modal.addEventListener("click", (e) => { - if (e.target === modal) { - closeModal("modal-ide-projects"); - } - }); - } - - // 4. Cerrar con tecla Escape - document.addEventListener("keydown", (e) => { - if (e.key === "Escape") { - closeModal("modal-ide-projects"); - } - }); - - // 5. Ocultar indicador "Desliza para leer más" al scrollear - const scrollArea = document.getElementById("modalScrollArea"); - const scrollHint = document.getElementById("scrollHint"); - if (scrollArea && scrollHint) { - scrollArea.addEventListener("scroll", () => { - const isAtBottom = scrollArea.scrollHeight - scrollArea.scrollTop - scrollArea.clientHeight < 40; - if (isAtBottom) { - scrollHint.classList.add("is-hidden"); - } else { - scrollHint.classList.remove("is-hidden"); - } - }); - } -}); - -// Inicialización del motor Canvas -//initNetwork(); -//requestAnimationFrame(animate); \ No newline at end of file