salomonsky commited on
Commit
021a33b
·
verified ·
1 Parent(s): 7f5fa26

Update index.html

Browse files
Files changed (1) hide show
  1. index.html +498 -614
index.html CHANGED
@@ -3,252 +3,135 @@
3
  <head>
4
  <meta charset="UTF-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
6
- <title>Analizador de Temas 3D con Gemini</title>
7
  <script src="https://cdn.tailwindcss.com"></script>
8
  <link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@700&display=swap" rel="stylesheet">
9
- <style>
10
- body {
11
- font-family: 'Orbitron', sans-serif;
12
- margin: 0;
13
- overflow: hidden;
14
- }
15
- #container {
16
- width: 100vw;
17
- height: 100vh;
18
- position: fixed;
19
- top: 0;
20
- left: 0;
21
- background-color: #111827;
22
- }
23
- canvas {
24
- display: block;
25
- }
26
- #ui {
27
- position: fixed;
28
- top: 20px;
29
- left: 20px;
30
- z-index: 100;
31
- background-color: rgba(31, 41, 55, 0.9);
32
- padding: 20px;
33
- border-radius: 12px;
34
- box-shadow: 0 4px 15px rgba(0, 0, 0, 0.4);
35
- max-width: 400px;
36
- color: white;
37
- height: calc(100vh - 40px);
38
- display: flex;
39
- flex-direction: column;
40
- }
41
- #tooltip {
42
- position: absolute;
43
- display: none;
44
- background: rgba(0, 0, 0, 0.8);
45
- color: white;
46
- padding: 8px 12px;
47
- border-radius: 6px;
48
- z-index: 101;
49
- pointer-events: none;
50
- font-size: 14px;
51
- }
52
- input[type="range"] {
53
- -webkit-appearance: none;
54
- appearance: none;
55
- width: 100%;
56
- height: 8px;
57
- background: #4b5563;
58
- border-radius: 5px;
59
- outline: none;
60
- opacity: 0.7;
61
- transition: opacity .2s;
62
- }
63
- input[type="range"]:hover {
64
- opacity: 1;
65
- }
66
- input[type="range"]::-webkit-slider-thumb {
67
- -webkit-appearance: none;
68
- appearance: none;
69
- width: 20px;
70
- height: 20px;
71
- background: #3b82f6;
72
- border-radius: 50%;
73
- cursor: pointer;
74
- }
75
- input[type="range"]::-moz-range-thumb {
76
- width: 20px;
77
- height: 20px;
78
- background: #3b82f6;
79
- border-radius: 50%;
80
- cursor: pointer;
81
- }
82
- #progressBarContainer {
83
- width: 100%;
84
- background-color: #374151;
85
- border-radius: 4px;
86
- overflow: hidden;
87
- display: none;
88
- height: 6px;
89
- margin-top: 12px;
90
- }
91
- #progressBar {
92
- width: 0%;
93
- height: 6px;
94
- background-color: #3b82f6;
95
- }
96
- #loginOverlay {
97
- transition: opacity 0.5s ease-in-out;
98
- display: flex;
99
- opacity: 0;
100
- }
101
- #toast {
102
- position: fixed;
103
- top: 20px;
104
- left: 50%;
105
- background-color: #22c55e;
106
- color: white;
107
- padding: 16px;
108
- border-radius: 8px;
109
- box-shadow: 0 4px 10px rgba(0,0,0,.3);
110
- z-index: 200;
111
- transform: translateY(-120%) translateX(-50%);
112
- transition: transform 0.5s ease-in-out;
113
- font-weight: bold;
114
- }
115
- #toast.show {
116
- transform: translateY(0) translateX(-50%);
117
- }
118
- #minimapContainer {
119
- width: 100%;
120
- height: 250px;
121
- margin-top: 16px;
122
- flex-shrink: 0;
123
- }
124
- #minimap {
125
- width: 100%;
126
- height: 100%;
127
- background-color: #1f2937;
128
- border-radius: 8px;
129
- border: 1px solid #4b5563;
130
- cursor: pointer;
131
- }
132
- /* main auth buttons inside the main UI header */
133
- #mainAuthButtons {
134
- display: flex;
135
- gap: 8px;
136
- align-items: center;
137
- }
138
- </style>
139
  </head>
140
 
141
- <body>
142
- <!-- Login modal / overlay -->
143
- <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">
144
- <div id="loginModal" class="bg-gray-800 p-8 rounded-lg shadow-xl w-full max-w-sm">
145
- <div id="loginForm">
146
- <h2 class="text-2xl font-bold text-green-400 mb-4">Iniciar Sesión</h2>
147
- <p class="text-gray-300 mb-6" id="loginMessage">Por favor, inicia sesión o regístrate.</p>
148
-
149
- <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">
150
- <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">
151
-
152
- <div class="flex justify-between items-center mt-4 text-sm">
153
- <label class="flex items-center text-gray-400">
154
- <input type="checkbox" id="showPasswordCheck" class="mr-2 rounded bg-gray-700 border-gray-600 text-blue-500 focus:ring-blue-500">
155
- Mostrar contraseña
156
- </label>
157
- <label class="flex items-center text-gray-400">
158
- <input type="checkbox" id="rememberMeCheck" class="mr-2 rounded bg-gray-700 border-gray-600 text-blue-500 focus:ring-blue-500" checked>
159
- Recordarme
160
- </label>
161
- </div>
162
 
163
- <div class="flex flex-col space-y-2 mt-6">
164
- <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>
165
- <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>
166
- <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>
 
 
 
 
 
167
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
168
  </div>
169
 
170
- <div id="usernameForm" style="display: none;">
171
- <h2 class="text-2xl font-bold text-green-400 mb-4">¡Bienvenido!</h2>
172
- <p class="text-gray-300 mb-6" id="usernameMessage">Elige un nombre de usuario público para continuar.</p>
173
- <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">
174
- <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>
 
 
 
 
 
 
175
  </div>
 
176
  </div>
177
  </div>
178
 
179
- <div id="container"></div>
180
- <div id="tooltip" class="z-[101]"></div>
 
181
 
182
- <div id="ui">
 
183
  <div class="flex items-center justify-between mb-4">
184
- <h1 class="text-2xl font-bold text-white flex items-center space-x-2">
185
  <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">
186
  <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" />
187
  </svg>
188
- <span class="text-green-400">ECOTAGS</span>
189
  </h1>
190
-
191
  <div class="flex items-center gap-2">
192
- <span id="authStatus" class="text-xs text-gray-300 hidden"></span>
193
- </div>
194
- <div id="mainAuthButtons" class="flex items-center">
195
- <button id="mainLoginButton" class="bg-blue-600 text-white px-3 py-1 rounded text-sm mr-2" style="display:none" title="Iniciar sesión">Iniciar sesión</button>
196
- <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>
197
  </div>
198
  </div>
199
 
200
- <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">
201
-
202
- <label for="level1Slider" class="text-sm text-gray-400">Nivel 1 (1-15): <span id="level1Value">10</span></label>
203
- <input type="range" id="level1Slider" min="1" max="15" value="10" class="w-full mb-2">
204
-
205
- <label for="level2Slider" class="text-sm text-gray-400">Nivel 2 (5-8): <span id="level2Value">5</span></label>
206
- <input type="range" id="level2Slider" min="5" max="8" value="5" class="w-full mb-2">
207
 
208
- <label for="level3Slider" class="text-sm text-gray-400">Nivel 3 (1-3): <span id="level3Value">3</span></label>
209
- <input type="range" id="level3Slider" min="1" max="3" value="3" class="w-full mb-4">
 
 
 
 
 
 
 
 
 
 
 
 
210
 
211
- <details class="mb-3 bg-gray-700/50 rounded-md p-3 border border-gray-600">
212
- <summary class="cursor-pointer text-sm text-blue-300 font-semibold">Configuración</summary>
213
- <div class="mt-3 space-y-2 text-sm">
214
  <div>
215
- <label for="geminiKeyInput" class="block text-gray-300">Gemini API Key (guardada localmente)</label>
216
- <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...">
217
  </div>
218
- <div class="flex items-center gap-2">
219
- <button id="saveGeminiKeyBtn" class="bg-gray-600 hover:bg-gray-500 px-3 py-1 rounded">Guardar clave</button>
220
- <span id="geminiKeyStatus" class="text-gray-400"></span>
221
  </div>
222
- <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>
223
  </div>
224
  </details>
225
 
226
- <div class="flex items-center gap-2 mb-2">
227
- <button id="newNeuronButton" class="bg-gray-700 hover:bg-gray-600 text-white font-semibold py-2 px-3 rounded-lg transition duration-300">+ Nueva neurona</button>
228
- <button id="visualizeButton" class="flex-1 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">
229
- <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">
230
- <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" />
231
- </svg>
232
- <span>Sembrar</span>
233
  </button>
234
  </div>
235
 
236
- <div id="progressBarContainer"><div id="progressBar"></div></div>
 
 
237
 
238
- <div id="userListContainer" class="mt-4 flex flex-col" style="max-height:25vh;">
239
- <h2 class="font-bold text-lg mb-2 text-blue-300">Galaxias de Usuarios</h2>
240
- <div id="userList" class="w-full overflow-y-auto text-gray-300 pr-2"></div>
241
  </div>
242
 
243
- <div id="minimapContainer">
244
- <div class="flex justify-between items-center mb-2">
245
- <h2 class="font-bold text-lg text-blue-300">Minimapa Galáctico</h2>
246
- <div class="flex space-x-1">
247
- <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>
248
- <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>
249
- </div>
250
  </div>
251
- <canvas id="minimap" width="360" height="200"></canvas>
 
252
  </div>
253
  </div>
254
 
@@ -259,16 +142,16 @@
259
 
260
  <script type="module">
261
  import { initializeApp } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-app.js";
 
262
  import {
263
  getAuth,
264
  onAuthStateChanged,
265
  createUserWithEmailAndPassword,
266
  signInWithEmailAndPassword,
267
- setPersistence,
268
- browserLocalPersistence,
269
- browserSessionPersistence,
270
  signInAnonymously,
271
- signOut
 
 
272
  } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-auth.js";
273
  import {
274
  getFirestore,
@@ -282,7 +165,6 @@
282
  setLogLevel
283
  } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-firestore.js";
284
 
285
- // --- FIREBASE CONFIG (usar la API key del proyecto Firebase) ---
286
  const firebaseConfig = {
287
  apiKey: "AIzaSyB4EZKkAH3weFSZNBQA8Y63gt5XbaeZGsQ",
288
  authDomain: "neuronal-1f3b9.firebaseapp.com",
@@ -293,10 +175,8 @@
293
  measurementId: "G-102SEBLQFJ"
294
  };
295
 
