Spaces:
Paused
Paused
| <html lang="es"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"/> | |
| <title>Analizador de Temas 3D con Gemini</title> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@700&display=swap" rel="stylesheet"> | |
| <style> | |
| body { | |
| font-family: 'Orbitron', sans-serif; | |
| margin: 0; | |
| overflow: hidden; | |
| } | |
| #container { | |
| width: 100vw; | |
| height: 100vh; | |
| position: fixed; | |
| top: 0; | |
| left: 0; | |
| background-color: #111827; | |
| } | |
| canvas { | |
| display: block; | |
| } | |
| #ui { | |
| position: fixed; | |
| top: 20px; | |
| left: 20px; | |
| z-index: 100; | |
| background-color: rgba(31, 41, 55, 0.9); | |
| padding: 20px; | |
| border-radius: 12px; | |
| box-shadow: 0 4px 15px rgba(0, 0, 0, 0.4); | |
| max-width: 400px; | |
| color: white; | |
| height: calc(100vh - 40px); | |
| display: flex; | |
| flex-direction: column; | |
| } | |
| #tooltip { | |
| position: absolute; | |
| display: none; | |
| background: rgba(0, 0, 0, 0.8); | |
| color: white; | |
| padding: 8px 12px; | |
| border-radius: 6px; | |
| z-index: 101; | |
| pointer-events: none; | |
| font-size: 14px; | |
| } | |
| input[type="range"] { | |
| -webkit-appearance: none; | |
| appearance: none; | |
| width: 100%; | |
| height: 8px; | |
| background: #4b5563; | |
| border-radius: 5px; | |
| outline: none; | |
| opacity: 0.7; | |
| transition: opacity .2s; | |
| } | |
| input[type="range"]:hover { | |
| opacity: 1; | |
| } | |
| input[type="range"]::-webkit-slider-thumb { | |
| -webkit-appearance: none; | |
| appearance: none; | |
| width: 20px; | |
| height: 20px; | |
| background: #3b82f6; | |
| border-radius: 50%; | |
| cursor: pointer; | |
| } | |
| input[type="range"]::-moz-range-thumb { | |
| width: 20px; | |
| height: 20px; | |
| background: #3b82f6; | |
| border-radius: 50%; | |
| cursor: pointer; | |
| } | |
| #progressBarContainer { | |
| width: 100%; | |
| background-color: #374151; | |
| border-radius: 4px; | |
| overflow: hidden; | |
| display: none; | |
| height: 6px; | |
| margin-top: 12px; | |
| } | |
| #progressBar { | |
| width: 0%; | |
| height: 6px; | |
| background-color: #3b82f6; | |
| } | |
| #loginOverlay { | |
| transition: opacity 0.5s ease-in-out; | |
| display: flex; | |
| opacity: 0; | |
| } | |
| #toast { | |
| position: fixed; | |
| top: 20px; | |
| left: 50%; | |
| background-color: #22c55e; | |
| color: white; | |
| padding: 16px; | |
| border-radius: 8px; | |
| box-shadow: 0 4px 10px rgba(0,0,0,.3); | |
| z-index: 200; | |
| transform: translateY(-120%) translateX(-50%); | |
| transition: transform 0.5s ease-in-out; | |
| font-weight: bold; | |
| } | |
| #toast.show { | |
| transform: translateY(0) translateX(-50%); | |
| } | |
| #minimapContainer { | |
| width: 100%; | |
| height: 250px; | |
| margin-top: 16px; | |
| flex-shrink: 0; | |
| } | |
| #minimap { | |
| width: 100%; | |
| height: 100%; | |
| background-color: #1f2937; | |
| border-radius: 8px; | |
| border: 1px solid #4b5563; | |
| cursor: pointer; | |
| } | |
| /* main auth buttons inside the main UI header */ | |
| #mainAuthButtons { | |
| display: flex; | |
| gap: 8px; | |
| align-items: center; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <!-- Login modal / overlay --> | |
| <div id="loginOverlay" class="fixed inset-0 bg-gray-900 bg-opacity-75 backdrop-blur-sm z-[200] items-center justify-center transition-opacity duration-500"> | |
| <div id="loginModal" class="bg-gray-800 p-8 rounded-lg shadow-xl w-full max-w-sm"> | |
| <div id="loginForm"> | |
| <h2 class="text-2xl font-bold text-green-400 mb-4">Iniciar Sesión</h2> | |
| <p class="text-gray-300 mb-6" id="loginMessage">Por favor, inicia sesión o regístrate.</p> | |
| <input type="email" id="loginEmail" class="w-full p-3 rounded-lg bg-gray-700 text-white border border-gray-600 focus:outline-none focus:border-blue-500" placeholder="Correo electrónico" autocomplete="email"> | |
| <input type="password" id="loginPassword" class="w-full p-3 mt-3 rounded-lg bg-gray-700 text-white border border-gray-600 focus:outline-none focus:border-blue-500" placeholder="Contraseña" autocomplete="current-password"> | |
| <div class="flex justify-between items-center mt-4 text-sm"> | |
| <label class="flex items-center text-gray-400"> | |
| <input type="checkbox" id="showPasswordCheck" class="mr-2 rounded bg-gray-700 border-gray-600 text-blue-500 focus:ring-blue-500"> | |
| Mostrar contraseña | |
| </label> | |
| <label class="flex items-center text-gray-400"> | |
| <input type="checkbox" id="rememberMeCheck" class="mr-2 rounded bg-gray-700 border-gray-600 text-blue-500 focus:ring-blue-500" checked> | |
| Recordarme | |
| </label> | |
| </div> | |
| <div class="flex flex-col space-y-2 mt-6"> | |
| <button id="loginButton" class="w-full bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 px-4 rounded-lg transition duration-300">Entrar</button> | |
| <button id="registerButton" class="w-full bg-gray-600 hover:bg-gray-700 text-white font-bold py-3 px-4 rounded-lg transition duration-300">Registrar</button> | |
| <button id="googleLoginButton" class="w-full bg-red-600 hover:bg-red-700 text-white font-bold py-3 px-4 rounded-lg transition duration-300">Entrar con Google</button> | |
| <button id="anonymousLoginButton" class="w-full bg-gray-900 hover:bg-black text-blue-300 font-bold py-3 px-4 rounded-lg transition duration-300 border border-gray-700">Explorar como Anónimo</button> | |
| </div> | |
| </div> | |
| <div id="usernameForm" style="display: none;"> | |
| <h2 class="text-2xl font-bold text-green-400 mb-4">¡Bienvenido!</h2> | |
| <p class="text-gray-300 mb-6" id="usernameMessage">Elige un nombre de usuario público para continuar.</p> | |
| <input type="text" id="usernameInput" class="w-full p-3 rounded-lg bg-gray-700 text-white border border-gray-600 focus:outline-none focus:border-blue-500" placeholder="Elige un nombre de usuario"> | |
| <button id="saveUsernameButton" class="w-full mt-4 bg-green-600 hover:bg-green-700 text-white font-bold py-3 px-4 rounded-lg transition duration-300">Guardar y Entrar</button> | |
| </div> | |
| </div> | |
| </div> | |
| <div id="container"></div> | |
| <div id="tooltip" class="z-[101]"></div> | |
| <div id="ui"> | |
| <div class="flex items-center justify-between mb-4"> | |
| <h1 class="text-2xl font-bold text-white flex items-center space-x-2"> | |
| <svg class="w-6 h-6 text-green-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> | |
| <path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z" /> | |
| </svg> | |
| <span class="text-green-400">ECOTAGS</span> | |
| </h1> | |
| <div id="mainAuthButtons" class="flex items-center"> | |
| <button id="mainLoginButton" class="bg-blue-600 text-white px-3 py-1 rounded text-sm mr-2" title="Iniciar sesión">Iniciar sesión</button> | |
| <button id="mainLogoutButton" class="bg-red-600 text-white px-3 py-1 rounded text-sm mr-2" style="display:none" title="Cerrar sesión">Cerrar sesión</button> | |
| </div> | |
| </div> | |
| <input type="text" id="topicInput" class="w-full p-2 rounded-lg bg-gray-700 text-white border border-gray-600 focus:outline-none focus:border-blue-500 mb-4" placeholder="Escribe uno o mas hashtags"> | |
| <label for="level1Slider" class="text-sm text-gray-400">Nivel 1 (1-15): <span id="level1Value">10</span></label> | |
| <input type="range" id="level1Slider" min="1" max="15" value="10" class="w-full mb-2"> | |
| <label for="level2Slider" class="text-sm text-gray-400">Nivel 2 (5-8): <span id="level2Value">5</span></label> | |
| <input type="range" id="level2Slider" min="5" max="8" value="5" class="w-full mb-2"> | |
| <label for="level3Slider" class="text-sm text-gray-400">Nivel 3 (1-3): <span id="level3Value">3</span></label> | |
| <input type="range" id="level3Slider" min="1" max="3" value="3" class="w-full mb-4"> | |
| <details class="mb-3 bg-gray-700/50 rounded-md p-3 border border-gray-600"> | |
| <summary class="cursor-pointer text-sm text-blue-300 font-semibold">Configuración</summary> | |
| <div class="mt-3 space-y-2 text-sm"> | |
| <div> | |
| <label for="geminiKeyInput" class="block text-gray-300">Gemini API Key (guardada localmente)</label> | |
| <input type="password" id="geminiKeyInput" class="w-full p-2 rounded bg-gray-700 text-white border border-gray-600 focus:outline-none focus:border-blue-500" placeholder="AIza..."> | |
| </div> | |
| <div class="flex items-center gap-2"> | |
| <button id="saveGeminiKeyBtn" class="bg-gray-600 hover:bg-gray-500 px-3 py-1 rounded">Guardar clave</button> | |
| <span id="geminiKeyStatus" class="text-gray-400"></span> | |
| </div> | |
| <p class="text-gray-400">En un Space estático de Hugging Face, no hay backend: guarda tu clave localmente aquí para llamar a la API de Gemini desde el navegador. Si necesitas ocultar la clave, migra a un Space con backend (Gradio/Streamlit) y usa Secrets del Space.</p> | |
| </div> | |
| </details> | |
| <button id="visualizeButton" class="w-full bg-green-600 hover:bg-green-700 text-white font-bold py-2 px-4 rounded-lg transition duration-300 flex items-center justify-center space-x-2 mb-2"> | |
| <svg class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> | |
| <path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z" /> | |
| </svg> | |
| <span>Sembrar</span> | |
| </button> | |
| <div id="progressBarContainer"><div id="progressBar"></div></div> | |
| <div id="userListContainer" class="mt-4 flex flex-col" style="max-height:25vh;"> | |
| <h2 class="font-bold text-lg mb-2 text-blue-300">Galaxias de Usuarios</h2> | |
| <div id="userList" class="w-full overflow-y-auto text-gray-300 pr-2"></div> | |
| </div> | |
| <div id="minimapContainer"> | |
| <div class="flex justify-between items-center mb-2"> | |
| <h2 class="font-bold text-lg text-blue-300">Minimapa Galáctico</h2> | |
| <div class="flex space-x-1"> | |
| <button id="zoomOutButton" class="w-6 h-6 bg-gray-700 hover:bg-gray-600 rounded text-lg font-bold flex items-center justify-center">-</button> | |
| <button id="zoomInButton" class="w-6 h-6 bg-gray-700 hover:bg-gray-600 rounded text-lg font-bold flex items-center justify-center">+</button> | |
| </div> | |
| </div> | |
| <canvas id="minimap" width="360" height="200"></canvas> | |
| </div> | |
| </div> | |
| <script src="https://cdn.jsdelivr.net/npm/three@0.128.0/build/three.min.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/geometries/TextGeometry.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/loaders/FontLoader.js"></script> | |
| <script type="module"> | |
| import { initializeApp } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-app.js"; | |
| import { | |
| getAuth, | |
| onAuthStateChanged, | |
| createUserWithEmailAndPassword, | |
| signInWithEmailAndPassword, | |
| setPersistence, | |
| browserLocalPersistence, | |
| browserSessionPersistence, | |
| signInAnonymously, | |
| GoogleAuthProvider, | |
| signInWithPopup, | |
| signOut | |
| } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-auth.js"; | |
| import { | |
| getFirestore, | |
| doc, | |
| addDoc, | |
| onSnapshot, | |
| collection, | |
| setDoc, | |
| getDoc, | |
| query, | |
| setLogLevel | |
| } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-firestore.js"; | |
| // --- FIREBASE CONFIG (usar la API key del proyecto Firebase) --- | |
| const firebaseConfig = { | |
| apiKey: "AIzaSyB4EZKkAH3weFSZNBQA8Y63gt5XbaeZGsQ", | |
| authDomain: "neuronal-1f3b9.firebaseapp.com", | |
| projectId: "neuronal-1f3b9", | |
| storageBucket: "neuronal-1f3b9.firebasestorage.app", | |
| messagingSenderId: "208887839866", | |
| appId: "1:208887839866:web:adbb697dd0b63195b10fc3", | |
| measurementId: "G-102SEBLQFJ" | |
| }; | |
| // --- Gemini API Key helpers (local fallback) --- | |
| function getLocalGeminiKey() { | |
| try { return localStorage.getItem('GEMINI_API_KEY') || ''; } catch { return ''; } | |
| } | |
| function setLocalGeminiKey(k) { | |
| try { localStorage.setItem('GEMINI_API_KEY', k || ''); } catch {} | |
| } | |
| // Three.js aliases (for older included builds) | |
| const THREE = window.THREE; | |
| const OrbitControls = THREE.OrbitControls; | |
| const TextGeometry = THREE.TextGeometry; | |
| const FontLoader = THREE.FontLoader; | |
| // Scene variables | |
| let scene, camera, renderer, controls, raycaster, mouse; | |
| let hashtagGroup, tooltip; | |
| let font; | |
| let mapCount = 0; | |
| // Firebase / app state | |
| let db, auth; | |
| let userId = null; | |
| const appId = "neuronal-1f3b9"; | |
| let userProfile = null; | |
| let userMaps = {}; | |
| let isAuthReady = false; | |
| let isFontReady = false; | |
| let isAnonymous = false; | |
| let userProfileCache = {}; | |
| let allMapsDataCache = {}; | |
| const LOAD_DISTANCE = 30.0; | |
| const intersected = {}; | |
| let minimapCtx; | |
| let minimapDotCoords = []; | |
| let minimapScale = 0.025; | |
| const MINIMAP_DOT_SIZE = 2; | |
| // Detectar si estamos en un Space estático de Hugging Face | |
| const isHFStatic = /\.hf\.space$/.test(location.hostname); | |
| const normalizeString = (str) => { | |
| if (!str) return ""; | |
| return str.normalize("NFD").replace(/[\u0300-\u036f]/g, ""); | |
| }; | |
| const loader = new FontLoader(); | |
| loader.load( | |
| 'https://cdn.jsdelivr.net/npm/three@0.128.0/examples/fonts/helvetiker_regular.typeface.json', | |
| function (loadedFont) { | |
| font = loadedFont; | |
| isFontReady = true; | |
| tryStartApp(); | |
| }, | |
| undefined, | |
| function (err) { | |
| console.error('Error al cargar la fuente 3D:', err); | |
| } | |
| ); | |
| initFirebase(); | |
| // Setup config UI | |
| const geminiKeyInput = document.getElementById('geminiKeyInput'); | |
| const saveGeminiKeyBtn = document.getElementById('saveGeminiKeyBtn'); | |
| const geminiKeyStatus = document.getElementById('geminiKeyStatus'); | |
| if (geminiKeyInput && saveGeminiKeyBtn && geminiKeyStatus) { | |
| const existing = getLocalGeminiKey(); | |
| if (existing) geminiKeyInput.value = existing; | |
| saveGeminiKeyBtn.addEventListener('click', (e) => { | |
| e.preventDefault(); | |
| setLocalGeminiKey(geminiKeyInput.value.trim()); | |
| geminiKeyStatus.textContent = 'Clave guardada localmente'; | |
| setTimeout(() => { geminiKeyStatus.textContent = ''; }, 2000); | |
| }); | |
| } | |
| async function initFirebase() { | |
| try { | |
| const app = initializeApp(firebaseConfig); | |
| db = getFirestore(app); | |
| auth = getAuth(app); | |
| setLogLevel('Debug'); | |
| const loginMessage = document.getElementById('loginMessage'); | |
| const loginOverlay = document.getElementById('loginOverlay'); | |
| const loginForm = document.getElementById('loginForm'); | |
| const usernameForm = document.getElementById('usernameForm'); | |
| const loginEmail = document.getElementById('loginEmail'); | |
| const loginPassword = document.getElementById('loginPassword'); | |
| const showPasswordCheck = document.getElementById('showPasswordCheck'); | |
| const rememberMeCheck = document.getElementById('rememberMeCheck'); | |
| const loginButton = document.getElementById('loginButton'); | |
| const registerButton = document.getElementById('registerButton'); | |
| const saveUsernameButton = document.getElementById('saveUsernameButton'); | |
| const usernameInput = document.getElementById('usernameInput'); | |
| const usernameMessage = document.getElementById('usernameMessage'); | |
| const googleLoginButton = document.getElementById('googleLoginButton'); | |
| const anonymousLoginButton = document.getElementById('anonymousLoginButton'); | |
| const mainLoginButton = document.getElementById('mainLoginButton'); | |
| const mainLogoutButton = document.getElementById('mainLogoutButton'); | |
| // show/hide password | |
| showPasswordCheck.addEventListener('change', () => { | |
| loginPassword.type = showPasswordCheck.checked ? 'text' : 'password'; | |
| }); | |
| // register email/password | |
| registerButton.addEventListener('click', async () => { | |
| try { | |
| const email = loginEmail.value; | |
| const password = loginPassword.value; | |
| if (email.length < 6 || password.length < 6) { | |
| loginMessage.innerText = "Correo y contraseña deben tener al menos 6 caracteres."; | |
| loginMessage.classList.add('text-red-500'); | |
| return; | |
| } | |
| loginMessage.innerText = "Registrando..."; | |
| loginMessage.classList.remove('text-red-500'); | |
| await createUserWithEmailAndPassword(auth, email, password); | |
| } catch (error) { | |
| console.error("Error al registrar:", error); | |
| loginMessage.innerText = `Error: ${error.message}`; | |
| loginMessage.classList.add('text-red-500'); | |
| } | |
| }); | |
| // login email/password | |
| loginButton.addEventListener('click', async () => { | |
| try { | |
| const email = loginEmail.value; | |
| const password = loginPassword.value; | |
| const persistence = rememberMeCheck.checked ? browserLocalPersistence : browserSessionPersistence; | |
| loginMessage.innerText = "Iniciando sesión..."; | |
| loginMessage.classList.remove('text-red-500'); | |
| await setPersistence(auth, persistence); | |
| await signInWithEmailAndPassword(auth, email, password); | |
| } catch (error) { | |
| console.error("Error al iniciar sesión:", error); | |
| loginMessage.innerText = `Error: ${error.message}`; | |
| loginMessage.classList.add('text-red-500'); | |
| } | |
| }); | |
| // Google login (from modal) | |
| googleLoginButton.addEventListener('click', async () => { | |
| try { | |
| const provider = new GoogleAuthProvider(); | |
| await signInWithPopup(auth, provider); | |
| } catch (error) { | |
| console.error("Error con Google Sign-In:", error); | |
| if (loginMessage) { | |
| loginMessage.innerText = `Error con Google: ${error.message}`; | |
| loginMessage.classList.add('text-red-500'); | |
| } | |
| } | |
| }); | |
| // anonymous login (from modal) | |
| anonymousLoginButton.addEventListener('click', async () => { | |
| try { | |
| loginMessage.innerText = "Entrando como anónimo..."; | |
| loginMessage.classList.remove('text-red-500'); | |
| await signInAnonymously(auth); | |
| } catch (error) { | |
| console.error("Error al iniciar sesión anónima:", error); | |
| loginMessage.innerText = `Error: ${error.message}`; | |
| loginMessage.classList.add('text-red-500'); | |
| } | |
| }); | |
| // mainLoginButton (in UI header) -> open login modal for upgrade from anonymous or to login | |
| mainLoginButton.addEventListener('click', async () => { | |
| // show the login modal so user can choose Google or email | |
| loginOverlay.style.display = 'flex'; | |
| setTimeout(() => { loginOverlay.style.opacity = '1'; }, 10); | |
| }); | |
| // mainLogoutButton (in UI header) | |
| mainLogoutButton.addEventListener('click', async () => { | |
| try { | |
| await signOut(auth); | |
| } catch (err) { | |
| console.error('Error al cerrar sesión:', err); | |
| } | |
| }); | |
| // save username for new accounts (modal) | |
| saveUsernameButton.addEventListener('click', async () => { | |
| if (!userId) { | |
| usernameMessage.innerText = "Error, no se ha detectado usuario. Refresca la página."; | |
| usernameMessage.classList.add('text-red-500'); | |
| return; | |
| } | |
| const username = normalizeString(usernameInput.value.trim()); | |
| if (username.length < 3) { | |
| usernameMessage.innerText = "El nombre debe tener al menos 3 caracteres."; | |
| usernameMessage.classList.add('text-red-500'); | |
| return; | |
| } | |
| await saveUserProfile(userId, username); | |
| loginOverlay.style.opacity = '0'; | |
| setTimeout(() => { loginOverlay.style.display = 'none'; }, 500); | |
| initScene(); | |
| loadAllMaps(); | |
| }); | |
| // auth state changes | |
| onAuthStateChanged(auth, async (user) => { | |
| if (user) { | |
| console.log("Usuario autenticado:", user.uid); | |
| userId = user.uid; | |
| isAuthReady = true; | |
| isAnonymous = user.isAnonymous; | |
| if (user.isAnonymous) { | |
| console.log("Usuario es anónimo."); | |
| userProfile = { username: "Anónimo" }; | |
| // show the login button in UI header to allow upgrade | |
| mainLoginButton.style.display = 'inline-block'; | |
| mainLogoutButton.style.display = 'none'; | |
| tryStartApp(); | |
| } else { | |
| console.log("Usuario registrado."); | |
| isAnonymous = false; | |
| // try fetch profile; if none, show username form | |
| await fetchUserProfile(userId); | |
| mainLoginButton.style.display = 'none'; | |
| mainLogoutButton.style.display = 'inline-block'; | |
| tryStartApp(); | |
| } | |
| // hide login overlay if it's open | |
| loginOverlay.style.opacity = '0'; | |
| setTimeout(() => { loginOverlay.style.display = 'none'; }, 500); | |
| } else { | |
| console.log("Ningún usuario autenticado."); | |
| userId = null; | |
| isAuthReady = false; | |
| isAnonymous = false; | |
| userProfile = null; | |
| loginOverlay.style.display = 'flex'; | |
| loginOverlay.style.opacity = '1'; | |
| loginForm.style.display = 'block'; | |
| usernameForm.style.display = 'none'; | |
| loginMessage.innerText = "Por favor, inicia sesión o regístrate."; | |
| loginMessage.classList.remove('text-red-500'); | |
| mainLoginButton.style.display = 'inline-block'; | |
| mainLogoutButton.style.display = 'none'; | |
| } | |
| }); | |
| } catch (error) { | |
| console.error("Error inicializando Firebase:", error); | |
| const loginMessage = document.getElementById('loginMessage'); | |
| if (loginMessage) { | |
| loginMessage.innerText = "Error de conexión. Intenta recargar."; | |
| loginMessage.classList.add('text-red-500'); | |
| } | |
| } | |
| } | |
| // Firestore profile helpers | |
| async function fetchUserProfile(uid) { | |
| if (!db) return; | |
| try { | |
| const profileDocRef = doc(db, 'artifacts', appId, 'users', uid, 'user_data', 'profile'); | |
| const docSnap = await getDoc(profileDocRef); | |
| if (docSnap && docSnap.exists()) { | |
| userProfile = docSnap.data(); | |
| userProfileCache[uid] = userProfile; | |
| console.log("Perfil de usuario cargado:", userProfile); | |
| } else { | |
| console.log("No se encontró perfil para el usuario:", uid); | |
| userProfile = null; | |
| } | |
| } catch (error) { | |
| console.error("Error al buscar perfil:", error); | |
| userProfile = null; | |
| } | |
| } | |
| async function saveUserProfile(uid, username) { | |
| if (!db) return; | |
| try { | |
| const profileDocRef = doc(db, 'artifacts', appId, 'users', uid, 'user_data', 'profile'); | |
| await setDoc(profileDocRef, { username: username }); | |
| userProfile = { username: username }; | |
| userProfileCache[uid] = userProfile; | |
| console.log("Perfil de usuario guardado:", userProfile); | |
| } catch (error) { | |
| console.error("Error al guardar perfil:", error); | |
| } | |
| } | |
| async function getProfile(uid) { | |
| if (userProfileCache[uid]) { | |
| return userProfileCache[uid]; | |
| } | |
| if (!db) return null; | |
| try { | |
| const profileDocRef = doc(db, 'artifacts', appId, 'users', uid, 'user_data', 'profile'); | |
| const docSnap = await getDoc(profileDocRef); | |
| if (docSnap && docSnap.exists()) { | |
| const profile = docSnap.data(); | |
| userProfileCache[uid] = profile; | |
| return profile; | |
| } else { | |
| return null; | |
| } | |
| } catch (error) { | |
| console.error("Error al buscar perfil (getProfile):", error); | |
| return null; | |
| } | |
| } | |
| function tryStartApp() { | |
| if (!isAuthReady || !isFontReady) { | |
| return; | |
| } | |
| console.log("Auth y Fuente listos. Iniciando app..."); | |
| const loginOverlay = document.getElementById('loginOverlay'); | |
| const loginForm = document.getElementById('loginForm'); | |
| const usernameForm = document.getElementById('usernameForm'); | |
| const usernameMessage = document.getElementById('usernameMessage'); | |
| if (userProfile) { | |
| console.log(`Bienvenido de nuevo, ${userProfile.username}`); | |
| loginOverlay.style.opacity = '0'; | |
| setTimeout(() => { loginOverlay.style.display = 'none'; }, 500); | |
| initScene(); | |
| loadAllMaps(); | |
| } else { | |
| console.log("Mostrando modal de login para nuevo usuario."); | |
| usernameMessage.innerText = "¡Bienvenido! Elige un nombre de usuario público."; | |
| usernameMessage.classList.remove('text-red-500'); | |
| loginForm.style.display = 'none'; | |
| usernameForm.style.display = 'block'; | |
| loginOverlay.style.display = 'flex'; | |
| loginOverlay.style.opacity = '1'; | |
| } | |
| } | |
| // ---------- Three.js scene setup and main app logic ---------- | |
| function initScene() { | |
| scene = new THREE.Scene(); | |
| camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000); | |
| camera.position.set(0, 0, 15); | |
| renderer = new THREE.WebGLRenderer({ antialias: true }); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| renderer.setPixelRatio(window.devicePixelRatio); | |
| document.getElementById('container').appendChild(renderer.domElement); | |
| tooltip = document.getElementById('tooltip'); | |
| controls = new OrbitControls(camera, renderer.domElement); | |
| controls.enableDamping = true; | |
| controls.dampingFactor = 0.05; | |
| controls.target.set(0, 0, 0); | |
| const ambientLight = new THREE.AmbientLight(0xffffff, 0.6); | |
| scene.add(ambientLight); | |
| const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8); | |
| directionalLight.position.set(5, 10, 7.5); | |
| scene.add(directionalLight); | |
| raycaster = new THREE.Raycaster(); | |
| mouse = new THREE.Vector2(); | |
| hashtagGroup = new THREE.Group(); | |
| scene.add(hashtagGroup); | |
| const visualizeButton = document.getElementById('visualizeButton'); | |
| const topicInput = document.getElementById('topicInput'); | |
| const level1Slider = document.getElementById('level1Slider'); | |
| const level2Slider = document.getElementById('level2Slider'); | |
| const level3Slider = document.getElementById('level3Slider'); | |
| if (isAnonymous) { | |
| visualizeButton.disabled = true; | |
| visualizeButton.innerHTML = ` | |
| <svg class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> | |
| <path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z" /> | |
| </svg> | |
| <span>Solo Lectura</span>`; | |
| visualizeButton.classList.add('bg-gray-500', 'hover:bg-gray-500', 'cursor-not-allowed'); | |
| visualizeButton.classList.remove('bg-green-600', 'hover:bg-green-700'); | |
| topicInput.disabled = true; | |
| topicInput.placeholder = 'Inicia sesión para sembrar'; | |
| level1Slider.disabled = true; | |
| level2Slider.disabled = true; | |
| level3Slider.disabled = true; | |
| } else { | |
| visualizeButton.addEventListener('click', handleAnalysisAndVisualization); | |
| } | |
| document.getElementById('level1Slider').addEventListener('input', (e) => { | |
| document.getElementById('level1Value').innerText = e.target.value; | |
| }); | |
| document.getElementById('level2Slider').addEventListener('input', (e) => { | |
| document.getElementById('level2Value').innerText = e.target.value; | |
| }); | |
| document.getElementById('level3Slider').addEventListener('input', (e) => { | |
| document.getElementById('level3Value').innerText = e.target.value; | |
| }); | |
| window.addEventListener('resize', onWindowResize); | |
| window.addEventListener('mousemove', onPointerMove); | |
| const minimapCanvas = document.getElementById('minimap'); | |
| if (minimapCanvas) { | |
| minimapCtx = minimapCanvas.getContext('2d'); | |
| minimapCanvas.addEventListener('click', onMinimapClick); | |
| } else { | |
| console.error("No se encontró el canvas del minimapa"); | |
| } | |
| document.getElementById('zoomInButton').addEventListener('click', () => { | |
| minimapScale *= 1.5; | |
| drawMinimap(); | |
| }); | |
| document.getElementById('zoomOutButton').addEventListener('click', () => { | |
| minimapScale /= 1.5; | |
| drawMinimap(); | |
| }); | |
| animate(); | |
| } | |
| function animate() { | |
| requestAnimationFrame(animate); | |
| controls.update(); | |
| updateRaycaster(); | |
| hashtagGroup.children.forEach(object => { | |
| if (object.userData.isText) { | |
| object.lookAt(camera.position); | |
| const distance = object.position.distanceTo(camera.position); | |
| const minScale = 0.5; | |
| const maxScale = 4.0; | |
| const scaleFactor = 10; | |
| let scale = (1 / distance) * scaleFactor; | |
| scale = Math.max(minScale, Math.min(maxScale, scale)); | |
| object.scale.set(scale, scale, scale); | |
| } | |
| }); | |
| hashtagGroup.children.forEach(object => { | |
| if (object.userData.isPlaceholder && !object.userData.isLoaded) { | |
| const distance = object.position.distanceTo(camera.position); | |
| if (distance < LOAD_DISTANCE) { | |
| object.userData.isLoaded = true; | |
| object.visible = false; | |
| loadFullGalaxy(object.userData.ownerId); | |
| } | |
| } | |
| }); | |
| renderer.render(scene, camera); | |
| } | |
| // Fetch helper with backoff | |
| async function fetchWithBackoff(url, options, retries = 3, delay = 1000) { | |
| try { | |
| return await fetch(url, options); | |
| } catch (err) { | |
| if (retries > 0) { | |
| await new Promise(resolve => setTimeout(resolve, delay)); | |
| return fetchWithBackoff(url, options, retries - 1, delay * 2); | |
| } else { | |
| throw err; | |
| } | |
| } | |
| } | |
| // Gemini call routed through Netlify proxy with local fallback | |
| async function callGemini(topic, mainCount, variantCount, subVariantCount) { | |
| const modelId = 'gemini-2.5-flash-preview-09-2025'; | |
| const depth = 3; | |
| let systemInstruction = `Eres un analista de tendencias. Tu tarea es generar una lista estructurada de palabras clave. Normaliza todo el texto para que no tenga acentos.`; | |
| let userPrompt = `Tema: "${topic}".\n`; | |
| let schema = { | |
| type: "OBJECT", | |
| properties: { | |
| analisis: { | |
| type: "STRING", | |
| description: "Un análisis conciso del tema en 2-3 frases, sin acentos." | |
| }, | |
| lista_palabras: { | |
| type: "ARRAY", | |
| description: `Una lista de ${mainCount} objetos.`, | |
| items: { | |
| type: "OBJECT", | |
| properties: { | |
| palabra_principal: { type: "STRING" } | |
| }, | |
| required: ["palabra_principal"] | |
| } | |
| } | |
| }, | |
| required: ["analisis", "lista_palabras"] | |
| }; | |
| const n1Items = schema.properties.lista_palabras.items; | |
| if (depth >= 2) { | |
| userPrompt += `1. Genera una lista de ${mainCount} palabras clave principales (Nivel 1).\n`; | |
| userPrompt += `2. Para CADA palabra de Nivel 1, genera ${variantCount} variantes (Nivel 2).\n`; | |
| n1Items.properties.variantes = { | |
| type: "ARRAY", | |
| description: `Una lista de ${variantCount} objetos de variantes.`, | |
| items: { | |
| type: "OBJECT", | |
| properties: { | |
| palabra_variante: { type: "STRING" } | |
| }, | |
| required: ["palabra_variante"] | |
| } | |
| }; | |
| n1Items.required.push("variantes"); | |
| } | |
| if (depth == 3) { | |
| userPrompt += `3. Para CADA variante de Nivel 2, genera ${subVariantCount} sub-variantes (Nivel 3).\n`; | |
| userPrompt += `4. Asegurate que todo el texto no contenga acentos.`; | |
| const n2Items = n1Items.properties.variantes.items; | |
| n2Items.properties.sub_variantes = { | |
| type: "ARRAY", | |
| description: `Una lista de ${subVariantCount} sub-variantes.`, | |
| items: { type: "STRING" } | |
| }; | |
| n2Items.required.push("sub_variantes"); | |
| } else if (depth == 2) { | |
| userPrompt += `3. Asegurate que todo el texto no contenga acentos.`; | |
| } else { | |
| userPrompt += `1. Genera una lista de ${mainCount} palabras clave principales (Nivel 1).\n`; | |
| userPrompt += `2. Asegurate que todo el texto no contenga acentos.`; | |
| } | |
| const payload = { | |
| contents: [{ parts: [{ text: userPrompt }] }], | |
| systemInstruction: { | |
| parts: [{ text: systemInstruction }] | |
| }, | |
| generationConfig: { | |
| responseMimeType: "application/json", | |
| responseSchema: schema | |
| } | |
| }; | |
| console.log("Preparando solicitud a Gemini:", payload); | |
| // 1) Intentar proxy de Netlify solo si NO estamos en Hugging Face Static | |
| if (!isHFStatic) { | |
| try { | |
| const proxyResp = await fetchWithBackoff('/.netlify/functions/gemini-proxy', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ model: modelId, payload }) | |
| }); | |
| if (proxyResp.ok) { | |
| return await proxyResp.json(); | |
| } else { | |
| const t = await proxyResp.text(); | |
| console.warn('Proxy no disponible o error:', proxyResp.status, t); | |
| } | |
| } catch (e) { | |
| console.warn('Fallo al conectar con el proxy, usando fallback local...', e); | |
| } | |
| } | |
| // 2) Fallback to direct call with local key (only for local/dev) | |
| const localKey = getLocalGeminiKey(); | |
| if (!localKey) { | |
| throw new Error('No se pudo usar un backend y no hay clave local configurada. En Hugging Face (Static), guarda tu clave en Configuración.'); | |
| } | |
| const apiUrlDirect = `https://generativelanguage.googleapis.com/v1beta/models/${modelId}:generateContent?key=${localKey}`; | |
| const directResp = await fetchWithBackoff(apiUrlDirect, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(payload) | |
| }); | |
| if (!directResp.ok) { | |
| const errorBody = await directResp.text(); | |
| console.error('Error en la API de Gemini (fallback):', directResp.status, errorBody); | |
| throw new Error(`Error en la API (fallback): ${directResp.statusText}`); | |
| } | |
| return await directResp.json(); | |
| } | |
| // Main handler for analysis + visualization | |
| async function handleAnalysisAndVisualization() { | |
| if (!font) { | |
| console.error("La fuente 3D no se ha cargado. Espera o refresca."); | |
| return; | |
| } | |
| const topic = normalizeString(document.getElementById('topicInput').value); | |
| const mainCount = document.getElementById('level1Slider').value; | |
| const variantCount = document.getElementById('level2Slider').value; | |
| const subVariantCount = document.getElementById('level3Slider').value; | |
| const button = document.getElementById('visualizeButton'); | |
| const progressBarContainer = document.getElementById('progressBarContainer'); | |
| const progressBar = document.getElementById('progressBar'); | |
| if (!topic) { | |
| console.warn("Por favor, introduce un tema."); | |
| return; | |
| } | |
| button.disabled = true; | |
| button.innerText = 'Analizando...'; | |
| progressBar.style.transition = 'none'; | |
| progressBar.style.width = '0%'; | |
| progressBarContainer.style.display = 'block'; | |
| void progressBar.offsetWidth; | |
| progressBar.style.transition = 'width 20s ease-out'; | |
| progressBar.style.width = '95%'; | |
| let mapOrigin = new THREE.Vector3(0, 0, 0); | |
| const existingUserMaps = userMaps[userId] || []; | |
| const localMapIndex = existingUserMaps.length; | |
| if (localMapIndex === 0) { | |
| const galaxyIndex = Object.keys(userMaps).length; | |
| const GALAXY_SEPARATION_STEP = 750; | |
| const angle = galaxyIndex * 2.3998; | |
| const radius = galaxyIndex * GALAXY_SEPARATION_STEP; | |
| mapOrigin.x = radius * Math.cos(angle); | |
| mapOrigin.z = radius * Math.sin(angle); | |
| mapOrigin.y = 0; | |
| } else { | |
| const galaxyCentroid = new THREE.Vector3(0, 0, 0); | |
| existingUserMaps.forEach(origin => galaxyCentroid.add(origin)); | |
| galaxyCentroid.divideScalar(localMapIndex); | |
| const LOCAL_RADIUS_STEP = 25; | |
| const angle = localMapIndex * 2.3998; | |
| const radius = Math.sqrt(localMapIndex) * LOCAL_RADIUS_STEP; | |
| mapOrigin.x = galaxyCentroid.x + radius * Math.cos(angle); | |
| mapOrigin.z = galaxyCentroid.z + radius * Math.sin(angle); | |
| mapOrigin.y = 0; | |
| } | |
| try { | |
| console.log(`Llamando a Gemini con: ${topic}`); | |
| const result = await callGemini(topic, mainCount, variantCount, subVariantCount); | |
| console.log("Respuesta de Gemini recibida:", result); | |
| const candidate = result.candidates?.[0]; | |
| if (candidate && candidate.content?.parts?.[0]?.text) { | |
| const text = candidate.content.parts[0].text; | |
| const parsedData = JSON.parse(text); | |
| const rootTopic = topic; | |
| visualizeRoot(rootTopic, mapOrigin); | |
| visualizeHashtags(parsedData.lista_palabras, mapOrigin, 1, null); | |
| if (db) { | |
| await saveMapToFirestore(rootTopic, "3", mapOrigin, parsedData); | |
| } | |
| } else { | |
| throw new Error("Respuesta de Gemini inválida o vacía."); | |
| } | |
| } catch (error) { | |
| console.error("Error en handleAnalysisAndVisualization:", error); | |
| } finally { | |
| button.disabled = false; | |
| button.innerText = 'Sembrar'; | |
| progressBar.style.transition = 'width 0.3s ease-in'; | |
| progressBar.style.width = '100%'; | |
| setTimeout(() => { | |
| progressBarContainer.style.display = 'none'; | |
| progressBar.style.width = '0%'; | |
| }, 500); | |
| } | |
| } | |
| // Scene helpers (clear, visualize root, visualize hashtags, etc.) | |
| function clearScene() { | |
| while (hashtagGroup.children.length > 0) { | |
| const object = hashtagGroup.children[0]; | |
| if (object.geometry) object.geometry.dispose(); | |
| if (Array.isArray(object.material)) { | |
| object.material.forEach(m => m.dispose()); | |
| } else if (object.material) { | |
| object.material.dispose(); | |
| } | |
| while (object.children.length > 0) { | |
| const child = object.children[0]; | |
| if (child.geometry) child.geometry.dispose(); | |
| if (child.material) child.material.dispose(); | |
| object.remove(child); | |
| } | |
| hashtagGroup.remove(object); | |
| } | |
| console.log("Escena limpiada."); | |
| } | |
| function visualizeRoot(topic, origin) { | |
| const { color: rootColor } = stringToHslColor(topic); | |
| const rootMaterial = new THREE.MeshStandardMaterial({ | |
| color: new THREE.Color(rootColor), | |
| roughness: 0.5, | |
| metalness: 0.1 | |
| }); | |
| const rootTextMaterial = new THREE.MeshBasicMaterial({ | |
| color: new THREE.Color(rootColor), | |
| transparent: true, | |
| opacity: 0.7 | |
| }); | |
| const rootSphereRadius = 0.4; | |
| const rootGeometry = new THREE.SphereGeometry(rootSphereRadius, 16, 16); | |
| const rootSphere = new THREE.Mesh(rootGeometry, rootMaterial); | |
| rootSphere.position.copy(origin); | |
| rootSphere.userData.hashtag = topic; | |
| rootSphere.userData.level = 0; | |
| hashtagGroup.add(rootSphere); | |
| const rootTextSize = 0.3; | |
| const rootTextGeometry = new TextGeometry(topic.toUpperCase(), { | |
| font: font, | |
| size: rootTextSize, | |
| height: 0.02, | |
| curveSegments: 4, | |
| bevelEnabled: false | |
| }); | |
| rootTextGeometry.computeBoundingBox(); | |
| const rootTextMesh = new THREE.Mesh(rootTextGeometry, rootTextMaterial); | |
| rootTextMesh.position.copy(origin); | |
| rootTextMesh.position.y += rootSphereRadius + 0.1; | |
| rootTextMesh.position.x -= (rootTextGeometry.boundingBox.max.x - rootTextGeometry.boundingBox.min.x) / 2; | |
| rootTextMesh.userData.isText = true; | |
| hashtagGroup.add(rootTextMesh); | |
| } | |
| async function saveMapToFirestore(topic, depth, origin, data) { | |
| if (!db) return; | |
| try { | |
| const mapCollection = collection(db, 'artifacts', appId, 'public', 'data', 'maps'); | |
| const mapDocument = { | |
| topic: topic, | |
| depth: depth, | |
| origin: { x: origin.x, y: origin.y, z: origin.z }, | |
| data: JSON.stringify(data), | |
| createdAt: new Date(), | |
| userId: userId | |
| }; | |
| await addDoc(mapCollection, mapDocument); | |
| console.log("Mapa guardado en Firestore:", topic); | |
| } catch (error) { | |
| console.error("Error guardando mapa en Firestore:", error); | |
| } | |
| } | |
| function loadAllMaps() { | |
| if (!db || !font) { | |
| console.warn("Firestore o la fuente no están listos. Esperando..."); | |
| if (!font) { | |
| setTimeout(loadAllMaps, 500); | |
| } | |
| return; | |
| } | |
| const mapCollection = collection(db, 'artifacts', appId, 'public', 'data', 'maps'); | |
| const q = query(mapCollection); | |
| onSnapshot(q, async (snapshot) => { | |
| console.log("Datos de Firestore recibidos, redibujando escena..."); | |
| clearScene(); | |
| mapCount = 0; | |
| userMaps = {}; | |
| allMapsDataCache = {}; | |
| if (snapshot.empty) { | |
| console.log("No se encontraron mapas en Firestore."); | |
| drawMinimap(); | |
| return; | |
| } | |
| snapshot.docs.forEach((doc) => { | |
| mapCount++; | |
| const map = doc.data(); | |
| if (!map.origin || !map.data || !map.topic || !map.userId) { | |
| console.warn("Documento de mapa incompleto, saltando:", doc.id); | |
| return; | |
| } | |
| const origin = new THREE.Vector3(map.origin.x, map.origin.y, map.origin.z); | |
| if (!userMaps[map.userId]) { | |
| userMaps[map.userId] = []; | |
| } | |
| userMaps[map.userId].push(origin); | |
| let parsedData; | |
| try { | |
| parsedData = JSON.parse(map.data); | |
| } catch (e) { | |
| console.error("Error al parsear datos del mapa:", e, doc.id); | |
| return; | |
| } | |
| if (!allMapsDataCache[map.userId]) { | |
| allMapsDataCache[map.userId] = []; | |
| } | |
| allMapsDataCache[map.userId].push({ topic: map.topic, origin: origin, data: parsedData }); | |
| if (map.userId === userId) { | |
| visualizeRoot(map.topic, origin); | |
| visualizeHashtags(parsedData.lista_palabras, origin, 1, null); | |
| } | |
| }); | |
| Object.keys(userMaps).forEach(uid => { | |
| if (uid !== userId) { | |
| const userMapOrigins = userMaps[uid]; | |
| const centroid = new THREE.Vector3(0, 0, 0); | |
| userMapOrigins.forEach(origin => centroid.add(origin)); | |
| centroid.divideScalar(userMapOrigins.length); | |
| visualizeUserPlaceholder(uid, centroid); | |
| } | |
| }); | |
| const userListElement = document.getElementById('userList'); | |
| if (userListElement) { | |
| userListElement.innerHTML = ''; | |
| const profilePromises = Object.keys(userMaps).map(uid => getProfile(uid)); | |
| const profiles = await Promise.all(profilePromises); | |
| Object.keys(userMaps).forEach((uid, index) => { | |
| const profile = profiles[index]; | |
| const username = profile ? profile.username : `Usuario ${uid.substring(0,4)}`; | |
| const userItem = document.createElement('div'); | |
| userItem.className = 'p-2 mb-1 rounded-md hover:bg-gray-700 cursor-pointer transition-colors duration-200 text-sm'; | |
| userItem.innerText = username; | |
| userItem.dataset.userid = uid; | |
| userItem.addEventListener('click', () => teleportToUser(uid)); | |
| userListElement.appendChild(userItem); | |
| }); | |
| } | |
| drawMinimap(); | |
| console.log(`Cargados ${mapCount} mapas.`); | |
| focusOnUserMaps(); | |
| }, (error) => { | |
| console.error("Error al escuchar mapas de Firestore:", error); | |
| }); | |
| } | |
| async function visualizeUserPlaceholder(uid, centroid) { | |
| const profile = await getProfile(uid); | |
| const username = profile ? profile.username : 'Usuario'; | |
| const placeholderMaterial = new THREE.MeshBasicMaterial({ | |
| color: 0x00ffff, | |
| emissive: 0x00ffff, | |
| emissiveIntensity: 1, | |
| wireframe: true | |
| }); | |
| const placeholderGeometry = new THREE.SphereGeometry(0.5, 8, 8); | |
| const placeholderSphere = new THREE.Mesh(placeholderGeometry, placeholderMaterial); | |
| placeholderSphere.position.copy(centroid); | |
| placeholderSphere.userData = { | |
| isPlaceholder: true, | |
| ownerId: uid, | |
| isLoaded: false, | |
| hashtag: username | |
| }; | |
| const placeholderTextMaterial = new THREE.MeshBasicMaterial({ color: 0x00ffff, transparent: true, opacity: 0.7 }); | |
| const textGeometry = new TextGeometry(username, { | |
| font: font, | |
| size: 0.3, | |
| height: 0.02, | |
| curveSegments: 4, | |
| bevelEnabled: false | |
| }); | |
| textGeometry.computeBoundingBox(); | |
| const textMesh = new THREE.Mesh(textGeometry, placeholderTextMaterial); | |
| textMesh.position.y += 0.6; | |
| textMesh.position.x -= (textGeometry.boundingBox.max.x - textGeometry.boundingBox.min.x) / 2; | |
| textMesh.userData.isText = true; | |
| placeholderSphere.add(textMesh); | |
| hashtagGroup.add(placeholderSphere); | |
| } | |
| function loadFullGalaxy(ownerId) { | |
| console.log("Cargando galaxia para:", ownerId); | |
| const mapsToLoad = allMapsDataCache[ownerId]; | |
| if (!mapsToLoad) { | |
| console.warn("No se encontraron datos en caché para:", ownerId); | |
| return; | |
| } | |
| mapsToLoad.forEach(map => { | |
| visualizeRoot(map.topic, map.origin); | |
| visualizeHashtags(map.data.lista_palabras, map.origin, 1, null); | |
| }); | |
| } | |
| function focusOnUserMaps() { | |
| if (!controls) return; | |
| if (!userId || !userMaps[userId] || userMaps[userId].length === 0) { | |
| console.log("Usuario sin mapas o no logueado, centrando en (0,0,0)."); | |
| controls.target.set(0, 0, 0); | |
| camera.position.set(0, 0, 15); | |
| controls.update(); | |
| return; | |
| } | |
| const userMapOrigins = userMaps[userId]; | |
| const centroid = new THREE.Vector3(0, 0, 0); | |
| userMapOrigins.forEach(origin => centroid.add(origin)); | |
| centroid.divideScalar(userMapOrigins.length); | |
| console.log(`Enfocando en la constelación del usuario en:`, centroid); | |
| controls.target.copy(centroid); | |
| const cameraOffset = new THREE.Vector3(0, 5, 20); | |
| camera.position.copy(centroid).add(cameraOffset); | |
| controls.update(); | |
| } | |
| function teleportToUser(targetUserId) { | |
| if (!controls || !userMaps[targetUserId] || userMaps[targetUserId].length === 0) { | |
| console.warn("No se puede teletransportar: Faltan controles o mapas para el usuario", targetUserId); | |
| return; | |
| } | |
| const userMapOrigins = userMaps[targetUserId]; | |
| const centroid = new THREE.Vector3(0, 0, 0); | |
| userMapOrigins.forEach(origin => centroid.add(origin)); | |
| centroid.divideScalar(userMapOrigins.length); | |
| console.log(`Teletransportando a la galaxia de ${targetUserId} en:`, centroid); | |
| controls.target.copy(centroid); | |
| const cameraOffset = new THREE.Vector3(0, 5, 20); | |
| camera.position.copy(centroid).add(cameraOffset); | |
| controls.update(); | |
| } | |
| function visualizeHashtags(dataList, origin, level, parentColor = null) { | |
| if (!dataList || dataList.length === 0) return; | |
| console.log(`Dibujando Nivel ${level} con ${dataList.length} items.`); | |
| for (const item of dataList) { | |
| let currentTag, variantsList; | |
| if (level === 1) { | |
| currentTag = normalizeString(item.palabra_principal); | |
| variantsList = item.variantes || []; | |
| } else if (level === 2) { | |
| currentTag = normalizeString(item.palabra_variante); | |
| variantsList = item.sub_variantes || []; | |
| } else { | |
| currentTag = normalizeString(item); | |
| variantsList = []; | |
| } | |
| if (!currentTag) continue; | |
| const { color, h } = stringToHslColor(currentTag); | |
| const nodeColor = (level === 1) ? color : parentColor; | |
| const nodeMaterial = new THREE.MeshStandardMaterial({ | |
| color: new THREE.Color(nodeColor), | |
| roughness: 0.5, | |
| metalness: 0.1 | |
| }); | |
| const nodeTextMaterial = new THREE.MeshBasicMaterial({ | |
| color: new THREE.Color(nodeColor), | |
| transparent: true, | |
| opacity: 0.7 | |
| }); | |
| const theta = (h / 360) * Math.PI * 2; | |
| let phiHash = 0; | |
| for (let i = 0; i < currentTag.length; i++) { | |
| phiHash = (phiHash + currentTag.charCodeAt(i) * 13) % 180; | |
| } | |
| const phi = ((phiHash / 180) * 90 + 45) * (Math.PI / 180); | |
| const baseRadius = 10 / (level * level); | |
| const clusterCenterX = baseRadius * Math.sin(phi) * Math.cos(theta); | |
| const clusterCenterY = baseRadius * Math.cos(phi); | |
| const clusterCenterZ = baseRadius * Math.sin(phi) * Math.sin(theta); | |
| const clusterCenter = new THREE.Vector3(clusterCenterX, clusterCenterY, clusterCenterZ); | |
| clusterCenter.add(origin); | |
| const branchColor = new THREE.Color(nodeColor).multiplyScalar(0.4); | |
| const lineMaterial = new THREE.LineBasicMaterial({ color: branchColor }); | |
| const linePoints = [origin, clusterCenter]; | |
| const lineGeometry = new THREE.BufferGeometry().setFromPoints(linePoints); | |
| const line = new THREE.Line(lineGeometry, lineMaterial); | |
| hashtagGroup.add(line); | |
| let sphereRadius; | |
| if (level === 1) sphereRadius = 0.2; | |
| else if (level === 2) sphereRadius = 0.1; | |
| else sphereRadius = 0.05; | |
| const leafGeometry = new THREE.SphereGeometry(sphereRadius, 8, 8); | |
| const sphere = new THREE.Mesh(leafGeometry, nodeMaterial); | |
| sphere.position.copy(clusterCenter); | |
| sphere.userData.hashtag = currentTag; | |
| sphere.userData.count = 1; | |
| sphere.userData.level = level; | |
| hashtagGroup.add(sphere); | |
| let baseTextSize; | |
| if (level === 1) baseTextSize = 0.24; | |
| else if (level === 2) baseTextSize = 0.12; | |
| else baseTextSize = 0.08; | |
| const textGeometry = new TextGeometry(currentTag.toUpperCase(), { | |
| font: font, | |
| size: baseTextSize, | |
| height: 0.02 / level, | |
| curveSegments: 4, | |
| bevelEnabled: false | |
| }); | |
| textGeometry.computeBoundingBox(); | |
| const textMesh = new THREE.Mesh(textGeometry, nodeTextMaterial); | |
| const smallMargin = 0.05 / level; | |
| textMesh.position.copy(clusterCenter); | |
| textMesh.position.y += sphereRadius + smallMargin; | |
| textMesh.position.x -= (textGeometry.boundingBox.max.x - textGeometry.boundingBox.min.x) / 2; | |
| textMesh.userData.isText = true; | |
| hashtagGroup.add(textMesh); | |
| visualizeHashtags(variantsList, clusterCenter, level + 1, nodeColor); | |
| } | |
| } | |
| function drawMinimapDot(dot, color, size = MINIMAP_DOT_SIZE) { | |
| if (!minimapCtx) return; | |
| minimapCtx.beginPath(); | |
| minimapCtx.arc(dot.x, dot.y, size, 0, Math.PI * 2); | |
| minimapCtx.fillStyle = color; | |
| minimapCtx.fill(); | |
| minimapCtx.fillStyle = 'white'; | |
| minimapCtx.font = '10px Orbitron'; | |
| minimapCtx.fillText(dot.username, dot.x + size + 3, dot.y + 4); | |
| } | |
| async function drawMinimap() { | |
| if (!minimapCtx || !userMaps) return; | |
| const canvas = minimapCtx.canvas; | |
| minimapCtx.fillStyle = '#1f2937'; | |
| minimapCtx.fillRect(0, 0, canvas.width, canvas.height); | |
| minimapDotCoords = []; | |
| let centerX = 0, centerZ = 0; | |
| if (userId && userMaps[userId] && userMaps[userId].length > 0) { | |
| const myCentroid = new THREE.Vector3(0, 0, 0); | |
| userMaps[userId].forEach(origin => myCentroid.add(origin)); | |
| myCentroid.divideScalar(userMaps[userId].length); | |
| centerX = myCentroid.x; | |
| centerZ = myCentroid.z; | |
| } | |
| const uids = Object.keys(userMaps); | |
| const profilePromises = uids.map(uid => getProfile(uid)); | |
| const profiles = await Promise.all(profilePromises); | |
| let myDotData = null; | |
| for (let i = 0; i < uids.length; i++) { | |
| const uid = uids[i]; | |
| const profile = profiles[i]; | |
| const username = profile ? profile.username : `Usuario ${uid.substring(0,4)}`; | |
| const userMapsList = userMaps[uid] || []; | |
| const numSatellites = userMapsList.length; | |
| const userCentroid = new THREE.Vector3(0,0,0); | |
| if (numSatellites > 0) { | |
| userMapsList.forEach(origin => userCentroid.add(origin)); | |
| userCentroid.divideScalar(numSatellites); | |
| } else { | |
| console.warn(`Usuario ${uid} no tiene orígenes de mapa, usando (0,0,0)`); | |
| } | |
| const relX = (userCentroid.x - centerX) * minimapScale; | |
| const relZ = (userCentroid.z - centerZ) * minimapScale; | |
| const canvasX = canvas.width / 2 + relX; | |
| const canvasY = canvas.height / 2 + relZ; | |
| const dotData = { x: canvasX, y: canvasY, uid: uid, username: username }; | |
| minimapDotCoords.push(dotData); | |
| const isMe = (uid === userId); | |
| const mainColor = isMe ? '#fde047' : '#06b6d4'; | |
| const baseSize = MINIMAP_DOT_SIZE; | |
| const sizeBonus = (numSatellites > 1) ? Math.log(numSatellites) * 1.5 : 0; | |
| let mainSize = baseSize + sizeBonus; | |
| if (isMe) mainSize += 1; | |
| if (numSatellites > 0) { | |
| const satelliteRadius = mainSize + 3; | |
| const satelliteSize = 1; | |
| for (let j = 0; j < numSatellites; j++) { | |
| const angle = (j / numSatellites) * Math.PI * 2; | |
| const satX = dotData.x + Math.cos(angle) * satelliteRadius; | |
| const satY = dotData.y + Math.sin(angle) * satelliteRadius; | |
| minimapCtx.beginPath(); | |
| minimapCtx.arc(satX, satY, satelliteSize, 0, Math.PI * 2); | |
| minimapCtx.fillStyle = mainColor; | |
| minimapCtx.globalAlpha = 0.6; | |
| minimapCtx.fill(); | |
| minimapCtx.globalAlpha = 1.0; | |
| } | |
| } | |
| if (!isMe) drawMinimapDot(dotData, mainColor, mainSize); | |
| else myDotData = { dot: dotData, color: mainColor, size: mainSize }; | |
| } | |
| if (myDotData) drawMinimapDot(myDotData.dot, myDotData.color, myDotData.size); | |
| } | |
| function onMinimapClick(event) { | |
| if (!minimapCtx) return; | |
| const canvas = minimapCtx.canvas; | |
| const rect = canvas.getBoundingClientRect(); | |
| const x = event.clientX - rect.left; | |
| const y = event.clientY - rect.top; | |
| let clickedUser = null; | |
| let minDistance = 10; | |
| for (let i = minimapDotCoords.length - 1; i >= 0; i--) { | |
| const dot = minimapDotCoords[i]; | |
| const dx = x - dot.x; | |
| const dy = y - dot.y; | |
| const distance = Math.sqrt(dx * dx + dy * dy); | |
| if (distance < minDistance) { | |
| minDistance = distance; | |
| clickedUser = dot.uid; | |
| } | |
| } | |
| if (clickedUser) { | |
| console.log("Clic en minimapa sobre usuario:", clickedUser); | |
| teleportToUser(clickedUser); | |
| } | |
| } | |
| function onWindowResize() { | |
| camera.aspect = window.innerWidth / window.innerHeight; | |
| camera.updateProjectionMatrix(); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| } | |
| function onPointerMove(event) { | |
| mouse.x = (event.clientX / window.innerWidth) * 2 - 1; | |
| mouse.y = -(event.clientY / window.innerHeight) * 2 + 1; | |
| tooltip.style.left = `${event.clientX + 15}px`; | |
| tooltip.style.top = `${event.clientY}px`; | |
| } | |
| function updateRaycaster() { | |
| raycaster.setFromCamera(mouse, camera); | |
| const objectsToIntersect = hashtagGroup.children.filter(o => o.isMesh); | |
| const intersects = raycaster.intersectObjects(objectsToIntersect, false); | |
| if (intersects.length > 0) { | |
| let targetObject = intersects[0].object; | |
| if (targetObject.userData.isText && targetObject.parent) targetObject = targetObject.parent; | |
| if (intersected.object !== targetObject) { | |
| intersected.object = targetObject; | |
| const data = intersected.object.userData; | |
| tooltip.style.display = 'block'; | |
| let tooltipText = `<strong>${data.hashtag}</strong>`; | |
| if (data.level !== undefined) tooltipText += `<br>Nivel: ${data.level}`; | |
| else if (data.isPlaceholder) tooltipText = `<strong>Galaxia de ${data.hashtag}</strong>`; | |
| tooltip.innerHTML = tooltipText; | |
| } | |
| } else { | |
| if (intersected.object) tooltip.style.display = 'none'; | |
| intersected.object = null; | |
| } | |
| } | |
| function stringToHslColor(str) { | |
| let hash = 0; | |
| for (let i = 0; i < str.length; i++) hash = str.charCodeAt(i) + ((hash << 5) - hash); | |
| const h = Math.abs(hash % 360); | |
| return { color: `hsl(${h}, 80%, 60%)`, h: h }; | |
| } | |
| </script> | |
| </body> | |
| </html> | |