296
- // --- Gemini API Key helpers (local fallback) ---
297
- // ADVERTENCIA: Incluir una clave por defecto en el cliente la expone públicamente.
298
- // Úsalo solo si entiendes los riesgos. Recomendado: guardar por UI o usar backend con Secrets.
299
- const DEFAULT_GEMINI_KEY = 'AIzaSyDmoQNpzgzW21f_WFCU9YbaAeI1fdOJMlo';
300
  function getLocalGeminiKey() {
301
  try {
302
  const k = localStorage.getItem('GEMINI_API_KEY') || '';
@@ -308,28 +188,37 @@
308
  function setLocalGeminiKey(k) {
309
  try { localStorage.setItem('GEMINI_API_KEY', k || ''); } catch {}
310
  }
 
311
 
312
- // Three.js aliases (for older included builds)
313
  const THREE = window.THREE;
314
  const OrbitControls = THREE.OrbitControls;
315
  const TextGeometry = THREE.TextGeometry;
316
  const FontLoader = THREE.FontLoader;
317
 
318
- // Scene variables
319
  let scene, camera, renderer, controls, raycaster, mouse;
320
  let hashtagGroup, tooltip;
321
  let font;
322
  let mapCount = 0;
323
-
324
- // Firebase / app state
325
- let db, auth;
 
 
 
 
 
 
 
 
 
 
 
326
  let userId = null;
327
  const appId = "neuronal-1f3b9";
328
  let userProfile = null;
329
  let userMaps = {};
330
  let isAuthReady = false;
331
  let isFontReady = false;
332
- let isAnonymous = false;
333
  let userProfileCache = {};
334
  let allMapsDataCache = {};
335
  const LOAD_DISTANCE = 30.0;
@@ -339,9 +228,10 @@
339
  let minimapScale = 0.025;
340
  const MINIMAP_DOT_SIZE = 2;
341
 
342
- // Detectar si estamos en un Space estático de Hugging Face
343
  const isHFStatic = /\.hf\.space$/.test(location.hostname);
344
 
 
 
345
  const normalizeString = (str) => {
346
  if (!str) return "";
347
  return str.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
@@ -353,34 +243,32 @@
353
  function (loadedFont) {
354
  font = loadedFont;
355
  isFontReady = true;
356
- tryStartApp();
357
  },
358
  undefined,
359
  function (err) {
360
- console.error('Error al cargar la fuente 3D:', err);
361
  }
362
  );
363
 
364
  initFirebase();
365
 
366
- // Setup config UI
367
  const geminiKeyInput = document.getElementById('geminiKeyInput');
368
  const saveGeminiKeyBtn = document.getElementById('saveGeminiKeyBtn');
369
  const geminiKeyStatus = document.getElementById('geminiKeyStatus');
 
370
  if (geminiKeyInput && saveGeminiKeyBtn && geminiKeyStatus) {
371
  const existing = getLocalGeminiKey();
372
- if (existing) geminiKeyInput.value = existing;
 
 
373
  saveGeminiKeyBtn.addEventListener('click', (e) => {
374
  e.preventDefault();
375
- setLocalGeminiKey(geminiKeyInput.value.trim());
376
- geminiKeyStatus.textContent = 'Clave guardada localmente';
 
377
  setTimeout(() => { geminiKeyStatus.textContent = ''; }, 2000);
378
  });
379
- // Mostrar aviso si se está usando la clave por defecto embebida
380
- if (!localStorage.getItem('GEMINI_API_KEY')) {
381
- geminiKeyStatus.textContent = 'Usando clave por defecto embebida (no recomendado)';
382
- setTimeout(() => { geminiKeyStatus.textContent = ''; }, 4000);
383
- }
384
  }
385
 
386
  async function initFirebase() {
@@ -388,203 +276,138 @@
388
  const app = initializeApp(firebaseConfig);
389
  db = getFirestore(app);
390
  auth = getAuth(app);
 
391
  setLogLevel('Debug');
392
 
393
  const loginMessage = document.getElementById('loginMessage');
394
  const loginOverlay = document.getElementById('loginOverlay');
395
- const loginForm = document.getElementById('loginForm');
396
- const usernameForm = document.getElementById('usernameForm');
 
397
  const loginEmail = document.getElementById('loginEmail');
398
  const loginPassword = document.getElementById('loginPassword');
399
- const showPasswordCheck = document.getElementById('showPasswordCheck');
400
- const rememberMeCheck = document.getElementById('rememberMeCheck');
401
  const loginButton = document.getElementById('loginButton');
402
  const registerButton = document.getElementById('registerButton');
 
 
403
  const saveUsernameButton = document.getElementById('saveUsernameButton');
404
  const usernameInput = document.getElementById('usernameInput');
405
- const usernameMessage = document.getElementById('usernameMessage');
406
- const anonymousLoginButton = document.getElementById('anonymousLoginButton');
407
- const mainLoginButton = document.getElementById('mainLoginButton');
408
  const mainLogoutButton = document.getElementById('mainLogoutButton');
409
- const authStatus = document.getElementById('authStatus');
410
-
411
- // show/hide password
412
- showPasswordCheck.addEventListener('change', () => {
413
- loginPassword.type = showPasswordCheck.checked ? 'text' : 'password';
414
- });
415
 
416
- // register email/password
417
  registerButton.addEventListener('click', async () => {
418
  try {
419
  const email = loginEmail.value;
420
  const password = loginPassword.value;
421
  if (email.length < 6 || password.length < 6) {
422
- loginMessage.innerText = "Correo y contraseña deben tener al menos 6 caracteres.";
423
- loginMessage.classList.add('text-red-500');
424
  return;
425
  }
426
  loginMessage.innerText = "Registrando...";
427
- loginMessage.classList.remove('text-red-500');
 
428
  await createUserWithEmailAndPassword(auth, email, password);
429
  } catch (error) {
430
- console.error("Error al registrar:", error);
431
  loginMessage.innerText = `Error: ${error.message}`;
432
- loginMessage.classList.add('text-red-500');
433
  }
434
  });
435
 
436
- // login email/password
437
  loginButton.addEventListener('click', async () => {
438
  try {
439
  const email = loginEmail.value;
440
  const password = loginPassword.value;
441
- const persistence = rememberMeCheck.checked ? browserLocalPersistence : browserSessionPersistence;
442
- loginMessage.innerText = "Iniciando sesión...";
443
- loginMessage.classList.remove('text-red-500');
444
- await setPersistence(auth, persistence);
445
  await signInWithEmailAndPassword(auth, email, password);
446
  } catch (error) {
447
- console.error("Error al iniciar sesión:", error);
448
  loginMessage.innerText = `Error: ${error.message}`;
449
- loginMessage.classList.add('text-red-500');
450
  }
451
  });
452
 
453
- // (Google eliminado) Solo Email/Password y Anónimo
454
-
455
- // anonymous login (from modal)
456
- anonymousLoginButton.addEventListener('click', async () => {
457
- try {
458
- loginMessage.innerText = "Entrando como anónimo...";
459
- loginMessage.classList.remove('text-red-500');
460
- await signInAnonymously(auth);
461
- } catch (error) {
462
- console.error("Error al iniciar sesión anónima:", error);
463
- loginMessage.innerText = `Error: ${error.message}`;
464
- loginMessage.classList.add('text-red-500');
465
- }
466
  });
467
 
468
- // mainLoginButton (in UI header) -> open login modal for upgrade from anonymous or to login
469
- mainLoginButton.addEventListener('click', async () => {
470
- // Abrir modal de login y ocultar opción anónima mientras se solicita inicio de sesión
471
- if (anonymousLoginButton) anonymousLoginButton.style.display = 'none';
472
- loginOverlay.style.display = 'flex';
473
- setTimeout(() => { loginOverlay.style.opacity = '1'; }, 10);
474
- });
475
-
476
- // mainLogoutButton (in UI header)
477
- mainLogoutButton.addEventListener('click', async () => {
478
- try {
479
- await signOut(auth);
480
- } catch (err) {
481
- console.error('Error al cerrar sesión:', err);
482
- }
483
  });
484
 
485
- // (Google eliminado) No hay procesamiento de redirect
486
-
487
- // save username for new accounts (modal)
488
  saveUsernameButton.addEventListener('click', async () => {
489
- if (!userId) {
490
- usernameMessage.innerText = "Error, no se ha detectado usuario. Refresca la página.";
491
- usernameMessage.classList.add('text-red-500');
492
- return;
493
- }
494
  const username = normalizeString(usernameInput.value.trim());
495
  if (username.length < 3) {
496
- usernameMessage.innerText = "El nombre debe tener al menos 3 caracteres.";
497
- usernameMessage.classList.add('text-red-500');
498
  return;
499
  }
500
- // Establecer perfil local inmediatamente para no bloquear por red
501
- userProfile = { username };
502
- userProfileCache[userId] = userProfile;
503
- loginOverlay.style.opacity = '0';
504
- setTimeout(() => { loginOverlay.style.display = 'none'; }, 500);
505
- initScene();
506
- loadAllMaps();
507
- // Guardar en Firestore en segundo plano (si falla, seguimos)
508
- try { await saveUserProfile(userId, username); } catch (e) { console.warn('No se pudo guardar el perfil en Firestore (continuamos localmente):', e); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
509
  });
510
 
511
- // auth state changes
512
  onAuthStateChanged(auth, async (user) => {
513
  if (user) {
514
- console.log("Usuario autenticado:", user.uid);
515
  userId = user.uid;
516
  isAuthReady = true;
517
- isAnonymous = user.isAnonymous;
518
 
519
- if (authStatus) {
520
- authStatus.classList.remove('hidden');
521
- authStatus.textContent = isAnonymous ? 'Estado: Anónimo' : `Estado: Autenticado (${user.email || 'email no disponible'})`;
522
- }
523
-
524
- if (user.isAnonymous) {
525
- console.log("Usuario es anónimo (nueva sesión). Solicitar username.");
526
- // No asignamos un username automático; mostramos el formulario
527
- userProfile = null;
528
- mainLoginButton.style.display = 'none';
529
- mainLogoutButton.style.display = 'inline-block';
530
- tryStartApp();
531
- } else {
532
- console.log("Usuario registrado.");
533
- isAnonymous = false;
534
- // try fetch profile; if none, show username form
535
- await fetchUserProfile(userId);
536
- mainLoginButton.style.display = 'none';
537
- mainLogoutButton.style.display = 'inline-block';
538
- tryStartApp();
539
- }
540
- // Ocultar overlay solo si YA existe perfil de usuario.
541
- // En nuevas sesiones anónimas queremos mostrar el formulario de username
542
- // y mantener el overlay visible hasta que el usuario guarde su nombre.
543
  if (userProfile) {
544
  loginOverlay.style.opacity = '0';
545
  setTimeout(() => { loginOverlay.style.display = 'none'; }, 500);
546
- }
547
- } else {
548
- console.log("Ningún usuario autenticado.");
549
- userId = null;
550
- isAuthReady = false;
551
- isAnonymous = false;
552
- userProfile = null;
553
-
554
- // Modo solo anónimo: iniciar sesión anónima automáticamente
555
- try {
556
- await signInAnonymously(auth);
557
- console.log('Sesión anónima iniciada automáticamente.');
558
- } catch (e) {
559
- console.error('No se pudo iniciar sesión anónima automáticamente:', e);
560
- // Si falla, mostrar modal básico con opción anónima
561
- loginOverlay.style.display = 'flex';
562
- loginOverlay.style.opacity = '1';
563
- loginForm.style.display = 'block';
564
- usernameForm.style.display = 'none';
565
- loginMessage.innerText = "No se pudo iniciar sesión automática. Usa 'Explorar como Anónimo'.";
566
- loginMessage.classList.remove('text-red-500');
567
- if (anonymousLoginButton) anonymousLoginButton.style.display = 'block';
568
- mainLoginButton.style.display = 'inline-block';
569
- mainLogoutButton.style.display = 'none';
570
- if (authStatus) {
571
- authStatus.textContent = 'Estado: No autenticado';
572
- authStatus.classList.remove('hidden');
573
  }
574
  }
 
 
 
 
 
 
575
  }
576
  });
577
  } catch (error) {
578
- console.error("Error inicializando Firebase:", error);
579
- const loginMessage = document.getElementById('loginMessage');
580
- if (loginMessage) {
581
- loginMessage.innerText = "Error de conexión. Intenta recargar.";
582
- loginMessage.classList.add('text-red-500');
583
- }
584
  }
585
  }
586
 
587
- // Firestore profile helpers
588
  async function fetchUserProfile(uid) {
589
  if (!db) return;
590
  try {
@@ -593,13 +416,10 @@
593
  if (docSnap && docSnap.exists()) {
594
  userProfile = docSnap.data();
595
  userProfileCache[uid] = userProfile;
596
- console.log("Perfil de usuario cargado:", userProfile);
597
  } else {
598
- console.log("No se encontró perfil para el usuario:", uid);
599
  userProfile = null;
600
  }
601
  } catch (error) {
602
- console.error("Error al buscar perfil:", error);
603
  userProfile = null;
604
  }
605
  }
@@ -611,19 +431,14 @@
611
  await setDoc(profileDocRef, { username: username });
612
  userProfile = { username: username };
613
  userProfileCache[uid] = userProfile;
614
- console.log("Perfil de usuario guardado:", userProfile);
615
  } catch (error) {
616
- // No bloquear el uso por errores de red/offline; mantener perfil local
617
- userProfile = { username: username };
618
- userProfileCache[uid] = userProfile;
619
- console.warn("Error al guardar perfil (continuamos con perfil local):", error);
620
  }
621
  }
622
 
623
  async function getProfile(uid) {
624
- if (userProfileCache[uid]) {
625
- return userProfileCache[uid];
626
- }
627
  if (!db) return null;
628
  try {
629
  const profileDocRef = doc(db, 'artifacts', appId, 'users', uid, 'user_data', 'profile');
@@ -636,43 +451,159 @@
636
  return null;
637
  }
638
  } catch (error) {
639
- console.error("Error al buscar perfil (getProfile):", error);
640
  return null;
641
  }
642
  }
643
 
644
- function tryStartApp() {
645
- if (!isAuthReady || !isFontReady) {
646
- return;
647
- }
648
- console.log("Auth y Fuente listos. Iniciando app...");
649
- const loginOverlay = document.getElementById('loginOverlay');
650
- const loginForm = document.getElementById('loginForm');
651
- const usernameForm = document.getElementById('usernameForm');
652
- const usernameMessage = document.getElementById('usernameMessage');
653
-
654
- // Permitir visualizar el mapa de inmediato, incluso si aún no se ha elegido username
655
- // Ocultamos overlay y cargamos escena y mapas.
656
- loginOverlay.style.opacity = '0';
657
- setTimeout(() => { loginOverlay.style.display = 'none'; }, 500);
658
- initScene();
659
- loadAllMaps();
660
-
661
- // Si falta perfil, mostrar un aviso no bloqueante en consola y dejar opción de abrir el modal más tarde
662
- if (!userProfile) {
663
- console.log("Sugerencia: elige tu nombre de usuario en cualquier momento desde el modal para personalizar tu galaxia.");
664
  }
665
  }
666
 
667
- // ---------- Three.js scene setup and main app logic ----------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
668
  function initScene() {
 
669
  scene = new THREE.Scene();
670
- camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
 
 
 
671
  camera.position.set(0, 0, 15);
672
 
673
- renderer = new THREE.WebGLRenderer({ antialias: true });
674
  renderer.setSize(window.innerWidth, window.innerHeight);
675
  renderer.setPixelRatio(window.devicePixelRatio);
 
 
 
676
  document.getElementById('container').appendChild(renderer.domElement);
677
 
678
  tooltip = document.getElementById('tooltip');
@@ -695,40 +626,16 @@
695
  hashtagGroup = new THREE.Group();
696
  scene.add(hashtagGroup);
697
 
 
 
 
 
698
  const visualizeButton = document.getElementById('visualizeButton');
699
- const newNeuronButton = document.getElementById('newNeuronButton');
700
- const topicInput = document.getElementById('topicInput');
701
- const level1Slider = document.getElementById('level1Slider');
702
- const level2Slider = document.getElementById('level2Slider');
703
- const level3Slider = document.getElementById('level3Slider');
704
-
705
- // Asegurar que los campos estén habilitados siempre (modo anónimo o autenticado)
706
- if (topicInput) topicInput.disabled = false;
707
- if (level1Slider) level1Slider.disabled = false;
708
- if (level2Slider) level2Slider.disabled = false;
709
- if (level3Slider) level3Slider.disabled = false;
710
-
711
- // Permitir sembrar también en modo anónimo (no se guardará en la nube)
712
- // Evitar múltiples bindings si la escena se reinstala
713
  if (!visualizeButton.dataset.bound) {
714
  visualizeButton.dataset.originalHtml = visualizeButton.innerHTML;
715
  visualizeButton.addEventListener('click', handleAnalysisAndVisualization);
716
  visualizeButton.dataset.bound = '1';
717
  }
718
- if (isAnonymous) {
719
- console.log('Modo anónimo: podrás visualizar neuronas, pero no se guardarán en Firestore.');
720
- topicInput.placeholder = 'Ingresa un tema para sembrar (modo anónimo)';
721
- }
722
-
723
- // Botón "Nueva neurona": limpiar input y centrar cámara en tu galaxia
724
- if (newNeuronButton) {
725
- newNeuronButton.addEventListener('click', () => {
726
- const ti = document.getElementById('topicInput');
727
- if (ti) ti.value = '';
728
- focusOnUserMaps();
729
- if (ti) ti.focus();
730
- });
731
- }
732
 
733
  document.getElementById('level1Slider').addEventListener('input', (e) => {
734
  document.getElementById('level1Value').innerText = e.target.value;
@@ -747,8 +654,6 @@
747
  if (minimapCanvas) {
748
  minimapCtx = minimapCanvas.getContext('2d');
749
  minimapCanvas.addEventListener('click', onMinimapClick);
750
- } else {
751
- console.error("No se encontró el canvas del minimapa");
752
  }
753
 
754
  document.getElementById('zoomInButton').addEventListener('click', () => {
@@ -765,37 +670,33 @@
765
 
766
  function animate() {
767
  requestAnimationFrame(animate);
768
- controls.update();
769
- updateRaycaster();
770
-
771
- hashtagGroup.children.forEach(object => {
772
- if (object.userData.isText) {
773
- object.lookAt(camera.position);
774
- const distance = object.position.distanceTo(camera.position);
775
- const minScale = 0.5;
776
- const maxScale = 4.0;
777
- const scaleFactor = 10;
778
- let scale = (1 / distance) * scaleFactor;
779
- scale = Math.max(minScale, Math.min(maxScale, scale));
780
- object.scale.set(scale, scale, scale);
781
- }
782
- });
783
-
784
- hashtagGroup.children.forEach(object => {
785
- if (object.userData.isPlaceholder && !object.userData.isLoaded) {
786
- const distance = object.position.distanceTo(camera.position);
787
- if (distance < LOAD_DISTANCE) {
788
- object.userData.isLoaded = true;
789
- object.visible = false;
790
- loadFullGalaxy(object.userData.ownerId);
791
  }
792
- }
793
- });
794
-
795
- renderer.render(scene, camera);
796
  }
797
 
798
- // Fetch helper with timeout + backoff
799
  async function fetchWithTimeout(url, options = {}, timeoutMs = 25000) {
800
  const controller = new AbortController();
801
  const id = setTimeout(() => controller.abort(), timeoutMs);
@@ -820,11 +721,16 @@
820
  }
821
  }
822
 
823
- // Gemini call routed through Netlify proxy with local fallback
824
  async function callGemini(topic, mainCount, variantCount, subVariantCount) {
825
  const modelId = 'gemini-2.5-flash-preview-09-2025';
826
  const depth = 3;
827
 
 
 
 
 
 
 
828
  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.`;
829
  let userPrompt = `Tema: "${topic}".\n`;
830
 
@@ -881,11 +787,6 @@
881
  items: { type: "STRING" }
882
  };
883
  n2Items.required.push("sub_variantes");
884
- } else if (depth == 2) {
885
- userPrompt += `3. Asegurate que todo el texto no contenga acentos.`;
886
- } else {
887
- userPrompt += `1. Genera una lista de ${mainCount} palabras clave principales (Nivel 1).\n`;
888
- userPrompt += `2. Asegurate que todo el texto no contenga acentos.`;
889
  }
890
 
891
  const payload = {
@@ -899,52 +800,35 @@
899
  }
900
  };
901
 
902
- console.log("Preparando solicitud a Gemini:", payload);
903
-
904
- // 1) Intentar proxy de Netlify solo si NO estamos en Hugging Face Static
905
  if (!isHFStatic) {
906
  try {
907
  const proxyResp = await fetchWithBackoff('/.netlify/functions/gemini-proxy', {
908
  method: 'POST',
909
  headers: { 'Content-Type': 'application/json' },
910
  body: JSON.stringify({ model: modelId, payload })
911
- }, /*retries*/ 1, /*delay*/ 1000, /*timeoutMs*/ 25000);
912
  if (proxyResp.ok) {
913
  return await proxyResp.json();
914
- } else {
915
- const t = await proxyResp.text();
916
- console.warn('Proxy no disponible o error:', proxyResp.status, t);
917
  }
918
- } catch (e) {
919
- console.warn('Fallo al conectar con el proxy, usando fallback local...', e);
920
- }
921
  }
922
 
923
- // 2) Fallback to direct call with local key (only for local/dev)
924
- const localKey = getLocalGeminiKey();
925
- if (!localKey) {
926
- 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.');
927
- }
928
  const apiUrlDirect = `https://generativelanguage.googleapis.com/v1beta/models/${modelId}:generateContent?key=${localKey}`;
929
  const directResp = await fetchWithBackoff(apiUrlDirect, {
930
  method: 'POST',
931
  headers: { 'Content-Type': 'application/json' },
932
  body: JSON.stringify(payload)
933
- }, /*retries*/ 2, /*delay*/ 1000, /*timeoutMs*/ 25000);
 
934
  if (!directResp.ok) {
935
- const errorBody = await directResp.text();
936
- console.error('Error en la API de Gemini (fallback):', directResp.status, errorBody);
937
- throw new Error(`Error en la API (fallback): ${directResp.statusText}`);
938
  }
939
  return await directResp.json();
940
  }
941
 
942
- // Main handler for analysis + visualization
943
  async function handleAnalysisAndVisualization() {
944
- if (!font) {
945
- console.error("La fuente 3D no se ha cargado. Espera o refresca.");
946
- return;
947
- }
948
 
949
  const topic = normalizeString(document.getElementById('topicInput').value);
950
  const mainCount = document.getElementById('level1Slider').value;
@@ -955,26 +839,22 @@
955
  const progressBarContainer = document.getElementById('progressBarContainer');
956
  const progressBar = document.getElementById('progressBar');
957
 
958
- if (!topic) {
959
- console.warn("Por favor, introduce un tema.");
960
- return;
961
- }
962
 
963
  button.disabled = true;
964
- // Guardar HTML original si no existe y colocar estado "Analizando..."
965
  if (!button.dataset.originalHtml) button.dataset.originalHtml = button.innerHTML;
966
- button.innerHTML = 'Analizando...';
967
 
968
- // Watchdog: si algo se queda colgado, restaurar UI en 30s
969
  const watchdog = setTimeout(() => {
970
  try { button.disabled = false; } catch {}
971
  try { if (button.dataset.originalHtml) button.innerHTML = button.dataset.originalHtml; } catch {}
972
- try { progressBarContainer.style.display = 'none'; } catch {}
973
  }, 30000);
974
 
975
  progressBar.style.transition = 'none';
976
  progressBar.style.width = '0%';
977
  progressBarContainer.style.display = 'block';
 
978
  void progressBar.offsetWidth;
979
  progressBar.style.transition = 'width 20s ease-out';
980
  progressBar.style.width = '95%';
@@ -983,6 +863,7 @@
983
  const existingUserMaps = userMaps[userId] || [];
984
  const localMapIndex = existingUserMaps.length;
985
 
 
986
  if (localMapIndex === 0) {
987
  const galaxyIndex = Object.keys(userMaps).length;
988
  const GALAXY_SEPARATION_STEP = 750;
@@ -1004,11 +885,9 @@
1004
  }
1005
 
1006
  try {
1007
- console.log(`Llamando a Gemini con: ${topic}`);
1008
  const result = await callGemini(topic, mainCount, variantCount, subVariantCount);
1009
- console.log("Respuesta de Gemini recibida:", result);
1010
-
1011
  const candidate = result.candidates?.[0];
 
1012
  if (candidate && candidate.content?.parts?.[0]?.text) {
1013
  const text = candidate.content.parts[0].text;
1014
  const parsedData = JSON.parse(text);
@@ -1017,15 +896,15 @@
1017
  visualizeRoot(rootTopic, mapOrigin);
1018
  visualizeHashtags(parsedData.lista_palabras, mapOrigin, 1, null);
1019
 
1020
- // Guardar si hay sesión (incluye usuarios anónimos con uid)
1021
  if (db && userId) {
1022
  await saveMapToFirestore(rootTopic, "3", mapOrigin, parsedData);
1023
  }
1024
  } else {
1025
- throw new Error("Respuesta de Gemini inválida o vacía.");
1026
  }
1027
  } catch (error) {
1028
- console.error("Error en handleAnalysisAndVisualization:", error);
 
1029
  } finally {
1030
  clearTimeout(watchdog);
1031
  button.disabled = false;
@@ -1034,19 +913,18 @@
1034
  } else {
1035
  button.innerText = 'Sembrar';
1036
  }
1037
- // Limpiar el input para permitir nueva siembra inmediata
1038
  const ti = document.getElementById('topicInput');
1039
  if (ti) ti.value = '';
1040
  progressBar.style.transition = 'width 0.3s ease-in';
1041
  progressBar.style.width = '100%';
1042
  setTimeout(() => {
1043
  progressBarContainer.style.display = 'none';
 
1044
  progressBar.style.width = '0%';
1045
  }, 500);
1046
  }
1047
  }
1048
 
1049
- // Scene helpers (clear, visualize root, visualize hashtags, etc.)
1050
  function clearScene() {
1051
  while (hashtagGroup.children.length > 0) {
1052
  const object = hashtagGroup.children[0];
@@ -1064,20 +942,21 @@
1064
  }
1065
  hashtagGroup.remove(object);
1066
  }
1067
- console.log("Escena limpiada.");
1068
  }
1069
 
1070
  function visualizeRoot(topic, origin) {
1071
  const { color: rootColor } = stringToHslColor(topic);
1072
  const rootMaterial = new THREE.MeshStandardMaterial({
1073
  color: new THREE.Color(rootColor),
1074
- roughness: 0.5,
1075
- metalness: 0.1
 
 
1076
  });
1077
  const rootTextMaterial = new THREE.MeshBasicMaterial({
1078
  color: new THREE.Color(rootColor),
1079
  transparent: true,
1080
- opacity: 0.7
1081
  });
1082
 
1083
  const rootSphereRadius = 0.4;
@@ -1106,6 +985,43 @@
1106
  hashtagGroup.add(rootTextMesh);
1107
  }
1108
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1109
  async function saveMapToFirestore(topic, depth, origin, data) {
1110
  if (!db) return;
1111
  try {
@@ -1119,15 +1035,14 @@
1119
  userId: userId
1120
  };
1121
  await addDoc(mapCollection, mapDocument);
1122
- console.log("Mapa guardado en Firestore:", topic);
1123
  } catch (error) {
1124
- console.error("Error guardando mapa en Firestore:", error);
 
1125
  }
1126
  }
1127
 
1128
  function loadAllMaps() {
1129
  if (!db || !font) {
1130
- console.warn("Firestore o la fuente no están listos. Esperando...");
1131
  if (!font) {
1132
  setTimeout(loadAllMaps, 500);
1133
  }
@@ -1138,23 +1053,23 @@
1138
  const q = query(mapCollection);
1139
 
1140
  onSnapshot(q, async (snapshot) => {
1141
- console.log("Datos de Firestore recibidos, redibujando escena...");
1142
  clearScene();
1143
  mapCount = 0;
1144
  userMaps = {};
1145
  allMapsDataCache = {};
1146
 
1147
  if (snapshot.empty) {
1148
- console.log("No se encontraron mapas en Firestore.");
1149
  drawMinimap();
 
 
1150
  return;
1151
  }
1152
 
 
1153
  snapshot.docs.forEach((doc) => {
1154
  mapCount++;
1155
  const map = doc.data();
1156
  if (!map.origin || !map.data || !map.topic || !map.userId) {
1157
- console.warn("Documento de mapa incompleto, saltando:", doc.id);
1158
  return;
1159
  }
1160
  const origin = new THREE.Vector3(map.origin.x, map.origin.y, map.origin.z);
@@ -1167,7 +1082,6 @@
1167
  try {
1168
  parsedData = JSON.parse(map.data);
1169
  } catch (e) {
1170
- console.error("Error al parsear datos del mapa:", e, doc.id);
1171
  return;
1172
  }
1173
 
@@ -1176,94 +1090,58 @@
1176
  }
1177
  allMapsDataCache[map.userId].push({ topic: map.topic, origin: origin, data: parsedData });
1178
 
1179
- // Visualizar TODOS los mapas (propios y de otros usuarios) de inmediato
1180
  visualizeRoot(map.topic, origin);
1181
  visualizeHashtags(parsedData.lista_palabras, origin, 1, null);
1182
  });
1183
 
1184
- // (Placeholder de usuarios ya no es necesario porque se visualizan todas las constelaciones)
 
 
 
1185
 
1186
  const userListElement = document.getElementById('userList');
1187
- if (userListElement) {
1188
- userListElement.innerHTML = '';
1189
- const profilePromises = Object.keys(userMaps).map(uid => getProfile(uid));
1190
- const profiles = await Promise.all(profilePromises);
1191
- Object.keys(userMaps).forEach((uid, index) => {
1192
- const profile = profiles[index];
1193
- const username = profile ? profile.username : `Usuario ${uid.substring(0,4)}`;
 
1194
  const userItem = document.createElement('div');
1195
- userItem.className = 'p-2 mb-1 rounded-md hover:bg-gray-700 cursor-pointer transition-colors duration-200 text-sm';
1196
- userItem.innerText = username;
1197
  userItem.dataset.userid = uid;
1198
  userItem.addEventListener('click', () => teleportToUser(uid));
1199
  userListElement.appendChild(userItem);
1200
- });
1201
- }
 
 
 
 
 
 
 
 
 
 
 
1202
 
1203
  drawMinimap();
1204
- console.log(`Cargados ${mapCount} mapas.`);
1205
  focusOnUserMaps();
1206
- }, (error) => {
1207
- console.error("Error al escuchar mapas de Firestore:", error);
1208
- });
1209
- }
 
1210
 
1211
- async function visualizeUserPlaceholder(uid, centroid) {
1212
- const profile = await getProfile(uid);
1213
- const username = profile ? profile.username : 'Usuario';
1214
- const placeholderMaterial = new THREE.MeshBasicMaterial({
1215
- color: 0x00ffff,
1216
- emissive: 0x00ffff,
1217
- emissiveIntensity: 1,
1218
- wireframe: true
1219
- });
1220
- const placeholderGeometry = new THREE.SphereGeometry(0.5, 8, 8);
1221
- const placeholderSphere = new THREE.Mesh(placeholderGeometry, placeholderMaterial);
1222
- placeholderSphere.position.copy(centroid);
1223
-
1224
- placeholderSphere.userData = {
1225
- isPlaceholder: true,
1226
- ownerId: uid,
1227
- isLoaded: false,
1228
- hashtag: username
1229
- };
1230
-
1231
- const placeholderTextMaterial = new THREE.MeshBasicMaterial({ color: 0x00ffff, transparent: true, opacity: 0.7 });
1232
- const textGeometry = new TextGeometry(username, {
1233
- font: font,
1234
- size: 0.3,
1235
- height: 0.02,
1236
- curveSegments: 4,
1237
- bevelEnabled: false
1238
- });
1239
- textGeometry.computeBoundingBox();
1240
-
1241
- const textMesh = new THREE.Mesh(textGeometry, placeholderTextMaterial);
1242
- textMesh.position.y += 0.6;
1243
- textMesh.position.x -= (textGeometry.boundingBox.max.x - textGeometry.boundingBox.min.x) / 2;
1244
- textMesh.userData.isText = true;
1245
-
1246
- placeholderSphere.add(textMesh);
1247
- hashtagGroup.add(placeholderSphere);
1248
- }
1249
-
1250
- function loadFullGalaxy(ownerId) {
1251
- console.log("Cargando galaxia para:", ownerId);
1252
- const mapsToLoad = allMapsDataCache[ownerId];
1253
- if (!mapsToLoad) {
1254
- console.warn("No se encontraron datos en caché para:", ownerId);
1255
- return;
1256
- }
1257
- mapsToLoad.forEach(map => {
1258
- visualizeRoot(map.topic, map.origin);
1259
- visualizeHashtags(map.data.lista_palabras, map.origin, 1, null);
1260
- });
1261
  }
1262
 
1263
  function focusOnUserMaps() {
1264
  if (!controls) return;
1265
  if (!userId || !userMaps[userId] || userMaps[userId].length === 0) {
1266
- console.log("Usuario sin mapas o no logueado, centrando en (0,0,0).");
1267
  controls.target.set(0, 0, 0);
1268
  camera.position.set(0, 0, 15);
1269
  controls.update();
@@ -1273,7 +1151,6 @@
1273
  const centroid = new THREE.Vector3(0, 0, 0);
1274
  userMapOrigins.forEach(origin => centroid.add(origin));
1275
  centroid.divideScalar(userMapOrigins.length);
1276
- console.log(`Enfocando en la constelación del usuario en:`, centroid);
1277
  controls.target.copy(centroid);
1278
  const cameraOffset = new THREE.Vector3(0, 5, 20);
1279
  camera.position.copy(centroid).add(cameraOffset);
@@ -1282,14 +1159,12 @@
1282
 
1283
  function teleportToUser(targetUserId) {
1284
  if (!controls || !userMaps[targetUserId] || userMaps[targetUserId].length === 0) {
1285
- console.warn("No se puede teletransportar: Faltan controles o mapas para el usuario", targetUserId);
1286
  return;
1287
  }
1288
  const userMapOrigins = userMaps[targetUserId];
1289
  const centroid = new THREE.Vector3(0, 0, 0);
1290
  userMapOrigins.forEach(origin => centroid.add(origin));
1291
  centroid.divideScalar(userMapOrigins.length);
1292
- console.log(`Teletransportando a la galaxia de ${targetUserId} en:`, centroid);
1293
  controls.target.copy(centroid);
1294
  const cameraOffset = new THREE.Vector3(0, 5, 20);
1295
  camera.position.copy(centroid).add(cameraOffset);
@@ -1298,7 +1173,6 @@
1298
 
1299
  function visualizeHashtags(dataList, origin, level, parentColor = null) {
1300
  if (!dataList || dataList.length === 0) return;
1301
- console.log(`Dibujando Nivel ${level} con ${dataList.length} items.`);
1302
  for (const item of dataList) {
1303
  let currentTag, variantsList;
1304
  if (level === 1) {
@@ -1316,13 +1190,13 @@
1316
  const nodeColor = (level === 1) ? color : parentColor;
1317
  const nodeMaterial = new THREE.MeshStandardMaterial({
1318
  color: new THREE.Color(nodeColor),
1319
- roughness: 0.5,
1320
- metalness: 0.1
1321
  });
1322
  const nodeTextMaterial = new THREE.MeshBasicMaterial({
1323
  color: new THREE.Color(nodeColor),
1324
  transparent: true,
1325
- opacity: 0.7
1326
  });
1327
 
1328
  const theta = (h / 360) * Math.PI * 2;
@@ -1340,7 +1214,7 @@
1340
  clusterCenter.add(origin);
1341
 
1342
  const branchColor = new THREE.Color(nodeColor).multiplyScalar(0.4);
1343
- const lineMaterial = new THREE.LineBasicMaterial({ color: branchColor });
1344
  const linePoints = [origin, clusterCenter];
1345
  const lineGeometry = new THREE.BufferGeometry().setFromPoints(linePoints);
1346
  const line = new THREE.Line(lineGeometry, lineMaterial);
@@ -1390,16 +1264,15 @@
1390
  minimapCtx.arc(dot.x, dot.y, size, 0, Math.PI * 2);
1391
  minimapCtx.fillStyle = color;
1392
  minimapCtx.fill();
1393
- minimapCtx.fillStyle = 'white';
1394
- minimapCtx.font = '10px Orbitron';
1395
- minimapCtx.fillText(dot.username, dot.x + size + 3, dot.y + 4);
1396
  }
1397
 
1398
  async function drawMinimap() {
1399
  if (!minimapCtx || !userMaps) return;
1400
  const canvas = minimapCtx.canvas;
1401
- minimapCtx.fillStyle = '#1f2937';
1402
- minimapCtx.fillRect(0, 0, canvas.width, canvas.height);
1403
  minimapDotCoords = [];
1404
 
1405
  let centerX = 0, centerZ = 0;
@@ -1426,8 +1299,6 @@
1426
  if (numSatellites > 0) {
1427
  userMapsList.forEach(origin => userCentroid.add(origin));
1428
  userCentroid.divideScalar(numSatellites);
1429
- } else {
1430
- console.warn(`Usuario ${uid} no tiene orígenes de mapa, usando (0,0,0)`);
1431
  }
1432
  const relX = (userCentroid.x - centerX) * minimapScale;
1433
  const relZ = (userCentroid.z - centerZ) * minimapScale;
@@ -1436,7 +1307,7 @@
1436
  const dotData = { x: canvasX, y: canvasY, uid: uid, username: username };
1437
  minimapDotCoords.push(dotData);
1438
  const isMe = (uid === userId);
1439
- const mainColor = isMe ? '#fde047' : '#06b6d4';
1440
  const baseSize = MINIMAP_DOT_SIZE;
1441
  const sizeBonus = (numSatellites > 1) ? Math.log(numSatellites) * 1.5 : 0;
1442
  let mainSize = baseSize + sizeBonus;
@@ -1473,8 +1344,9 @@
1473
  const y = event.clientY - rect.top;
1474
  let clickedUser = null;
1475
  let minDistance = 10;
1476
- for (let i = minimapDotCoords.length - 1; i >= 0; i--) {
1477
  const dot = minimapDotCoords[i];
 
1478
  const dx = x - dot.x;
1479
  const dy = y - dot.y;
1480
  const distance = Math.sqrt(dx * dx + dy * dy);
@@ -1484,7 +1356,6 @@
1484
  }
1485
  }
1486
  if (clickedUser) {
1487
- console.log("Clic en minimapa sobre usuario:", clickedUser);
1488
  teleportToUser(clickedUser);
1489
  }
1490
  }
@@ -1504,23 +1375,36 @@
1504
 
1505
  function updateRaycaster() {
1506
  raycaster.setFromCamera(mouse, camera);
1507
- const objectsToIntersect = hashtagGroup.children.filter(o => o.isMesh);
 
 
 
1508
  const intersects = raycaster.intersectObjects(objectsToIntersect, false);
1509
 
1510
  if (intersects.length > 0) {
1511
  let targetObject = intersects[0].object;
 
1512
  if (targetObject.userData.isText && targetObject.parent) targetObject = targetObject.parent;
 
1513
  if (intersected.object !== targetObject) {
1514
  intersected.object = targetObject;
1515
  const data = intersected.object.userData;
1516
- tooltip.style.display = 'block';
1517
- let tooltipText = `<strong>${data.hashtag}</strong>`;
1518
- if (data.level !== undefined) tooltipText += `<br>Nivel: ${data.level}`;
1519
- else if (data.isPlaceholder) tooltipText = `<strong>Galaxia de ${data.hashtag}</strong>`;
 
 
 
 
 
 
 
 
1520
  tooltip.innerHTML = tooltipText;
1521
  }
1522
  } else {
1523
- if (intersected.object) tooltip.style.display = 'none';
1524
  intersected.object = null;
1525
  }
1526
  }
@@ -1533,4 +1417,4 @@
1533
  }
1534
  </script>
1535
  </body>
1536
- </html>
 
3
  <head>
4
  <meta charset="UTF-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
6
+ <title>Analizador de Temas 3D - Debug Mode</title>
7
  <script src="https://cdn.tailwindcss.com"></script>
8
  <link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@700&display=swap" rel="stylesheet">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  </head>
10
 
11
+ <body class="m-0 overflow-hidden bg-gray-900 font-[Orbitron]">
12
+
13
+ <div id="loginOverlay" class="fixed inset-0 bg-black/90 backdrop-blur-md z-[9999] flex items-center justify-center transition-opacity duration-500 opacity-100">
14
+ <div id="loginModal" class="bg-slate-900/95 border border-white/10 backdrop-blur-xl p-8 rounded-2xl shadow-2xl w-full max-w-md text-white relative overflow-hidden">
15
+
16
+ <div id="authStep1">
17
+ <h2 class="text-3xl font-bold text-center mb-6 text-transparent bg-clip-text bg-gradient-to-r from-green-400 to-blue-500">ECOTAGS</h2>
18
+ <p class="text-gray-400 text-center mb-8 text-xs">Explorador Semántico de Redes Neuronales</p>
 
 
 
 
 
 
 
 
 
 
 
 
 
19
 
20
+ <button id="btnGoToAnon" class="w-full bg-gray-800 hover:bg-gray-700 text-gray-300 font-bold py-3 px-4 rounded-lg transition-all border border-white/10 flex items-center justify-center gap-2 mb-6">
21
+ <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path></svg>
22
+ Entrar como Anónimo
23
+ </button>
24
+
25
+ <div class="relative flex py-2 items-center mb-4">
26
+ <div class="flex-grow border-t border-gray-700"></div>
27
+ <span class="flex-shrink-0 mx-4 text-gray-600 text-xs">O usa tu correo</span>
28
+ <div class="flex-grow border-t border-gray-700"></div>
29
  </div>
30
+
31
+ <form id="emailAuthForm" class="space-y-3">
32
+ <input type="email" id="loginEmail" class="w-full p-3 rounded-lg bg-black/30 border border-white/10 text-white placeholder-gray-500 focus:border-green-500 focus:outline-none text-sm transition-colors" placeholder="Correo electrónico">
33
+ <input type="password" id="loginPassword" class="w-full p-3 rounded-lg bg-black/30 border border-white/10 text-white placeholder-gray-500 focus:border-green-500 focus:outline-none text-sm transition-colors" placeholder="Contraseña">
34
+
35
+ <div class="flex gap-2 pt-2">
36
+ <button type="button" id="loginButton" class="flex-1 bg-blue-600/20 hover:bg-blue-600/40 text-blue-400 py-2 rounded border border-blue-500/30 text-sm font-bold transition-all">Entrar</button>
37
+ <button type="button" id="registerButton" class="flex-1 bg-green-600/20 hover:bg-green-600/40 text-green-400 py-2 rounded border border-green-500/30 text-sm font-bold transition-all">Registrar</button>
38
+ </div>
39
+ </form>
40
+
41
+ <p id="loginMessage" class="text-center text-xs mt-4 min-h-[1.5rem] text-red-400 font-bold bg-black/40 rounded p-1"></p>
42
  </div>
43
 
44
+ <div id="authStep2" class="hidden">
45
+ <h2 class="text-2xl font-bold text-white mb-2">Identidad Digital</h2>
46
+ <p class="text-gray-400 text-sm mb-6">Elige el nombre de tu Estrella Central.</p>
47
+
48
+ <input type="text" id="usernameInput" class="w-full p-4 rounded-lg bg-white/5 border border-white/10 text-white placeholder-gray-500 focus:outline-none focus:border-green-500 transition-all text-lg mb-6" placeholder="Tu Nickname">
49
+
50
+ <button id="saveUsernameButton" class="w-full bg-gradient-to-r from-green-500 to-blue-600 hover:from-green-400 hover:to-blue-500 text-white font-bold py-3 px-4 rounded-lg shadow-lg shadow-green-900/50 transition-all">
51
+ Iniciar Exploración
52
+ </button>
53
+
54
+ <button id="backToStep1" class="w-full mt-3 text-gray-500 text-xs hover:text-white transition-colors">Volver</button>
55
  </div>
56
+
57
  </div>
58
  </div>
59
 
60
+ <div id="container" class="w-screen h-screen fixed top-0 left-0 bg-gray-900"></div>
61
+
62
+ <div id="tooltip" class="absolute hidden bg-black/80 text-white px-3 py-2 rounded-lg z-[101] pointer-events-none text-sm border border-white/20 backdrop-blur-sm"></div>
63
 
64
+ <div id="ui" class="fixed top-5 left-5 z-[100] bg-slate-900/40 backdrop-blur-xl border border-white/10 p-5 rounded-2xl shadow-2xl max-w-[400px] text-white h-[calc(100vh-40px)] flex flex-col transition-all hover:bg-slate-900/60 hover:border-white/20 hidden">
65
+
66
  <div class="flex items-center justify-between mb-4">
67
+ <h1 class="text-xl font-bold text-white flex items-center space-x-2 drop-shadow-md">
68
  <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">
69
  <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" />
70
  </svg>
71
+ <span class="text-transparent bg-clip-text bg-gradient-to-r from-green-400 to-blue-500">ECOTAGS</span>
72
  </h1>
 
73
  <div class="flex items-center gap-2">
74
+ <span id="authStatus" class="text-[10px] text-gray-400 uppercase tracking-wider"></span>
75
+ <button id="mainLogoutButton" class="bg-red-500/20 hover:bg-red-500/40 text-red-300 border border-red-500/30 px-2 py-1 rounded text-[10px] backdrop-blur-sm transition-colors">Salir</button>
 
 
 
76
  </div>
77
  </div>
78
 
79
+ <input type="text" id="topicInput" class="w-full p-3 rounded-lg bg-black/20 text-white border border-white/10 focus:outline-none focus:border-green-400 focus:bg-black/40 mb-4 transition-all placeholder-gray-500 backdrop-blur-sm" placeholder="Escribe hashtags...">
 
 
 
 
 
 
80
 
81
+ <div class="space-y-3 mb-4">
82
+ <div>
83
+ <label class="text-xs text-gray-400 flex justify-between">Nivel 1 <span id="level1Value" class="text-green-400">10</span></label>
84
+ <input type="range" id="level1Slider" min="1" max="15" value="10" class="w-full h-1 bg-gray-700 rounded-lg appearance-none cursor-pointer accent-green-500 hover:accent-green-400">
85
+ </div>
86
+ <div>
87
+ <label class="text-xs text-gray-400 flex justify-between">Nivel 2 <span id="level2Value" class="text-green-400">5</span></label>
88
+ <input type="range" id="level2Slider" min="5" max="8" value="5" class="w-full h-1 bg-gray-700 rounded-lg appearance-none cursor-pointer accent-green-500 hover:accent-green-400">
89
+ </div>
90
+ <div>
91
+ <label class="text-xs text-gray-400 flex justify-between">Nivel 3 <span id="level3Value" class="text-green-400">3</span></label>
92
+ <input type="range" id="level3Slider" min="1" max="3" value="3" class="w-full h-1 bg-gray-700 rounded-lg appearance-none cursor-pointer accent-green-500 hover:accent-green-400">
93
+ </div>
94
+ </div>
95
 
96
+ <details class="mb-4 bg-black/20 rounded-lg border border-white/5" open>
97
+ <summary class="cursor-pointer text-xs text-blue-300 font-bold p-2 uppercase tracking-wide hover:text-blue-200">Configuración API</summary>
98
+ <div class="p-3 space-y-2 text-xs">
99
  <div>
100
+ <label class="block text-gray-400 mb-1">Gemini API Key (Requerida)</label>
101
+ <input type="password" id="geminiKeyInput" class="w-full p-2 rounded bg-black/30 text-white border border-white/10 focus:border-blue-400 focus:outline-none" placeholder="Pega tu API Key aquí...">
102
  </div>
103
+ <div class="flex items-center gap-2 justify-end">
104
+ <span id="geminiKeyStatus" class="text-green-400 italic"></span>
105
+ <button id="saveGeminiKeyBtn" class="bg-white/10 hover:bg-white/20 px-3 py-1 rounded text-white border border-white/10 transition-colors">Guardar</button>
106
  </div>
 
107
  </div>
108
  </details>
109
 
110
+ <div class="flex gap-2 mb-2">
111
+ <button id="visualizeButton" class="w-full bg-gradient-to-r from-green-600 to-green-500 hover:from-green-500 hover:to-green-400 text-white font-bold py-3 px-4 rounded-xl shadow-lg shadow-green-900/50 transition-all flex items-center justify-center space-x-2 border border-white/10">
112
+ <svg class="w-5 h-5 animate-pulse" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
113
+ <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
114
+ </svg>
115
+ <span>Sembrar Galaxia</span>
 
116
  </button>
117
  </div>
118
 
119
+ <div id="progressBarContainer" class="w-full bg-gray-700/50 rounded-full h-1.5 mb-4 overflow-hidden hidden">
120
+ <div id="progressBar" class="bg-green-500 h-1.5 w-0 shadow-[0_0_10px_rgba(34,197,94,0.8)]"></div>
121
+ </div>
122
 
123
+ <div id="userListContainer" class="flex-1 flex flex-col min-h-0 bg-black/20 rounded-lg p-2 border border-white/5 mb-4">
124
+ <h2 class="font-bold text-xs text-blue-300 mb-2 uppercase tracking-wider sticky top-0 bg-transparent">Exploradores</h2>
125
+ <div id="userList" class="overflow-y-auto pr-1 space-y-1 custom-scrollbar text-xs"></div>
126
  </div>
127
 
128
+ <div id="minimapContainer" class="shrink-0 relative">
129
+ <div class="flex justify-between items-center mb-1 absolute top-2 right-2 z-10 gap-1">
130
+ <button id="zoomOutButton" class="w-6 h-6 bg-black/50 hover:bg-black/70 rounded border border-white/20 text-white flex items-center justify-center">-</button>
131
+ <button id="zoomInButton" class="w-6 h-6 bg-black/50 hover:bg-black/70 rounded border border-white/20 text-white flex items-center justify-center">+</button>
 
 
 
132
  </div>
133
+ <canvas id="minimap" width="360" height="150" class="w-full h-[150px] bg-black/40 rounded-lg border border-white/10 cursor-crosshair"></canvas>
134
+ <div class="text-[10px] text-center text-gray-500 mt-1">Minimapa Galáctico</div>
135
  </div>
136
  </div>
137
 
 
142
 
143
  <script type="module">
144
  import { initializeApp } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-app.js";
145
+ import { getAnalytics } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-analytics.js";
146
  import {
147
  getAuth,
148
  onAuthStateChanged,
149
  createUserWithEmailAndPassword,
150
  signInWithEmailAndPassword,
 
 
 
151
  signInAnonymously,
152
+ signOut,
153
+ setPersistence,
154
+ browserLocalPersistence
155
  } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-auth.js";
156
  import {
157
  getFirestore,
 
165
  setLogLevel
166
  } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-firestore.js";
167
 
 
168
  const firebaseConfig = {
169
  apiKey: "AIzaSyB4EZKkAH3weFSZNBQA8Y63gt5XbaeZGsQ",
170
  authDomain: "neuronal-1f3b9.firebaseapp.com",
 
175
  measurementId: "G-102SEBLQFJ"
176
  };
177
 
178
+ // --- MANEJO DE API KEY ---
179
+ const DEFAULT_GEMINI_KEY = 'TU_API_KEY_AQUI'; // Reemplazala si quieres hardcodear, pero mejor usa el UI
 
 
180
  function getLocalGeminiKey() {
181
  try {
182
  const k = localStorage.getItem('GEMINI_API_KEY') || '';
 
188
  function setLocalGeminiKey(k) {
189
  try { localStorage.setItem('GEMINI_API_KEY', k || ''); } catch {}
190
  }
191
+ // -------------------------
192
 
 
193
  const THREE = window.THREE;
194
  const OrbitControls = THREE.OrbitControls;
195
  const TextGeometry = THREE.TextGeometry;
196
  const FontLoader = THREE.FontLoader;
197
 
 
198
  let scene, camera, renderer, controls, raycaster, mouse;
199
  let hashtagGroup, tooltip;
200
  let font;
201
  let mapCount = 0;
202
+ const clock = new THREE.Clock(); // Reloj para animaciones suaves
203
+
204
+ // --- VARIABLES DEL COMETA Y FONDO ---
205
+ let starsSystem;
206
+ let cometGroup, cometHead, cometTextMesh, cometTailPoints;
207
+ let cometAngle = 0;
208
+ let userCentroidForComet = new THREE.Vector3(0,0,0);
209
+ // Lista de palabras aleatorias para el cometa
210
+ const COMET_WORDS = ["CURIOSIDAD", "EXPLORA", "DESCUBRE", "NEXO", "SINTAXIS", "IDEA", "FUTURO", "ORBITA", "COSMOS", "ENLACE", "RED", "NODO"];
211
+ const COMET_TAIL_LENGTH = 30; // Longitud de la cola
212
+ const cometTailPositions = [];
213
+ // ------------------------------------
214
+
215
+ let db, auth, analytics;
216
  let userId = null;
217
  const appId = "neuronal-1f3b9";
218
  let userProfile = null;
219
  let userMaps = {};
220
  let isAuthReady = false;
221
  let isFontReady = false;
 
222
  let userProfileCache = {};
223
  let allMapsDataCache = {};
224
  const LOAD_DISTANCE = 30.0;
 
228
  let minimapScale = 0.025;
229
  const MINIMAP_DOT_SIZE = 2;
230
 
 
231
  const isHFStatic = /\.hf\.space$/.test(location.hostname);
232
 
233
+ let authMode = null;
234
+
235
  const normalizeString = (str) => {
236
  if (!str) return "";
237
  return str.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
 
243
  function (loadedFont) {
244
  font = loadedFont;
245
  isFontReady = true;
246
+ checkAppReady();
247
  },
248
  undefined,
249
  function (err) {
250
+ console.error(err);
251
  }
252
  );
253
 
254
  initFirebase();
255
 
 
256
  const geminiKeyInput = document.getElementById('geminiKeyInput');
257
  const saveGeminiKeyBtn = document.getElementById('saveGeminiKeyBtn');
258
  const geminiKeyStatus = document.getElementById('geminiKeyStatus');
259
+
260
  if (geminiKeyInput && saveGeminiKeyBtn && geminiKeyStatus) {
261
  const existing = getLocalGeminiKey();
262
+ if (existing && existing !== DEFAULT_GEMINI_KEY) {
263
+ geminiKeyInput.value = existing;
264
+ }
265
  saveGeminiKeyBtn.addEventListener('click', (e) => {
266
  e.preventDefault();
267
+ const newVal = geminiKeyInput.value.trim();
268
+ setLocalGeminiKey(newVal);
269
+ geminiKeyStatus.textContent = 'Guardado';
270
  setTimeout(() => { geminiKeyStatus.textContent = ''; }, 2000);
271
  });
 
 
 
 
 
272
  }
273
 
274
  async function initFirebase() {
 
276
  const app = initializeApp(firebaseConfig);
277
  db = getFirestore(app);
278
  auth = getAuth(app);
279
+ analytics = getAnalytics(app);
280
  setLogLevel('Debug');
281
 
282
  const loginMessage = document.getElementById('loginMessage');
283
  const loginOverlay = document.getElementById('loginOverlay');
284
+ const authStep1 = document.getElementById('authStep1');
285
+ const authStep2 = document.getElementById('authStep2');
286
+
287
  const loginEmail = document.getElementById('loginEmail');
288
  const loginPassword = document.getElementById('loginPassword');
 
 
289
  const loginButton = document.getElementById('loginButton');
290
  const registerButton = document.getElementById('registerButton');
291
+
292
+ const btnGoToAnon = document.getElementById('btnGoToAnon');
293
  const saveUsernameButton = document.getElementById('saveUsernameButton');
294
  const usernameInput = document.getElementById('usernameInput');
295
+ const backToStep1 = document.getElementById('backToStep1');
 
 
296
  const mainLogoutButton = document.getElementById('mainLogoutButton');
 
 
 
 
 
 
297
 
 
298
  registerButton.addEventListener('click', async () => {
299
  try {
300
  const email = loginEmail.value;
301
  const password = loginPassword.value;
302
  if (email.length < 6 || password.length < 6) {
303
+ loginMessage.innerText = "Mínimo 6 caracteres.";
 
304
  return;
305
  }
306
  loginMessage.innerText = "Registrando...";
307
+ authMode = 'email';
308
+ await setPersistence(auth, browserLocalPersistence);
309
  await createUserWithEmailAndPassword(auth, email, password);
310
  } catch (error) {
 
311
  loginMessage.innerText = `Error: ${error.message}`;
 
312
  }
313
  });
314
 
 
315
  loginButton.addEventListener('click', async () => {
316
  try {
317
  const email = loginEmail.value;
318
  const password = loginPassword.value;
319
+ loginMessage.innerText = "Entrando...";
320
+ authMode = 'email';
321
+ await setPersistence(auth, browserLocalPersistence);
 
322
  await signInWithEmailAndPassword(auth, email, password);
323
  } catch (error) {
 
324
  loginMessage.innerText = `Error: ${error.message}`;
 
325
  }
326
  });
327
 
328
+ btnGoToAnon.addEventListener('click', () => {
329
+ authMode = 'anonymous';
330
+ authStep1.classList.add('hidden');
331
+ authStep2.classList.remove('hidden');
 
 
 
 
 
 
 
 
 
332
  });
333
 
334
+ backToStep1.addEventListener('click', () => {
335
+ authMode = null;
336
+ authStep2.classList.add('hidden');
337
+ authStep1.classList.remove('hidden');
338
+ loginMessage.innerText = "";
 
 
 
 
 
 
 
 
 
 
339
  });
340
 
 
 
 
341
  saveUsernameButton.addEventListener('click', async () => {
 
 
 
 
 
342
  const username = normalizeString(usernameInput.value.trim());
343
  if (username.length < 3) {
344
+ usernameInput.classList.add('border-red-500');
 
345
  return;
346
  }
347
+ usernameInput.classList.remove('border-red-500');
348
+ saveUsernameButton.innerHTML = "Creando Identidad...";
349
+ saveUsernameButton.disabled = true;
350
+
351
+ try {
352
+ if (authMode === 'anonymous') {
353
+ await setPersistence(auth, browserLocalPersistence);
354
+ await signInAnonymously(auth);
355
+ }
356
+ if (auth.currentUser) {
357
+ await saveUserProfile(auth.currentUser.uid, username);
358
+ loginOverlay.style.opacity = '0';
359
+ setTimeout(() => { loginOverlay.style.display = 'none'; }, 500);
360
+ initScene();
361
+ loadAllMaps();
362
+ document.getElementById('ui').classList.remove('hidden');
363
+ }
364
+ } catch (e) {
365
+ saveUsernameButton.innerHTML = "Error. Intenta de nuevo.";
366
+ saveUsernameButton.disabled = false;
367
+ console.error(e);
368
+ }
369
+ });
370
+
371
+ mainLogoutButton.addEventListener('click', async () => {
372
+ await signOut(auth);
373
+ location.reload();
374
  });
375
 
 
376
  onAuthStateChanged(auth, async (user) => {
377
  if (user) {
 
378
  userId = user.uid;
379
  isAuthReady = true;
380
+ await fetchUserProfile(userId);
381
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
382
  if (userProfile) {
383
  loginOverlay.style.opacity = '0';
384
  setTimeout(() => { loginOverlay.style.display = 'none'; }, 500);
385
+ document.getElementById('ui').classList.remove('hidden');
386
+ document.getElementById('authStatus').textContent = userProfile.username;
387
+ if (!scene) {
388
+ initScene();
389
+ loadAllMaps();
390
+ }
391
+ } else {
392
+ authStep1.classList.add('hidden');
393
+ authStep2.classList.remove('hidden');
394
+ if (authMode === 'email') {
395
+ usernameInput.focus();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
396
  }
397
  }
398
+ } else {
399
+ loginOverlay.style.display = 'flex';
400
+ loginOverlay.style.opacity = '1';
401
+ authStep1.classList.remove('hidden');
402
+ authStep2.classList.add('hidden');
403
+ document.getElementById('ui').classList.add('hidden');
404
  }
405
  });
406
  } catch (error) {
407
+ console.error("Firebase Init Error", error);
 
 
 
 
 
408
  }
409
  }
410
 
 
411
  async function fetchUserProfile(uid) {
412
  if (!db) return;
413
  try {
 
416
  if (docSnap && docSnap.exists()) {
417
  userProfile = docSnap.data();
418
  userProfileCache[uid] = userProfile;
 
419
  } else {
 
420
  userProfile = null;
421
  }
422
  } catch (error) {
 
423
  userProfile = null;
424
  }
425
  }
 
431
  await setDoc(profileDocRef, { username: username });
432
  userProfile = { username: username };
433
  userProfileCache[uid] = userProfile;
434
+ document.getElementById('authStatus').textContent = username;
435
  } catch (error) {
436
+ console.error(error);
 
 
 
437
  }
438
  }
439
 
440
  async function getProfile(uid) {
441
+ if (userProfileCache[uid]) return userProfileCache[uid];
 
 
442
  if (!db) return null;
443
  try {
444
  const profileDocRef = doc(db, 'artifacts', appId, 'users', uid, 'user_data', 'profile');
 
451
  return null;
452
  }
453
  } catch (error) {
 
454
  return null;
455
  }
456
  }
457
 
458
+ function checkAppReady() {
459
+ if (isAuthReady && isFontReady && userProfile) {
460
+ initScene();
461
+ loadAllMaps();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
462
  }
463
  }
464
 
465
+ // --- FUNCIÓN PARA CREAR ESTRELLAS DE FONDO ---
466
+ function createBackgroundStars() {
467
+ const geometry = new THREE.BufferGeometry();
468
+ const vertices = [];
469
+ // Crear 5000 estrellas distribuidas aleatoriamente en un volumen grande
470
+ for (let i = 0; i < 5000; i++) {
471
+ vertices.push(THREE.MathUtils.randFloatSpread(2000)); // x spread
472
+ vertices.push(THREE.MathUtils.randFloatSpread(2000)); // y spread
473
+ vertices.push(THREE.MathUtils.randFloatSpread(2000)); // z spread
474
+ }
475
+ geometry.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3));
476
+ // Material simple de puntos blancos
477
+ const material = new THREE.PointsMaterial({ color: 0xaaaaaa, size: 0.7, transparent: true, opacity: 0.6, sizeAttenuation: true });
478
+ starsSystem = new THREE.Points(geometry, material);
479
+ scene.add(starsSystem);
480
+ }
481
+
482
+ // --- FUNCIÓN PARA CALCULAR EL CENTROIDE DEL USUARIO ACTUAL ---
483
+ function getCurrentUserCentroid() {
484
+ if (!userId || !userMaps[userId] || userMaps[userId].length === 0) return new THREE.Vector3(0,0,0);
485
+ const centroid = new THREE.Vector3(0,0,0);
486
+ userMaps[userId].forEach(o => centroid.add(o));
487
+ centroid.divideScalar(userMaps[userId].length);
488
+ return centroid;
489
+ }
490
+
491
+ // --- FUNCIÓN PARA INICIALIZAR EL COMETA ---
492
+ function initComet() {
493
+ // Limpiar cometa anterior si existe
494
+ if(cometGroup) { scene.remove(cometGroup); cometGroup = null; }
495
+ if(cometTailPoints) { scene.remove(cometTailPoints); cometTailPoints = null; }
496
+
497
+ if(!font || !userId) return;
498
+
499
+ // Calcular el centro de la órbita basado en los mapas del usuario
500
+ userCentroidForComet.copy(getCurrentUserCentroid());
501
+
502
+ cometGroup = new THREE.Group();
503
+
504
+ // 1. Cabeza del Cometa (Esfera brillante)
505
+ const headGeo = new THREE.SphereGeometry(0.8, 32, 32);
506
+ const headMat = new THREE.MeshStandardMaterial({
507
+ color: 0xffffff,
508
+ emissive: 0x00ffff, // Color cian brillante
509
+ emissiveIntensity: 5, // Intensidad alta para que brille
510
+ toneMapped: false // Evita que el tone mapping apague el brillo
511
+ });
512
+ cometHead = new THREE.Mesh(headGeo, headMat);
513
+ cometGroup.add(cometHead);
514
+
515
+ // 2. Texto del Cometa (Palabra aleatoria)
516
+ const word = COMET_WORDS[Math.floor(Math.random() * COMET_WORDS.length)];
517
+ const textGeo = new TextGeometry(word, { font: font, size: 0.5, height: 0.05, curveSegments: 4, bevelEnabled: false });
518
+ textGeo.center();
519
+ const textMat = new THREE.MeshBasicMaterial({ color: 0xccffff, transparent: true, opacity: 0.9 });
520
+ cometTextMesh = new THREE.Mesh(textGeo, textMat);
521
+ cometTextMesh.position.y = 1.5; // Flota sobre la cabeza
522
+ cometGroup.add(cometTextMesh);
523
+
524
+ // 3. Cola del Cometa (Sistema de Partículas)
525
+ const tailGeo = new THREE.BufferGeometry();
526
+ // Crear array para las posiciones de las partículas de la cola
527
+ const tailPositionsArray = new Float32Array(COMET_TAIL_LENGTH * 3);
528
+ tailGeo.setAttribute('position', new THREE.BufferAttribute(tailPositionsArray, 3));
529
+
530
+ // Material para la cola: puntos brillantes con blending aditivo para efecto de brillo
531
+ const tailMat = new THREE.PointsMaterial({
532
+ color: 0x00aaff,
533
+ size: 1.2,
534
+ transparent: true,
535
+ opacity: 0.7,
536
+ blending: THREE.AdditiveBlending,
537
+ depthWrite: false, // Importante para transparencias superpuestas
538
+ sizeAttenuation: true
539
+ });
540
+ cometTailPoints = new THREE.Points(tailGeo, tailMat);
541
+ // La cola se añade directamente a la escena, no al grupo, para que no rote con la cabeza
542
+ scene.add(cometTailPoints);
543
+
544
+ scene.add(cometGroup);
545
+
546
+ // Inicializar el historial de posiciones de la cola
547
+ cometTailPositions.length = 0;
548
+ for(let i=0; i<COMET_TAIL_LENGTH; i++) cometTailPositions.push(new THREE.Vector3());
549
+ }
550
+
551
+ // --- FUNCIÓN PARA ACTUALIZAR LA ANIMACIÓN DEL COMETA ---
552
+ function updateComet(delta) {
553
+ if(!cometGroup || !cometHead || !cometTailPoints) return;
554
+
555
+ // 1. Lógica Orbital
556
+ cometAngle += delta * 0.3; // Velocidad de órbita (ajustar según necesidad)
557
+
558
+ // Radios de la órbita elíptica
559
+ const orbitRadiusX = 60;
560
+ const orbitRadiusZ = 45;
561
+
562
+ // Calcular nueva posición relativa al centroide del usuario
563
+ const x = userCentroidForComet.x + Math.cos(cometAngle) * orbitRadiusX;
564
+ const z = userCentroidForComet.z + Math.sin(cometAngle) * orbitRadiusZ;
565
+ // Añadir una onda vertical senoidal para movimiento más dinámico
566
+ const y = userCentroidForComet.y + Math.sin(cometAngle * 3) * 10;
567
+
568
+ cometGroup.position.set(x, y, z);
569
+
570
+ // Hacer que el texto del cometa siempre mire a la cámara
571
+ if(cometTextMesh) cometTextMesh.lookAt(camera.position);
572
+
573
+ // 2. Actualización de la Cola
574
+ // Eliminar la posición más antigua y añadir la posición actual de la cabeza al inicio
575
+ cometTailPositions.pop();
576
+ cometTailPositions.unshift(cometGroup.position.clone());
577
+
578
+ // Actualizar los vértices de la geometría de puntos de la cola
579
+ const positionsAttr = cometTailPoints.geometry.attributes.position;
580
+ const positionsArray = positionsAttr.array;
581
+
582
+ for(let i=0; i < COMET_TAIL_LENGTH; i++) {
583
+ const pos = cometTailPositions[i];
584
+ positionsArray[i*3] = pos.x;
585
+ positionsArray[i*3+1] = pos.y;
586
+ positionsArray[i*3+2] = pos.z;
587
+ }
588
+ // Marcar que la geometría necesita actualización
589
+ positionsAttr.needsUpdate = true;
590
+ }
591
+
592
  function initScene() {
593
+ if (scene) return;
594
  scene = new THREE.Scene();
595
+ // Fog ligeramente más denso para ocultar el horizonte de estrellas
596
+ scene.fog = new THREE.FogExp2(0x111827, 0.008);
597
+
598
+ camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 2000); // Far plane aumentado para ver estrellas
599
  camera.position.set(0, 0, 15);
600
 
601
+ renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
602
  renderer.setSize(window.innerWidth, window.innerHeight);
603
  renderer.setPixelRatio(window.devicePixelRatio);
604
+ // Habilitar manejo de color correcto para brillos intensos
605
+ renderer.toneMapping = THREE.ACESFilmicToneMapping;
606
+ renderer.toneMappingExposure = 1.0;
607
  document.getElementById('container').appendChild(renderer.domElement);
608
 
609
  tooltip = document.getElementById('tooltip');
 
626
  hashtagGroup = new THREE.Group();
627
  scene.add(hashtagGroup);
628
 
629
+ // --- AÑADIR ESTRELLAS DE FONDO ---
630
+ createBackgroundStars();
631
+ // --------------------------------
632
+
633
  const visualizeButton = document.getElementById('visualizeButton');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
634
  if (!visualizeButton.dataset.bound) {
635
  visualizeButton.dataset.originalHtml = visualizeButton.innerHTML;
636
  visualizeButton.addEventListener('click', handleAnalysisAndVisualization);
637
  visualizeButton.dataset.bound = '1';
638
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
639
 
640
  document.getElementById('level1Slider').addEventListener('input', (e) => {
641
  document.getElementById('level1Value').innerText = e.target.value;
 
654
  if (minimapCanvas) {
655
  minimapCtx = minimapCanvas.getContext('2d');
656
  minimapCanvas.addEventListener('click', onMinimapClick);
 
 
657
  }
658
 
659
  document.getElementById('zoomInButton').addEventListener('click', () => {
 
670
 
671
  function animate() {
672
  requestAnimationFrame(animate);
673
+
674
+ const delta = clock.getDelta(); // Obtener tiempo transcurrido para animación suave
675
+
676
+ if(controls) controls.update();
677
+
678
+ // --- ACTUALIZAR COMETA ---
679
+ updateComet(delta);
680
+ // ------------------------
681
+
682
+ if(camera && hashtagGroup) {
683
+ updateRaycaster();
684
+ hashtagGroup.children.forEach(object => {
685
+ if (object.userData.isText) {
686
+ object.lookAt(camera.position);
687
+ const distance = object.position.distanceTo(camera.position);
688
+ const minScale = 0.5;
689
+ const maxScale = 4.0;
690
+ const scaleFactor = 10;
691
+ let scale = (1 / distance) * scaleFactor;
692
+ scale = Math.max(minScale, Math.min(maxScale, scale));
693
+ object.scale.set(scale, scale, scale);
 
 
694
  }
695
+ });
696
+ renderer.render(scene, camera);
697
+ }
 
698
  }
699
 
 
700
  async function fetchWithTimeout(url, options = {}, timeoutMs = 25000) {
701
  const controller = new AbortController();
702
  const id = setTimeout(() => controller.abort(), timeoutMs);
 
721
  }
722
  }
723
 
 
724
  async function callGemini(topic, mainCount, variantCount, subVariantCount) {
725
  const modelId = 'gemini-2.5-flash-preview-09-2025';
726
  const depth = 3;
727
 
728
+ // VALIDACIÓN DE API KEY ANTES DE LLAMAR
729
+ const localKey = getLocalGeminiKey();
730
+ if (!localKey || localKey.length < 10) {
731
+ throw new Error("Falta la API Key de Gemini. Configúrala en el menú.");
732
+ }
733
+
734
  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.`;
735
  let userPrompt = `Tema: "${topic}".\n`;
736
 
 
787
  items: { type: "STRING" }
788
  };
789
  n2Items.required.push("sub_variantes");
 
 
 
 
 
790
  }
791
 
792
  const payload = {
 
800
  }
801
  };
802
 
 
 
 
803
  if (!isHFStatic) {
804
  try {
805
  const proxyResp = await fetchWithBackoff('/.netlify/functions/gemini-proxy', {
806
  method: 'POST',
807
  headers: { 'Content-Type': 'application/json' },
808
  body: JSON.stringify({ model: modelId, payload })
809
+ }, 1, 1000, 25000);
810
  if (proxyResp.ok) {
811
  return await proxyResp.json();
 
 
 
812
  }
813
+ } catch (e) {}
 
 
814
  }
815
 
 
 
 
 
 
816
  const apiUrlDirect = `https://generativelanguage.googleapis.com/v1beta/models/${modelId}:generateContent?key=${localKey}`;
817
  const directResp = await fetchWithBackoff(apiUrlDirect, {
818
  method: 'POST',
819
  headers: { 'Content-Type': 'application/json' },
820
  body: JSON.stringify(payload)
821
+ }, 2, 1000, 25000);
822
+
823
  if (!directResp.ok) {
824
+ const errText = await directResp.text();
825
+ throw new Error(`Error API (${directResp.status}): ${errText}`);
 
826
  }
827
  return await directResp.json();
828
  }
829
 
 
830
  async function handleAnalysisAndVisualization() {
831
+ if (!font) return;
 
 
 
832
 
833
  const topic = normalizeString(document.getElementById('topicInput').value);
834
  const mainCount = document.getElementById('level1Slider').value;
 
839
  const progressBarContainer = document.getElementById('progressBarContainer');
840
  const progressBar = document.getElementById('progressBar');
841
 
842
+ if (!topic) return;
 
 
 
843
 
844
  button.disabled = true;
 
845
  if (!button.dataset.originalHtml) button.dataset.originalHtml = button.innerHTML;
846
+ button.innerHTML = '<span class="animate-pulse">Analizando...</span>';
847
 
 
848
  const watchdog = setTimeout(() => {
849
  try { button.disabled = false; } catch {}
850
  try { if (button.dataset.originalHtml) button.innerHTML = button.dataset.originalHtml; } catch {}
851
+ try { progressBarContainer.style.display = 'none'; progressBarContainer.classList.add('hidden'); } catch {}
852
  }, 30000);
853
 
854
  progressBar.style.transition = 'none';
855
  progressBar.style.width = '0%';
856
  progressBarContainer.style.display = 'block';
857
+ progressBarContainer.classList.remove('hidden');
858
  void progressBar.offsetWidth;
859
  progressBar.style.transition = 'width 20s ease-out';
860
  progressBar.style.width = '95%';
 
863
  const existingUserMaps = userMaps[userId] || [];
864
  const localMapIndex = existingUserMaps.length;
865
 
866
+ // Cálculo de posición de la nueva galaxia
867
  if (localMapIndex === 0) {
868
  const galaxyIndex = Object.keys(userMaps).length;
869
  const GALAXY_SEPARATION_STEP = 750;
 
885
  }
886
 
887
  try {
 
888
  const result = await callGemini(topic, mainCount, variantCount, subVariantCount);
 
 
889
  const candidate = result.candidates?.[0];
890
+
891
  if (candidate && candidate.content?.parts?.[0]?.text) {
892
  const text = candidate.content.parts[0].text;
893
  const parsedData = JSON.parse(text);
 
896
  visualizeRoot(rootTopic, mapOrigin);
897
  visualizeHashtags(parsedData.lista_palabras, mapOrigin, 1, null);
898
 
 
899
  if (db && userId) {
900
  await saveMapToFirestore(rootTopic, "3", mapOrigin, parsedData);
901
  }
902
  } else {
903
+ throw new Error("La IA no devolvió texto válido.");
904
  }
905
  } catch (error) {
906
+ console.error("ERROR CRITICO:", error);
907
+ alert("ERROR: " + error.message + "\n\nRevisa tu API Key en Configuración.");
908
  } finally {
909
  clearTimeout(watchdog);
910
  button.disabled = false;
 
913
  } else {
914
  button.innerText = 'Sembrar';
915
  }
 
916
  const ti = document.getElementById('topicInput');
917
  if (ti) ti.value = '';
918
  progressBar.style.transition = 'width 0.3s ease-in';
919
  progressBar.style.width = '100%';
920
  setTimeout(() => {
921
  progressBarContainer.style.display = 'none';
922
+ progressBarContainer.classList.add('hidden');
923
  progressBar.style.width = '0%';
924
  }, 500);
925
  }
926
  }
927
 
 
928
  function clearScene() {
929
  while (hashtagGroup.children.length > 0) {
930
  const object = hashtagGroup.children[0];
 
942
  }
943
  hashtagGroup.remove(object);
944
  }
 
945
  }
946
 
947
  function visualizeRoot(topic, origin) {
948
  const { color: rootColor } = stringToHslColor(topic);
949
  const rootMaterial = new THREE.MeshStandardMaterial({
950
  color: new THREE.Color(rootColor),
951
+ roughness: 0.2,
952
+ metalness: 0.8,
953
+ emissive: new THREE.Color(rootColor),
954
+ emissiveIntensity: 0.2
955
  });
956
  const rootTextMaterial = new THREE.MeshBasicMaterial({
957
  color: new THREE.Color(rootColor),
958
  transparent: true,
959
+ opacity: 0.9
960
  });
961
 
962
  const rootSphereRadius = 0.4;
 
985
  hashtagGroup.add(rootTextMesh);
986
  }
987
 
988
+ function createUserSun(centerPosition, username, isCurrentUser) {
989
+ const sunColor = isCurrentUser ? 0xffd700 : 0x4ade80; // Oro para actual, Verde para otros
990
+ const sunGeometry = new THREE.SphereGeometry(2.5, 32, 32);
991
+ const sunMaterial = new THREE.MeshStandardMaterial({
992
+ color: sunColor,
993
+ emissive: sunColor,
994
+ emissiveIntensity: 0.8,
995
+ roughness: 0.1,
996
+ metalness: 0.2
997
+ });
998
+
999
+ const sunMesh = new THREE.Mesh(sunGeometry, sunMaterial);
1000
+ sunMesh.position.copy(centerPosition);
1001
+ sunMesh.userData.hashtag = `Usuario: ${username}`;
1002
+ sunMesh.userData.isPlaceholder = true; // Tratamos como placeholder para tooltip
1003
+ hashtagGroup.add(sunMesh);
1004
+
1005
+ // Texto del Usuario
1006
+ const textMaterial = new THREE.MeshBasicMaterial({ color: 0xffffff });
1007
+ const textGeometry = new TextGeometry(username.toUpperCase(), {
1008
+ font: font,
1009
+ size: 0.8,
1010
+ height: 0.1,
1011
+ curveSegments: 4,
1012
+ bevelEnabled: false
1013
+ });
1014
+ textGeometry.computeBoundingBox();
1015
+ const textMesh = new THREE.Mesh(textGeometry, textMaterial);
1016
+
1017
+ textMesh.position.copy(centerPosition);
1018
+ textMesh.position.y += 3.5; // Flota sobre el sol
1019
+ textMesh.position.x -= (textGeometry.boundingBox.max.x - textGeometry.boundingBox.min.x) / 2;
1020
+ textMesh.userData.isText = true;
1021
+
1022
+ hashtagGroup.add(textMesh);
1023
+ }
1024
+
1025
  async function saveMapToFirestore(topic, depth, origin, data) {
1026
  if (!db) return;
1027
  try {
 
1035
  userId: userId
1036
  };
1037
  await addDoc(mapCollection, mapDocument);
 
1038
  } catch (error) {
1039
+ console.error(error);
1040
+ alert("Error guardando en BD: " + error.message);
1041
  }
1042
  }
1043
 
1044
  function loadAllMaps() {
1045
  if (!db || !font) {
 
1046
  if (!font) {
1047
  setTimeout(loadAllMaps, 500);
1048
  }
 
1053
  const q = query(mapCollection);
1054
 
1055
  onSnapshot(q, async (snapshot) => {
 
1056
  clearScene();
1057
  mapCount = 0;
1058
  userMaps = {};
1059
  allMapsDataCache = {};
1060
 
1061
  if (snapshot.empty) {
 
1062
  drawMinimap();
1063
+ // Si no hay mapas, inicializar el cometa en el origen
1064
+ initComet();
1065
  return;
1066
  }
1067
 
1068
+ // 1. Reconstruir todas las neuronas de todos los usuarios
1069
  snapshot.docs.forEach((doc) => {
1070
  mapCount++;
1071
  const map = doc.data();
1072
  if (!map.origin || !map.data || !map.topic || !map.userId) {
 
1073
  return;
1074
  }
1075
  const origin = new THREE.Vector3(map.origin.x, map.origin.y, map.origin.z);
 
1082
  try {
1083
  parsedData = JSON.parse(map.data);
1084
  } catch (e) {
 
1085
  return;
1086
  }
1087
 
 
1090
  }
1091
  allMapsDataCache[map.userId].push({ topic: map.topic, origin: origin, data: parsedData });
1092
 
 
1093
  visualizeRoot(map.topic, origin);
1094
  visualizeHashtags(parsedData.lista_palabras, origin, 1, null);
1095
  });
1096
 
1097
+ // 2. Crear los Soles Centrales de cada Usuario
1098
+ const uids = Object.keys(userMaps);
1099
+ const profilePromises = uids.map(uid => getProfile(uid));
1100
+ const profiles = await Promise.all(profilePromises);
1101
 
1102
  const userListElement = document.getElementById('userList');
1103
+ if (userListElement) userListElement.innerHTML = '';
1104
+
1105
+ uids.forEach((uid, index) => {
1106
+ const profile = profiles[index];
1107
+ const username = profile ? profile.username : `Anon-${uid.substring(0,4)}`;
1108
+
1109
+ // Crear lista UI
1110
+ if (userListElement) {
1111
  const userItem = document.createElement('div');
1112
+ userItem.className = 'p-2 mb-1 rounded bg-white/5 hover:bg-white/10 cursor-pointer transition-colors duration-200 text-[10px] text-gray-300 flex items-center gap-2';
1113
+ userItem.innerHTML = `<span class="w-2 h-2 rounded-full bg-green-400"></span> ${username}`;
1114
  userItem.dataset.userid = uid;
1115
  userItem.addEventListener('click', () => teleportToUser(uid));
1116
  userListElement.appendChild(userItem);
1117
+ }
1118
+
1119
+ // Crear Sol 3D Central
1120
+ const origins = userMaps[uid];
1121
+ if(origins && origins.length > 0) {
1122
+ const centroid = new THREE.Vector3();
1123
+ origins.forEach(o => centroid.add(o));
1124
+ centroid.divideScalar(origins.length);
1125
+
1126
+ // Si es la primera galaxia, el centro es el origen, si no, es el promedio
1127
+ createUserSun(centroid, username, uid === userId);
1128
+ }
1129
+ });
1130
 
1131
  drawMinimap();
 
1132
  focusOnUserMaps();
1133
+
1134
+ // --- INICIALIZAR EL COMETA DESPUÉS DE CARGAR MAPAS ---
1135
+ // Esto asegura que el cometa conozca el centroide del usuario actual
1136
+ initComet();
1137
+ // ----------------------------------------------------
1138
 
1139
+ }, (error) => {});
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1140
  }
1141
 
1142
  function focusOnUserMaps() {
1143
  if (!controls) return;
1144
  if (!userId || !userMaps[userId] || userMaps[userId].length === 0) {
 
1145
  controls.target.set(0, 0, 0);
1146
  camera.position.set(0, 0, 15);
1147
  controls.update();
 
1151
  const centroid = new THREE.Vector3(0, 0, 0);
1152
  userMapOrigins.forEach(origin => centroid.add(origin));
1153
  centroid.divideScalar(userMapOrigins.length);
 
1154
  controls.target.copy(centroid);
1155
  const cameraOffset = new THREE.Vector3(0, 5, 20);
1156
  camera.position.copy(centroid).add(cameraOffset);
 
1159
 
1160
  function teleportToUser(targetUserId) {
1161
  if (!controls || !userMaps[targetUserId] || userMaps[targetUserId].length === 0) {
 
1162
  return;
1163
  }
1164
  const userMapOrigins = userMaps[targetUserId];
1165
  const centroid = new THREE.Vector3(0, 0, 0);
1166
  userMapOrigins.forEach(origin => centroid.add(origin));
1167
  centroid.divideScalar(userMapOrigins.length);
 
1168
  controls.target.copy(centroid);
1169
  const cameraOffset = new THREE.Vector3(0, 5, 20);
1170
  camera.position.copy(centroid).add(cameraOffset);
 
1173
 
1174
  function visualizeHashtags(dataList, origin, level, parentColor = null) {
1175
  if (!dataList || dataList.length === 0) return;
 
1176
  for (const item of dataList) {
1177
  let currentTag, variantsList;
1178
  if (level === 1) {
 
1190
  const nodeColor = (level === 1) ? color : parentColor;
1191
  const nodeMaterial = new THREE.MeshStandardMaterial({
1192
  color: new THREE.Color(nodeColor),
1193
+ roughness: 0.3,
1194
+ metalness: 0.5
1195
  });
1196
  const nodeTextMaterial = new THREE.MeshBasicMaterial({
1197
  color: new THREE.Color(nodeColor),
1198
  transparent: true,
1199
+ opacity: 0.8
1200
  });
1201
 
1202
  const theta = (h / 360) * Math.PI * 2;
 
1214
  clusterCenter.add(origin);
1215
 
1216
  const branchColor = new THREE.Color(nodeColor).multiplyScalar(0.4);
1217
+ const lineMaterial = new THREE.LineBasicMaterial({ color: branchColor, transparent: true, opacity: 0.4 });
1218
  const linePoints = [origin, clusterCenter];
1219
  const lineGeometry = new THREE.BufferGeometry().setFromPoints(linePoints);
1220
  const line = new THREE.Line(lineGeometry, lineMaterial);
 
1264
  minimapCtx.arc(dot.x, dot.y, size, 0, Math.PI * 2);
1265
  minimapCtx.fillStyle = color;
1266
  minimapCtx.fill();
1267
+ minimapCtx.fillStyle = 'rgba(255,255,255,0.7)';
1268
+ minimapCtx.font = '8px Orbitron';
1269
+ minimapCtx.fillText(dot.username.substring(0,8), dot.x + size + 3, dot.y + 3);
1270
  }
1271
 
1272
  async function drawMinimap() {
1273
  if (!minimapCtx || !userMaps) return;
1274
  const canvas = minimapCtx.canvas;
1275
+ minimapCtx.clearRect(0,0,canvas.width, canvas.height);
 
1276
  minimapDotCoords = [];
1277
 
1278
  let centerX = 0, centerZ = 0;
 
1299
  if (numSatellites > 0) {
1300
  userMapsList.forEach(origin => userCentroid.add(origin));
1301
  userCentroid.divideScalar(numSatellites);
 
 
1302
  }
1303
  const relX = (userCentroid.x - centerX) * minimapScale;
1304
  const relZ = (userCentroid.z - centerZ) * minimapScale;
 
1307
  const dotData = { x: canvasX, y: canvasY, uid: uid, username: username };
1308
  minimapDotCoords.push(dotData);
1309
  const isMe = (uid === userId);
1310
+ const mainColor = isMe ? '#4ade80' : '#60a5fa';
1311
  const baseSize = MINIMAP_DOT_SIZE;
1312
  const sizeBonus = (numSatellites > 1) ? Math.log(numSatellites) * 1.5 : 0;
1313
  let mainSize = baseSize + sizeBonus;
 
1344
  const y = event.clientY - rect.top;
1345
  let clickedUser = null;
1346
  let minDistance = 10;
1347
+ for (let i = minimapDotCoords.length - 0; i >= 0; i--) {
1348
  const dot = minimapDotCoords[i];
1349
+ if(!dot) continue;
1350
  const dx = x - dot.x;
1351
  const dy = y - dot.y;
1352
  const distance = Math.sqrt(dx * dx + dy * dy);
 
1356
  }
1357
  }
1358
  if (clickedUser) {
 
1359
  teleportToUser(clickedUser);
1360
  }
1361
  }
 
1375
 
1376
  function updateRaycaster() {
1377
  raycaster.setFromCamera(mouse, camera);
1378
+ // Incluir la cabeza del cometa en los objetos interactuables si se desea
1379
+ let objectsToIntersect = hashtagGroup.children.filter(o => o.isMesh);
1380
+ if (cometHead) objectsToIntersect.push(cometHead);
1381
+
1382
  const intersects = raycaster.intersectObjects(objectsToIntersect, false);
1383
 
1384
  if (intersects.length > 0) {
1385
  let targetObject = intersects[0].object;
1386
+ // Lógica especial para el cometa si es necesario, por ahora usa el mismo tooltip
1387
  if (targetObject.userData.isText && targetObject.parent) targetObject = targetObject.parent;
1388
+
1389
  if (intersected.object !== targetObject) {
1390
  intersected.object = targetObject;
1391
  const data = intersected.object.userData;
1392
+ tooltip.classList.remove('hidden');
1393
+ let tooltipText = "";
1394
+ if (targetObject === cometHead) {
1395
+ // Tooltip específico para el cometa
1396
+ const word = cometTextMesh ? cometTextMesh.geometry.parameters.text : "Cometa";
1397
+ tooltipText = `<strong class="text-cyan-300 text-lg">Cometa: ${word}</strong><br><span class="text-gray-400">Orbitando tu zona</span>`;
1398
+ } else {
1399
+ // Tooltip normal
1400
+ tooltipText = `<strong class="text-green-300 text-lg">${data.hashtag}</strong>`;
1401
+ if (data.level !== undefined) tooltipText += `<br><span class="text-gray-400">Nivel: ${data.level}</span>`;
1402
+ else if (data.isPlaceholder) tooltipText = `<strong>Galaxia de ${data.hashtag}</strong>`;
1403
+ }
1404
  tooltip.innerHTML = tooltipText;
1405
  }
1406
  } else {
1407
+ if (intersected.object) tooltip.classList.add('hidden');
1408
  intersected.object = null;
1409
  }
1410
  }
 
1417
  }
1418
  </script>
1419
  </body>
1420
+ </html>