salomonsky commited on
Commit
c6cfc3a
·
verified ·
1 Parent(s): 6981db8

Upload 3 files

Browse files
Files changed (3) hide show
  1. index.html +92 -1344
  2. main.js +928 -0
  3. styles.css +33 -0
index.html CHANGED
@@ -3,135 +3,151 @@
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
 
@@ -139,1282 +155,14 @@
139
  <script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"></script>
140
  <script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/geometries/TextGeometry.js"></script>
141
  <script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/loaders/FontLoader.js"></script>
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,
158
- doc,
159
- addDoc,
160
- onSnapshot,
161
- collection,
162
- setDoc,
163
- getDoc,
164
- query,
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",
171
- projectId: "neuronal-1f3b9",
172
- storageBucket: "neuronal-1f3b9.firebasestorage.app",
173
- messagingSenderId: "208887839866",
174
- appId: "1:208887839866:web:adbb697dd0b63195b10fc3",
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') || '';
183
- return k || DEFAULT_GEMINI_KEY;
184
- } catch {
185
- return DEFAULT_GEMINI_KEY;
186
- }
187
- }
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;
225
- const intersected = {};
226
- let minimapCtx;
227
- let minimapDotCoords = [];
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, "");
238
- };
239
-
240
- const loader = new FontLoader();
241
- loader.load(
242
- 'https://cdn.jsdelivr.net/npm/three@0.128.0/examples/fonts/helvetiker_regular.typeface.json',
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() {
275
- try {
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 {
414
- const profileDocRef = doc(db, 'artifacts', appId, 'users', uid, 'user_data', 'profile');
415
- const docSnap = await getDoc(profileDocRef);
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
- }
426
-
427
- async function saveUserProfile(uid, username) {
428
- if (!db) return;
429
- try {
430
- const profileDocRef = doc(db, 'artifacts', appId, 'users', uid, 'user_data', 'profile');
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');
445
- const docSnap = await getDoc(profileDocRef);
446
- if (docSnap && docSnap.exists()) {
447
- const profile = docSnap.data();
448
- userProfileCache[uid] = profile;
449
- return profile;
450
- } else {
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');
610
-
611
- controls = new OrbitControls(camera, renderer.domElement);
612
- controls.enableDamping = true;
613
- controls.dampingFactor = 0.05;
614
- controls.target.set(0, 0, 0);
615
-
616
- const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
617
- scene.add(ambientLight);
618
-
619
- const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
620
- directionalLight.position.set(5, 10, 7.5);
621
- scene.add(directionalLight);
622
-
623
- raycaster = new THREE.Raycaster();
624
- mouse = new THREE.Vector2();
625
-
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;
642
- });
643
- document.getElementById('level2Slider').addEventListener('input', (e) => {
644
- document.getElementById('level2Value').innerText = e.target.value;
645
- });
646
- document.getElementById('level3Slider').addEventListener('input', (e) => {
647
- document.getElementById('level3Value').innerText = e.target.value;
648
- });
649
-
650
- window.addEventListener('resize', onWindowResize);
651
- window.addEventListener('mousemove', onPointerMove);
652
-
653
- const minimapCanvas = document.getElementById('minimap');
654
- if (minimapCanvas) {
655
- minimapCtx = minimapCanvas.getContext('2d');
656
- minimapCanvas.addEventListener('click', onMinimapClick);
657
- }
658
-
659
- document.getElementById('zoomInButton').addEventListener('click', () => {
660
- minimapScale *= 1.5;
661
- drawMinimap();
662
- });
663
- document.getElementById('zoomOutButton').addEventListener('click', () => {
664
- minimapScale /= 1.5;
665
- drawMinimap();
666
- });
667
-
668
- animate();
669
- }
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);
703
- try {
704
- const resp = await fetch(url, { ...options, signal: controller.signal });
705
- return resp;
706
- } finally {
707
- clearTimeout(id);
708
- }
709
- }
710
-
711
- async function fetchWithBackoff(url, options, retries = 2, delay = 1000, timeoutMs = 25000) {
712
- try {
713
- return await fetchWithTimeout(url, options, timeoutMs);
714
- } catch (err) {
715
- if (retries > 0) {
716
- await new Promise(resolve => setTimeout(resolve, delay));
717
- return fetchWithBackoff(url, options, retries - 1, delay * 2, timeoutMs);
718
- } else {
719
- throw err;
720
- }
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
-
737
- let schema = {
738
- type: "OBJECT",
739
- properties: {
740
- analisis: {
741
- type: "STRING",
742
- description: "Un análisis conciso del tema en 2-3 frases, sin acentos."
743
- },
744
- lista_palabras: {
745
- type: "ARRAY",
746
- description: `Una lista de ${mainCount} objetos.`,
747
- items: {
748
- type: "OBJECT",
749
- properties: {
750
- palabra_principal: { type: "STRING" }
751
- },
752
- required: ["palabra_principal"]
753
- }
754
- }
755
- },
756
- required: ["analisis", "lista_palabras"]
757
- };
758
-
759
- const n1Items = schema.properties.lista_palabras.items;
760
-
761
- if (depth >= 2) {
762
- userPrompt += `1. Genera una lista de ${mainCount} palabras clave principales (Nivel 1).\n`;
763
- userPrompt += `2. Para CADA palabra de Nivel 1, genera ${variantCount} variantes (Nivel 2).\n`;
764
-
765
- n1Items.properties.variantes = {
766
- type: "ARRAY",
767
- description: `Una lista de ${variantCount} objetos de variantes.`,
768
- items: {
769
- type: "OBJECT",
770
- properties: {
771
- palabra_variante: { type: "STRING" }
772
- },
773
- required: ["palabra_variante"]
774
- }
775
- };
776
- n1Items.required.push("variantes");
777
- }
778
-
779
- if (depth == 3) {
780
- userPrompt += `3. Para CADA variante de Nivel 2, genera ${subVariantCount} sub-variantes (Nivel 3).\n`;
781
- userPrompt += `4. Asegurate que todo el texto no contenga acentos.`;
782
-
783
- const n2Items = n1Items.properties.variantes.items;
784
- n2Items.properties.sub_variantes = {
785
- type: "ARRAY",
786
- description: `Una lista de ${subVariantCount} sub-variantes.`,
787
- items: { type: "STRING" }
788
- };
789
- n2Items.required.push("sub_variantes");
790
- }
791
-
792
- const payload = {
793
- contents: [{ parts: [{ text: userPrompt }] }],
794
- systemInstruction: {
795
- parts: [{ text: systemInstruction }]
796
- },
797
- generationConfig: {
798
- responseMimeType: "application/json",
799
- responseSchema: schema
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;
835
- const variantCount = document.getElementById('level2Slider').value;
836
- const subVariantCount = document.getElementById('level3Slider').value;
837
-
838
- const button = document.getElementById('visualizeButton');
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%';
861
-
862
- let mapOrigin = new THREE.Vector3(0, 0, 0);
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;
870
- const angle = galaxyIndex * 2.3998;
871
- const radius = galaxyIndex * GALAXY_SEPARATION_STEP;
872
- mapOrigin.x = radius * Math.cos(angle);
873
- mapOrigin.z = radius * Math.sin(angle);
874
- mapOrigin.y = 0;
875
- } else {
876
- const galaxyCentroid = new THREE.Vector3(0, 0, 0);
877
- existingUserMaps.forEach(origin => galaxyCentroid.add(origin));
878
- galaxyCentroid.divideScalar(localMapIndex);
879
- const LOCAL_RADIUS_STEP = 25;
880
- const angle = localMapIndex * 2.3998;
881
- const radius = Math.sqrt(localMapIndex) * LOCAL_RADIUS_STEP;
882
- mapOrigin.x = galaxyCentroid.x + radius * Math.cos(angle);
883
- mapOrigin.z = galaxyCentroid.z + radius * Math.sin(angle);
884
- mapOrigin.y = 0;
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);
894
-
895
- const rootTopic = topic;
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;
911
- if (button.dataset.originalHtml) {
912
- button.innerHTML = button.dataset.originalHtml;
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];
931
- if (object.geometry) object.geometry.dispose();
932
- if (Array.isArray(object.material)) {
933
- object.material.forEach(m => m.dispose());
934
- } else if (object.material) {
935
- object.material.dispose();
936
- }
937
- while (object.children.length > 0) {
938
- const child = object.children[0];
939
- if (child.geometry) child.geometry.dispose();
940
- if (child.material) child.material.dispose();
941
- object.remove(child);
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;
963
- const rootGeometry = new THREE.SphereGeometry(rootSphereRadius, 16, 16);
964
- const rootSphere = new THREE.Mesh(rootGeometry, rootMaterial);
965
- rootSphere.position.copy(origin);
966
- rootSphere.userData.hashtag = topic;
967
- rootSphere.userData.level = 0;
968
- hashtagGroup.add(rootSphere);
969
-
970
- const rootTextSize = 0.3;
971
- const rootTextGeometry = new TextGeometry(topic.toUpperCase(), {
972
- font: font,
973
- size: rootTextSize,
974
- height: 0.02,
975
- curveSegments: 4,
976
- bevelEnabled: false
977
- });
978
- rootTextGeometry.computeBoundingBox();
979
-
980
- const rootTextMesh = new THREE.Mesh(rootTextGeometry, rootTextMaterial);
981
- rootTextMesh.position.copy(origin);
982
- rootTextMesh.position.y += rootSphereRadius + 0.1;
983
- rootTextMesh.position.x -= (rootTextGeometry.boundingBox.max.x - rootTextGeometry.boundingBox.min.x) / 2;
984
- rootTextMesh.userData.isText = true;
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 {
1028
- const mapCollection = collection(db, 'artifacts', appId, 'public', 'data', 'maps');
1029
- const mapDocument = {
1030
- topic: topic,
1031
- depth: depth,
1032
- origin: { x: origin.x, y: origin.y, z: origin.z },
1033
- data: JSON.stringify(data),
1034
- createdAt: new Date(),
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
- }
1049
- return;
1050
- }
1051
-
1052
- const mapCollection = collection(db, 'artifacts', appId, 'public', 'data', 'maps');
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);
1076
- if (!userMaps[map.userId]) {
1077
- userMaps[map.userId] = [];
1078
- }
1079
- userMaps[map.userId].push(origin);
1080
-
1081
- let parsedData;
1082
- try {
1083
- parsedData = JSON.parse(map.data);
1084
- } catch (e) {
1085
- return;
1086
- }
1087
-
1088
- if (!allMapsDataCache[map.userId]) {
1089
- allMapsDataCache[map.userId] = [];
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();
1148
- return;
1149
- }
1150
- const userMapOrigins = userMaps[userId];
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);
1157
- controls.update();
1158
- }
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);
1171
- controls.update();
1172
- }
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) {
1179
- currentTag = normalizeString(item.palabra_principal);
1180
- variantsList = item.variantes || [];
1181
- } else if (level === 2) {
1182
- currentTag = normalizeString(item.palabra_variante);
1183
- variantsList = item.sub_variantes || [];
1184
- } else {
1185
- currentTag = normalizeString(item);
1186
- variantsList = [];
1187
- }
1188
- if (!currentTag) continue;
1189
- const { color, h } = stringToHslColor(currentTag);
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;
1203
- let phiHash = 0;
1204
- for (let i = 0; i < currentTag.length; i++) {
1205
- phiHash = (phiHash + currentTag.charCodeAt(i) * 13) % 180;
1206
- }
1207
- const phi = ((phiHash / 180) * 90 + 45) * (Math.PI / 180);
1208
-
1209
- const baseRadius = 10 / (level * level);
1210
- const clusterCenterX = baseRadius * Math.sin(phi) * Math.cos(theta);
1211
- const clusterCenterY = baseRadius * Math.cos(phi);
1212
- const clusterCenterZ = baseRadius * Math.sin(phi) * Math.sin(theta);
1213
- const clusterCenter = new THREE.Vector3(clusterCenterX, clusterCenterY, clusterCenterZ);
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);
1221
- hashtagGroup.add(line);
1222
-
1223
- let sphereRadius;
1224
- if (level === 1) sphereRadius = 0.2;
1225
- else if (level === 2) sphereRadius = 0.1;
1226
- else sphereRadius = 0.05;
1227
-
1228
- const leafGeometry = new THREE.SphereGeometry(sphereRadius, 8, 8);
1229
- const sphere = new THREE.Mesh(leafGeometry, nodeMaterial);
1230
- sphere.position.copy(clusterCenter);
1231
- sphere.userData.hashtag = currentTag;
1232
- sphere.userData.count = 1;
1233
- sphere.userData.level = level;
1234
- hashtagGroup.add(sphere);
1235
-
1236
- let baseTextSize;
1237
- if (level === 1) baseTextSize = 0.24;
1238
- else if (level === 2) baseTextSize = 0.12;
1239
- else baseTextSize = 0.08;
1240
-
1241
- const textGeometry = new TextGeometry(currentTag.toUpperCase(), {
1242
- font: font,
1243
- size: baseTextSize,
1244
- height: 0.02 / level,
1245
- curveSegments: 4,
1246
- bevelEnabled: false
1247
- });
1248
- textGeometry.computeBoundingBox();
1249
- const textMesh = new THREE.Mesh(textGeometry, nodeTextMaterial);
1250
- const smallMargin = 0.05 / level;
1251
- textMesh.position.copy(clusterCenter);
1252
- textMesh.position.y += sphereRadius + smallMargin;
1253
- textMesh.position.x -= (textGeometry.boundingBox.max.x - textGeometry.boundingBox.min.x) / 2;
1254
- textMesh.userData.isText = true;
1255
- hashtagGroup.add(textMesh);
1256
-
1257
- visualizeHashtags(variantsList, clusterCenter, level + 1, nodeColor);
1258
- }
1259
- }
1260
-
1261
- function drawMinimapDot(dot, color, size = MINIMAP_DOT_SIZE) {
1262
- if (!minimapCtx) return;
1263
- minimapCtx.beginPath();
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;
1279
- if (userId && userMaps[userId] && userMaps[userId].length > 0) {
1280
- const myCentroid = new THREE.Vector3(0, 0, 0);
1281
- userMaps[userId].forEach(origin => myCentroid.add(origin));
1282
- myCentroid.divideScalar(userMaps[userId].length);
1283
- centerX = myCentroid.x;
1284
- centerZ = myCentroid.z;
1285
- }
1286
-
1287
- const uids = Object.keys(userMaps);
1288
- const profilePromises = uids.map(uid => getProfile(uid));
1289
- const profiles = await Promise.all(profilePromises);
1290
- let myDotData = null;
1291
-
1292
- for (let i = 0; i < uids.length; i++) {
1293
- const uid = uids[i];
1294
- const profile = profiles[i];
1295
- const username = profile ? profile.username : `Usuario ${uid.substring(0,4)}`;
1296
- const userMapsList = userMaps[uid] || [];
1297
- const numSatellites = userMapsList.length;
1298
- const userCentroid = new THREE.Vector3(0,0,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;
1305
- const canvasX = canvas.width / 2 + relX;
1306
- const canvasY = canvas.height / 2 + relZ;
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;
1314
- if (isMe) mainSize += 1;
1315
-
1316
- if (numSatellites > 0) {
1317
- const satelliteRadius = mainSize + 3;
1318
- const satelliteSize = 1;
1319
- for (let j = 0; j < numSatellites; j++) {
1320
- const angle = (j / numSatellites) * Math.PI * 2;
1321
- const satX = dotData.x + Math.cos(angle) * satelliteRadius;
1322
- const satY = dotData.y + Math.sin(angle) * satelliteRadius;
1323
- minimapCtx.beginPath();
1324
- minimapCtx.arc(satX, satY, satelliteSize, 0, Math.PI * 2);
1325
- minimapCtx.fillStyle = mainColor;
1326
- minimapCtx.globalAlpha = 0.6;
1327
- minimapCtx.fill();
1328
- minimapCtx.globalAlpha = 1.0;
1329
- }
1330
- }
1331
-
1332
- if (!isMe) drawMinimapDot(dotData, mainColor, mainSize);
1333
- else myDotData = { dot: dotData, color: mainColor, size: mainSize };
1334
- }
1335
-
1336
- if (myDotData) drawMinimapDot(myDotData.dot, myDotData.color, myDotData.size);
1337
- }
1338
-
1339
- function onMinimapClick(event) {
1340
- if (!minimapCtx) return;
1341
- const canvas = minimapCtx.canvas;
1342
- const rect = canvas.getBoundingClientRect();
1343
- const x = event.clientX - rect.left;
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);
1353
- if (distance < minDistance) {
1354
- minDistance = distance;
1355
- clickedUser = dot.uid;
1356
- }
1357
- }
1358
- if (clickedUser) {
1359
- teleportToUser(clickedUser);
1360
- }
1361
- }
1362
-
1363
- function onWindowResize() {
1364
- camera.aspect = window.innerWidth / window.innerHeight;
1365
- camera.updateProjectionMatrix();
1366
- renderer.setSize(window.innerWidth, window.innerHeight);
1367
- }
1368
-
1369
- function onPointerMove(event) {
1370
- mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
1371
- mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
1372
- tooltip.style.left = `${event.clientX + 15}px`;
1373
- tooltip.style.top = `${event.clientY}px`;
1374
- }
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
- }
1411
-
1412
- function stringToHslColor(str) {
1413
- let hash = 0;
1414
- for (let i = 0; i < str.length; i++) hash = str.charCodeAt(i) + ((hash << 5) - hash);
1415
- const h = Math.abs(hash % 360);
1416
- return { color: `hsl(${h}, 80%, 60%)`, h: h };
1417
- }
1418
- </script>
1419
  </body>
1420
  </html>
 
3
  <head>
4
  <meta charset="UTF-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
6
+ <title>Navegador Neuronal de Twitter</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
+ <link rel="stylesheet" href="styles.css">
10
  </head>
11
 
12
+ <body class="m-0 overflow-hidden font-[Orbitron] text-slate-200">
13
 
14
+ <div id="loginOverlay" class="fixed inset-0 bg-black/95 z-[9999] flex items-center justify-center transition-opacity duration-700 opacity-100">
15
+ <div id="loginModal" class="glass-panel p-8 rounded-2xl w-full max-w-md text-white relative overflow-hidden border-t border-cyan-500/30">
16
+
17
+ <div class="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-transparent via-cyan-500 to-transparent opacity-70"></div>
18
 
19
  <div id="authStep1">
20
+ <h2 class="text-4xl font-bold text-center mb-2 text-transparent bg-clip-text bg-gradient-to-r from-cyan-400 via-blue-500 to-purple-600 drop-shadow-[0_0_15px_rgba(34,211,238,0.5)]">NEXUS</h2>
21
+ <p class="text-cyan-500/60 text-center mb-8 text-[10px] tracking-[0.3em] uppercase">Visualizador Semántico Neural v2.0</p>
22
 
23
+ <button id="btnGoToAnon" class="w-full bg-white/5 hover:bg-white/10 text-cyan-300 border border-cyan-500/20 font-bold py-4 px-4 rounded-lg transition-all flex items-center justify-center gap-3 mb-6 group hover:shadow-[0_0_15px_rgba(34,211,238,0.2)]">
24
+ <svg class="w-5 h-5 group-hover:scale-110 transition-transform text-cyan-400" 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>
25
  Entrar como Anónimo
26
  </button>
27
 
28
  <div class="relative flex py-2 items-center mb-4">
29
+ <div class="flex-grow border-t border-white/10"></div>
30
+ <span class="flex-shrink-0 mx-4 text-gray-500 text-[10px] uppercase tracking-wider">Credenciales de Acceso</span>
31
+ <div class="flex-grow border-t border-white/10"></div>
32
  </div>
33
 
34
+ <form id="emailAuthForm" class="space-y-4">
35
+ <div class="relative group">
36
+ <input type="email" id="loginEmail" class="w-full p-3 pl-4 rounded-lg bg-black/50 border border-white/10 text-white placeholder-gray-600 focus:border-cyan-500 focus:outline-none text-sm transition-all focus:bg-black/70" placeholder="ID de Usuario (Email)">
37
+ <div class="absolute inset-0 border border-cyan-500/0 rounded-lg group-hover:border-cyan-500/20 pointer-events-none transition-colors"></div>
38
+ </div>
39
+ <div class="relative group">
40
+ <input type="password" id="loginPassword" class="w-full p-3 pl-4 rounded-lg bg-black/50 border border-white/10 text-white placeholder-gray-600 focus:border-cyan-500 focus:outline-none text-sm transition-all focus:bg-black/70" placeholder="Clave de Acceso">
41
+ <div class="absolute inset-0 border border-cyan-500/0 rounded-lg group-hover:border-cyan-500/20 pointer-events-none transition-colors"></div>
42
+ </div>
43
 
44
+ <div class="flex gap-3 pt-2">
45
+ <button type="button" id="loginButton" class="flex-1 bg-blue-600/10 hover:bg-blue-600/30 text-blue-400 py-2 rounded border border-blue-500/30 text-xs font-bold transition-all uppercase tracking-wider hover:shadow-[0_0_10px_rgba(59,130,246,0.3)]">Entrar</button>
46
+ <button type="button" id="registerButton" class="flex-1 bg-green-600/10 hover:bg-green-600/30 text-green-400 py-2 rounded border border-green-500/30 text-xs font-bold transition-all uppercase tracking-wider hover:shadow-[0_0_10px_rgba(74,222,128,0.3)]">Registrar</button>
47
  </div>
48
  </form>
49
 
50
+ <p id="loginMessage" class="text-center text-xs mt-4 min-h-[1.5rem] text-red-400 font-bold tracking-wide"></p>
51
  </div>
52
 
53
  <div id="authStep2" class="hidden">
54
+ <h2 class="text-2xl font-bold text-white mb-2 text-center">Identidad Digital</h2>
55
+ <p class="text-gray-400 text-xs mb-8 text-center">Asigna un nombre clave a tu constelación.</p>
56
 
57
+ <input type="text" id="usernameInput" class="w-full p-4 rounded-lg bg-black/50 border border-white/10 text-white placeholder-gray-600 focus:outline-none focus:border-purple-500 transition-all text-lg mb-8 text-center tracking-[0.2em] font-bold" placeholder="NICKNAME">
58
 
59
+ <button id="saveUsernameButton" class="w-full bg-gradient-to-r from-cyan-600 to-blue-700 hover:from-cyan-500 hover:to-blue-600 text-white font-bold py-4 px-4 rounded-lg shadow-[0_0_20px_rgba(6,182,212,0.4)] transition-all uppercase text-sm tracking-widest mb-4">
60
+ Establecer Enlace Neural
61
  </button>
62
 
63
+ <button id="backToStep1" class="w-full mt-2 text-gray-500 text-[10px] hover:text-white transition-colors uppercase tracking-widest">Abortar Secuencia</button>
64
  </div>
65
 
66
  </div>
67
  </div>
68
 
69
+ <div id="container" class="w-screen h-screen fixed top-0 left-0 bg-[#020205]"></div>
70
 
71
+ <div id="tooltip" class="absolute hidden bg-black/80 text-white px-4 py-3 rounded-none border-l-2 border-cyan-500 z-[101] pointer-events-none text-sm backdrop-blur-md shadow-[0_0_15px_rgba(6,182,212,0.2)] max-w-xs"></div>
72
 
73
+ <div id="ui" class="fixed top-5 left-5 z-[100] glass-panel p-6 rounded-xl max-w-[380px] text-white h-[calc(100vh-40px)] flex flex-col transition-all hidden transform duration-700 translate-x-[-20px] opacity-0" style="opacity: 1; transform: translateX(0);">
74
 
75
+ <div class="flex items-center justify-between mb-6 border-b border-white/10 pb-4">
76
+ <h1 class="text-lg font-bold text-white flex items-center space-x-3">
77
+ <div class="w-2 h-2 bg-cyan-400 rounded-full animate-pulse shadow-[0_0_10px_#22d3ee]"></div>
78
+ <span class="text-transparent bg-clip-text bg-gradient-to-r from-cyan-300 to-blue-500 tracking-[0.2em]">NEXUS</span>
 
 
79
  </h1>
80
+ <div class="flex items-center gap-3">
81
+ <span id="authStatus" class="text-[9px] text-cyan-500/80 uppercase tracking-widest border border-cyan-500/20 px-2 py-1 rounded bg-cyan-900/10"></span>
82
+ <button id="mainLogoutButton" class="text-red-400/70 hover:text-red-300 text-[10px] uppercase transition-colors font-bold tracking-wider">Salir</button>
83
  </div>
84
  </div>
85
 
86
+ <div class="relative mb-5 group">
87
+ <div class="absolute -inset-0.5 bg-gradient-to-r from-cyan-500 to-purple-600 rounded-lg blur opacity-20 group-hover:opacity-60 transition duration-500"></div>
88
+ <input type="text" id="topicInput" class="relative w-full p-4 rounded-lg bg-black/80 text-white border border-white/10 focus:outline-none focus:border-cyan-500/50 text-sm placeholder-gray-500 font-mono" placeholder="Ingresar semilla semántica...">
89
+ </div>
90
 
91
+ <div class="space-y-5 mb-6 px-1">
92
  <div>
93
+ <label class="text-[10px] text-gray-400 flex justify-between uppercase tracking-wider mb-2">Densidad Nivel 1 <span id="level1Value" class="text-cyan-400 font-mono bg-cyan-900/20 px-1 rounded">10</span></label>
94
+ <input type="range" id="level1Slider" min="1" max="15" value="10" class="w-full h-1 bg-gray-800 rounded-lg appearance-none cursor-pointer accent-cyan-500 hover:accent-cyan-400">
95
  </div>
96
  <div>
97
+ <label class="text-[10px] text-gray-400 flex justify-between uppercase tracking-wider mb-2">Densidad Nivel 2 <span id="level2Value" class="text-cyan-400 font-mono bg-cyan-900/20 px-1 rounded">5</span></label>
98
+ <input type="range" id="level2Slider" min="5" max="8" value="5" class="w-full h-1 bg-gray-800 rounded-lg appearance-none cursor-pointer accent-cyan-500 hover:accent-cyan-400">
99
  </div>
100
  <div>
101
+ <label class="text-[10px] text-gray-400 flex justify-between uppercase tracking-wider mb-2">Densidad Nivel 3 <span id="level3Value" class="text-cyan-400 font-mono bg-cyan-900/20 px-1 rounded">3</span></label>
102
+ <input type="range" id="level3Slider" min="1" max="3" value="3" class="w-full h-1 bg-gray-800 rounded-lg appearance-none cursor-pointer accent-cyan-500 hover:accent-cyan-400">
103
  </div>
104
  </div>
105
 
106
+ <details class="mb-5 bg-black/40 rounded border border-white/5 overflow-hidden group">
107
+ <summary class="cursor-pointer text-[10px] text-blue-300/80 font-bold p-3 uppercase tracking-wide hover:bg-white/5 hover:text-cyan-300 transition-colors list-none flex justify-between items-center">
108
+ <span>Configuración API</span>
109
+ <span class="text-xs group-open:rotate-180 transition-transform duration-300 text-cyan-500">▼</span>
110
+ </summary>
111
+ <div class="p-4 space-y-3 border-t border-white/5 bg-black/60">
112
  <div>
113
+ <label class="block text-[9px] text-gray-500 mb-1 uppercase tracking-widest">Gemini API Key</label>
114
+ <input type="password" id="geminiKeyInput" class="w-full p-2 rounded bg-black/50 text-white border border-white/10 text-xs focus:border-cyan-500/50 focus:outline-none font-mono tracking-tighter" placeholder="Pegar Key aquí...">
115
  </div>
116
+ <div class="flex items-center gap-3 justify-end">
117
+ <span id="geminiKeyStatus" class="text-[9px] text-green-400 italic font-mono"></span>
118
+ <button id="saveGeminiKeyBtn" class="bg-white/5 hover:bg-white/10 px-3 py-1.5 rounded text-gray-300 text-[10px] border border-white/10 transition-colors uppercase tracking-wider hover:border-cyan-500/30 hover:text-cyan-300">Guardar</button>
119
  </div>
120
  </div>
121
  </details>
122
 
123
  <div class="flex gap-2 mb-2">
124
+ <button id="visualizeButton" class="relative w-full overflow-hidden bg-cyan-900/20 hover:bg-cyan-800/40 text-cyan-300 font-bold py-4 px-4 rounded-lg border border-cyan-500/30 transition-all flex items-center justify-center space-x-3 group hover:shadow-[0_0_15px_rgba(34,211,238,0.2)]">
125
+ <div class="absolute inset-0 w-full h-full bg-gradient-to-r from-transparent via-cyan-500/10 to-transparent -translate-x-full group-hover:animate-shimmer"></div>
126
+ <svg class="w-4 h-4 text-cyan-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
127
+ <path stroke-linecap="round" stroke-linejoin="round" d="M3.75 13.5l10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z" />
128
  </svg>
129
+ <span class="uppercase tracking-[0.2em] text-xs">Ejecutar Análisis</span>
130
  </button>
131
  </div>
132
 
133
+ <div id="progressBarContainer" class="w-full bg-gray-900 rounded-full h-1 mb-4 overflow-hidden hidden border border-white/10">
134
+ <div id="progressBar" class="bg-gradient-to-r from-cyan-500 via-blue-500 to-purple-600 h-1 w-0 shadow-[0_0_10px_#22d3ee]"></div>
135
  </div>
136
 
137
+ <div id="userListContainer" class="flex-1 flex flex-col min-h-0 bg-black/40 rounded border border-white/5 mb-4 relative overflow-hidden group">
138
+ <div class="absolute top-0 left-0 w-full h-4 bg-gradient-to-b from-black/80 to-transparent pointer-events-none z-10"></div>
139
+ <h2 class="font-bold text-[9px] text-gray-500 mb-2 uppercase tracking-[0.2em] p-3 border-b border-white/5 sticky top-0 bg-black/40 backdrop-blur-sm">Exploradores Activos</h2>
140
+ <div id="userList" class="overflow-y-auto pr-1 space-y-1 custom-scrollbar text-xs p-2"></div>
141
+ <div class="absolute bottom-0 left-0 w-full h-4 bg-gradient-to-t from-black/80 to-transparent pointer-events-none z-10"></div>
142
  </div>
143
 
144
+ <div id="minimapContainer" class="shrink-0 relative group border-t border-white/5 pt-4">
145
+ <div class="flex justify-between items-center mb-1 absolute top-6 right-2 z-10 gap-1 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
146
+ <button id="zoomOutButton" class="w-6 h-6 bg-black/80 hover:bg-cyan-900/50 rounded border border-white/20 text-white flex items-center justify-center text-xs backdrop-blur-sm transition-colors">-</button>
147
+ <button id="zoomInButton" class="w-6 h-6 bg-black/80 hover:bg-cyan-900/50 rounded border border-white/20 text-white flex items-center justify-center text-xs backdrop-blur-sm transition-colors">+</button>
148
  </div>
149
+ <canvas id="minimap" width="340" height="130" class="w-full h-[130px] bg-black/60 rounded border border-white/10 cursor-crosshair shadow-inner"></canvas>
150
+ <div class="text-[9px] text-center text-cyan-500/40 mt-2 uppercase tracking-[0.3em]">Radar Galáctico</div>
151
  </div>
152
  </div>
153
 
 
155
  <script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"></script>
156
  <script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/geometries/TextGeometry.js"></script>
157
  <script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/loaders/FontLoader.js"></script>
158
+
159
+ <script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/EffectComposer.js"></script>
160
+ <script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/RenderPass.js"></script>
161
+ <script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/ShaderPass.js"></script>
162
+ <script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/shaders/CopyShader.js"></script>
163
+ <script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/shaders/LuminosityHighPassShader.js"></script>
164
+ <script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/UnrealBloomPass.js"></script>
165
+
166
+ <script type="module" src="main.js"></script>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
167
  </body>
168
  </html>
main.js ADDED
@@ -0,0 +1,928 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { initializeApp } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-app.js";
2
+ import { getAnalytics } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-analytics.js";
3
+ import { getAuth, onAuthStateChanged, createUserWithEmailAndPassword, signInWithEmailAndPassword, signInAnonymously, signOut, setPersistence, browserLocalPersistence } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-auth.js";
4
+ import { getFirestore, doc, addDoc, onSnapshot, collection, setDoc, getDoc, query, setLogLevel } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-firestore.js";
5
+
6
+ const firebaseConfig = {
7
+ apiKey: "AIzaSyB4EZKkAH3weFSZNBQA8Y63gt5XbaeZGsQ",
8
+ authDomain: "neuronal-1f3b9.firebaseapp.com",
9
+ projectId: "neuronal-1f3b9",
10
+ storageBucket: "neuronal-1f3b9.firebasestorage.app",
11
+ messagingSenderId: "208887839866",
12
+ appId: "1:208887839866:web:adbb697dd0b63195b10fc3",
13
+ measurementId: "G-102SEBLQFJ"
14
+ };
15
+
16
+ const DEFAULT_GEMINI_KEY = 'AIzaSyDmoQNpzgzW21f_WFCU9YbaAeI1fdOJMlo';
17
+ function getLocalGeminiKey() {
18
+ try { return localStorage.getItem('GEMINI_API_KEY') || DEFAULT_GEMINI_KEY; } catch { return DEFAULT_GEMINI_KEY; }
19
+ }
20
+ function setLocalGeminiKey(k) {
21
+ try { localStorage.setItem('GEMINI_API_KEY', k || ''); } catch {}
22
+ }
23
+
24
+ const THREE = window.THREE;
25
+ const OrbitControls = THREE.OrbitControls;
26
+ const TextGeometry = THREE.TextGeometry;
27
+ const FontLoader = THREE.FontLoader;
28
+
29
+ let scene, camera, renderer, controls, raycaster, mouse;
30
+ let composer;
31
+ let hashtagGroup, tooltip;
32
+ let font;
33
+ let clock = new THREE.Clock();
34
+
35
+ let cometGroup, cometHead, cometLight, cometText;
36
+ let cometParticlesGeometry, cometParticlesMaterial, cometParticlesMesh;
37
+ let cometParticlesData = [];
38
+ const COMET_PARTICLE_COUNT = 400;
39
+ const COMET_WORDS = ["NEXUS", "DATA", "VOID", "SIGNAL", "CYBER", "PULSE", "NODE", "FLUX", "SYNTH", "CORE", "ORBIT", "LINK"];
40
+ let cometAngle = 0;
41
+ let userCentroidForComet = new THREE.Vector3(0,0,0);
42
+
43
+ let bgParticles;
44
+
45
+ let db, auth, analytics, userId = null, appId = "neuronal-1f3b9", userProfile = null;
46
+ let userMaps = {}, userProfileCache = {}, allMapsDataCache = {};
47
+ let isAuthReady = false, isFontReady = false;
48
+ let minimapCtx, minimapDotCoords = [], minimapScale = 0.025;
49
+ const MINIMAP_DOT_SIZE = 2;
50
+ let intersected = {};
51
+
52
+ const isHFStatic = /\.hf\.space$/.test(location.hostname);
53
+ let authMode = null;
54
+
55
+ const normalizeString = (str) => {
56
+ if (!str) return "";
57
+ return str.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
58
+ };
59
+
60
+ const loader = new FontLoader();
61
+ loader.load('https://cdn.jsdelivr.net/npm/three@0.128.0/examples/fonts/helvetiker_regular.typeface.json', (loadedFont) => {
62
+ font = loadedFont;
63
+ isFontReady = true;
64
+ checkAppReady();
65
+ });
66
+
67
+ initFirebase();
68
+
69
+ const gkInput = document.getElementById('geminiKeyInput');
70
+ const gkBtn = document.getElementById('saveGeminiKeyBtn');
71
+ const gkStatus = document.getElementById('geminiKeyStatus');
72
+ if (gkInput && gkBtn) {
73
+ const existing = getLocalGeminiKey();
74
+ if(existing && existing !== DEFAULT_GEMINI_KEY) gkInput.value = existing;
75
+ gkBtn.addEventListener('click', (e) => {
76
+ e.preventDefault();
77
+ setLocalGeminiKey(gkInput.value.trim());
78
+ gkStatus.textContent = 'GUARDADO';
79
+ setTimeout(() => gkStatus.textContent = '', 2000);
80
+ });
81
+ }
82
+
83
+ async function initFirebase() {
84
+ try {
85
+ const app = initializeApp(firebaseConfig);
86
+ db = getFirestore(app);
87
+ auth = getAuth(app);
88
+ analytics = getAnalytics(app);
89
+ setLogLevel('Silent');
90
+
91
+ const els = {
92
+ msg: document.getElementById('loginMessage'),
93
+ overlay: document.getElementById('loginOverlay'),
94
+ step1: document.getElementById('authStep1'),
95
+ step2: document.getElementById('authStep2'),
96
+ email: document.getElementById('loginEmail'),
97
+ pass: document.getElementById('loginPassword'),
98
+ loginBtn: document.getElementById('loginButton'),
99
+ regBtn: document.getElementById('registerButton'),
100
+ anonBtn: document.getElementById('btnGoToAnon'),
101
+ saveUserBtn: document.getElementById('saveUsernameButton'),
102
+ userInput: document.getElementById('usernameInput'),
103
+ backBtn: document.getElementById('backToStep1'),
104
+ logoutBtn: document.getElementById('mainLogoutButton'),
105
+ ui: document.getElementById('ui')
106
+ };
107
+
108
+ els.regBtn.addEventListener('click', async () => {
109
+ if(els.email.value.length < 6) {
110
+ els.msg.innerText = "Email/Pass muy corto"; return;
111
+ }
112
+ els.msg.innerText = "Procesando registro...";
113
+ authMode = 'email';
114
+ try {
115
+ await setPersistence(auth, browserLocalPersistence);
116
+ await createUserWithEmailAndPassword(auth, els.email.value, els.pass.value);
117
+ } catch(e) { els.msg.innerText = "Error: " + e.message; }
118
+ });
119
+
120
+ els.loginBtn.addEventListener('click', async () => {
121
+ els.msg.innerText = "Autenticando...";
122
+ authMode = 'email';
123
+ try {
124
+ await setPersistence(auth, browserLocalPersistence);
125
+ await signInWithEmailAndPassword(auth, els.email.value, els.pass.value);
126
+ } catch(e) { els.msg.innerText = "Error: " + e.message; }
127
+ });
128
+
129
+ els.anonBtn.addEventListener('click', () => {
130
+ authMode = 'anonymous';
131
+ els.step1.classList.add('hidden');
132
+ els.step2.classList.remove('hidden');
133
+ });
134
+
135
+ els.backBtn.addEventListener('click', () => {
136
+ els.step2.classList.add('hidden');
137
+ els.step1.classList.remove('hidden');
138
+ els.msg.innerText = "";
139
+ });
140
+
141
+ els.saveUserBtn.addEventListener('click', async () => {
142
+ const name = normalizeString(els.userInput.value.trim());
143
+ if(name.length < 3) {
144
+ els.userInput.classList.add('border-red-500'); return;
145
+ }
146
+ els.userInput.classList.remove('border-red-500');
147
+ els.saveUserBtn.innerText = "ESTABLECIENDO ENLACE...";
148
+ els.saveUserBtn.disabled = true;
149
+ try {
150
+ if(authMode === 'anonymous') await signInAnonymously(auth);
151
+ if(auth.currentUser) {
152
+ await saveUserProfile(auth.currentUser.uid, name);
153
+ els.overlay.style.opacity = '0';
154
+ setTimeout(() => els.overlay.style.display = 'none', 700);
155
+ initScene();
156
+ loadAllMaps();
157
+ els.ui.classList.remove('hidden');
158
+ setTimeout(() => {
159
+ els.ui.style.transform = 'translateX(0)';
160
+ els.ui.style.opacity = '1';
161
+ }, 100);
162
+ }
163
+ } catch(e) {
164
+ els.saveUserBtn.innerText = "ERROR DE CONEXIÓN";
165
+ els.saveUserBtn.disabled = false;
166
+ console.error(e);
167
+ }
168
+ });
169
+
170
+ els.logoutBtn.addEventListener('click', async () => { await signOut(auth); location.reload(); });
171
+
172
+ onAuthStateChanged(auth, async (user) => {
173
+ if(user) {
174
+ userId = user.uid;
175
+ isAuthReady = true;
176
+ await fetchUserProfile(userId);
177
+ if(userProfile) {
178
+ els.overlay.style.opacity = '0';
179
+ setTimeout(() => els.overlay.style.display = 'none', 700);
180
+ els.ui.classList.remove('hidden');
181
+ setTimeout(() => {
182
+ els.ui.style.transform = 'translateX(0)';
183
+ els.ui.style.opacity = '1';
184
+ }, 100);
185
+ document.getElementById('authStatus').textContent = userProfile.username;
186
+ if(!scene) { initScene(); loadAllMaps(); }
187
+ } else {
188
+ els.step1.classList.add('hidden');
189
+ els.step2.classList.remove('hidden');
190
+ if(authMode === 'email') els.userInput.focus();
191
+ }
192
+ } else {
193
+ els.overlay.style.display = 'flex';
194
+ els.overlay.style.opacity = '1';
195
+ els.step1.classList.remove('hidden');
196
+ els.step2.classList.add('hidden');
197
+ els.ui.classList.add('hidden');
198
+ }
199
+ });
200
+
201
+ } catch (e) { console.error("Firebase Error", e); }
202
+ }
203
+
204
+ async function fetchUserProfile(uid) {
205
+ if(!db) return;
206
+ try {
207
+ const snap = await getDoc(doc(db, 'artifacts', appId, 'users', uid, 'user_data', 'profile'));
208
+ if(snap.exists()) { userProfile = snap.data(); userProfileCache[uid] = userProfile; }
209
+ } catch {}
210
+ }
211
+ async function saveUserProfile(uid, name) {
212
+ if(!db) return;
213
+ await setDoc(doc(db, 'artifacts', appId, 'users', uid, 'user_data', 'profile'), { username: name });
214
+ userProfile = { username: name };
215
+ userProfileCache[uid] = userProfile;
216
+ document.getElementById('authStatus').textContent = name;
217
+ }
218
+ async function getProfile(uid) {
219
+ if(userProfileCache[uid]) return userProfileCache[uid];
220
+ try {
221
+ const snap = await getDoc(doc(db, 'artifacts', appId, 'users', uid, 'user_data', 'profile'));
222
+ if(snap.exists()) { userProfileCache[uid] = snap.data(); return snap.data(); }
223
+ } catch {}
224
+ return null;
225
+ }
226
+
227
+ function checkAppReady() { if(isAuthReady && isFontReady && userProfile) { initScene(); loadAllMaps(); } }
228
+
229
+ function initScene() {
230
+ if(scene) return;
231
+ scene = new THREE.Scene();
232
+ scene.fog = new THREE.FogExp2(0x020205, 0.005);
233
+
234
+ camera = new THREE.PerspectiveCamera(60, window.innerWidth/window.innerHeight, 0.1, 2500);
235
+ camera.position.set(0, 5, 30);
236
+
237
+ renderer = new THREE.WebGLRenderer({ antialias: false, powerPreference: "high-performance" });
238
+ renderer.setSize(window.innerWidth, window.innerHeight);
239
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
240
+ renderer.toneMapping = THREE.ReinhardToneMapping;
241
+ renderer.toneMappingExposure = 1.2;
242
+ document.getElementById('container').appendChild(renderer.domElement);
243
+
244
+ tooltip = document.getElementById('tooltip');
245
+
246
+ controls = new OrbitControls(camera, renderer.domElement);
247
+ controls.enableDamping = true;
248
+ controls.dampingFactor = 0.04;
249
+ controls.rotateSpeed = 0.5;
250
+ controls.zoomSpeed = 0.7;
251
+ controls.maxDistance = 600;
252
+ controls.target.set(0,0,0);
253
+
254
+ scene.add(new THREE.AmbientLight(0x404040, 1.0));
255
+ const dirLight = new THREE.DirectionalLight(0xaaccff, 1.2);
256
+ dirLight.position.set(50, 80, 50);
257
+ scene.add(dirLight);
258
+
259
+ const renderScene = new THREE.RenderPass(scene, camera);
260
+
261
+ const bloomPass = new THREE.UnrealBloomPass(new THREE.Vector2(window.innerWidth, window.innerHeight), 1.5, 0.4, 0.85);
262
+ bloomPass.threshold = 0.15;
263
+ bloomPass.strength = 1.4;
264
+ bloomPass.radius = 0.6;
265
+
266
+ composer = new THREE.EffectComposer(renderer);
267
+ composer.addPass(renderScene);
268
+ composer.addPass(bloomPass);
269
+
270
+ raycaster = new THREE.Raycaster();
271
+ mouse = new THREE.Vector2();
272
+
273
+ hashtagGroup = new THREE.Group();
274
+ scene.add(hashtagGroup);
275
+
276
+ createAdvancedBackground();
277
+ initAdvancedComet();
278
+
279
+ const visBtn = document.getElementById('visualizeButton');
280
+ if(!visBtn.dataset.bound) {
281
+ visBtn.addEventListener('click', handleAnalysisAndVisualization);
282
+ visBtn.dataset.bound = '1';
283
+ }
284
+
285
+ ['level1', 'level2', 'level3'].forEach(l => {
286
+ document.getElementById(`${l}Slider`).addEventListener('input', e => document.getElementById(`${l}Value`).innerText = e.target.value);
287
+ });
288
+
289
+ window.addEventListener('resize', onWindowResize);
290
+ window.addEventListener('mousemove', onPointerMove);
291
+
292
+ const mmCanvas = document.getElementById('minimap');
293
+ if(mmCanvas) {
294
+ minimapCtx = mmCanvas.getContext('2d');
295
+ mmCanvas.addEventListener('click', onMinimapClick);
296
+ }
297
+ document.getElementById('zoomInButton').addEventListener('click', () => { minimapScale *= 1.5; drawMinimap(); });
298
+ document.getElementById('zoomOutButton').addEventListener('click', () => { minimapScale /= 1.5; drawMinimap(); });
299
+
300
+ animate();
301
+ }
302
+
303
+ function createAdvancedBackground() {
304
+ const pGeo = new THREE.BufferGeometry();
305
+ const count = 5000;
306
+ const pos = new Float32Array(count * 3);
307
+ const sizes = new Float32Array(count);
308
+
309
+ for(let i=0; i<count; i++) {
310
+ pos[i*3] = (Math.random()-0.5) * 1500;
311
+ pos[i*3+1] = (Math.random()-0.5) * 1500;
312
+ pos[i*3+2] = (Math.random()-0.5) * 1500;
313
+ sizes[i] = Math.random();
314
+ }
315
+ pGeo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
316
+ pGeo.setAttribute('size', new THREE.BufferAttribute(sizes, 1));
317
+
318
+ const pMat = new THREE.PointsMaterial({
319
+ color: 0x88ccff,
320
+ size: 1.0,
321
+ transparent: true,
322
+ opacity: 0.6,
323
+ sizeAttenuation: true
324
+ });
325
+
326
+ bgParticles = new THREE.Points(pGeo, pMat);
327
+ scene.add(bgParticles);
328
+ }
329
+
330
+ function initAdvancedComet() {
331
+ if(cometGroup) { scene.remove(cometGroup); }
332
+ if(cometParticlesMesh) { scene.remove(cometParticlesMesh); }
333
+
334
+ if(!font || !userId) return;
335
+
336
+ userCentroidForComet.copy(getCurrentUserCentroid());
337
+
338
+ cometGroup = new THREE.Group();
339
+
340
+ const coreGeo = new THREE.SphereGeometry(0.5, 32, 32);
341
+ const coreMat = new THREE.MeshBasicMaterial({ color: 0xffffff });
342
+ cometHead = new THREE.Mesh(coreGeo, coreMat);
343
+
344
+ const haloGeo = new THREE.SphereGeometry(0.9, 32, 32);
345
+ const haloMat = new THREE.MeshBasicMaterial({
346
+ color: 0x00ffff,
347
+ transparent: true,
348
+ opacity: 0.25,
349
+ blending: THREE.AdditiveBlending
350
+ });
351
+ const halo = new THREE.Mesh(haloGeo, haloMat);
352
+ cometHead.add(halo);
353
+ cometGroup.add(cometHead);
354
+
355
+ cometLight = new THREE.PointLight(0x00ffff, 2.5, 60);
356
+ cometGroup.add(cometLight);
357
+
358
+ const word = COMET_WORDS[Math.floor(Math.random() * COMET_WORDS.length)];
359
+ const tGeo = new TextGeometry(word, { font: font, size: 0.4, height: 0.02, bevelEnabled: false });
360
+ tGeo.center();
361
+ const tMat = new THREE.MeshBasicMaterial({ color: 0xccffff });
362
+ cometText = new THREE.Mesh(tGeo, tMat);
363
+ cometText.position.y = 1.3;
364
+ cometGroup.add(cometText);
365
+
366
+ scene.add(cometGroup);
367
+
368
+ const pGeo = new THREE.BufferGeometry();
369
+ const positions = new Float32Array(COMET_PARTICLE_COUNT * 3);
370
+ const colors = new Float32Array(COMET_PARTICLE_COUNT * 3);
371
+ const sizes = new Float32Array(COMET_PARTICLE_COUNT);
372
+
373
+ pGeo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
374
+ pGeo.setAttribute('color', new THREE.BufferAttribute(colors, 3));
375
+ pGeo.setAttribute('size', new THREE.BufferAttribute(sizes, 1));
376
+
377
+ const pMat = new THREE.PointsMaterial({
378
+ vertexColors: true,
379
+ size: 1.0,
380
+ transparent: true,
381
+ opacity: 0.9,
382
+ blending: THREE.AdditiveBlending,
383
+ depthWrite: false,
384
+ sizeAttenuation: true
385
+ });
386
+
387
+ cometParticlesMesh = new THREE.Points(pGeo, pMat);
388
+ scene.add(cometParticlesMesh);
389
+
390
+ cometParticlesData = [];
391
+ for(let i=0; i<COMET_PARTICLE_COUNT; i++) {
392
+ cometParticlesData.push({
393
+ life: -1,
394
+ velocity: new THREE.Vector3()
395
+ });
396
+ positions[i*3] = 99999;
397
+ }
398
+ }
399
+
400
+ function updateAdvancedComet(delta, time) {
401
+ if(!cometGroup || !cometParticlesMesh) return;
402
+
403
+ cometAngle += delta * 0.35;
404
+ const rX = 80; const rZ = 60;
405
+
406
+ const x = userCentroidForComet.x + Math.cos(cometAngle) * rX;
407
+ const z = userCentroidForComet.z + Math.sin(cometAngle) * rZ;
408
+ const y = userCentroidForComet.y + Math.sin(cometAngle * 2.0) * 20;
409
+
410
+ const targetPos = new THREE.Vector3(x, y, z);
411
+
412
+ const nextPos = new THREE.Vector3(
413
+ userCentroidForComet.x + Math.cos(cometAngle + 0.1) * rX,
414
+ userCentroidForComet.y + Math.sin((cometAngle + 0.1) * 2.0) * 20,
415
+ userCentroidForComet.z + Math.sin(cometAngle + 0.1) * rZ
416
+ );
417
+ cometGroup.position.copy(targetPos);
418
+ cometGroup.lookAt(nextPos);
419
+
420
+ if(cometText) cometText.quaternion.copy(camera.quaternion);
421
+
422
+ const positions = cometParticlesMesh.geometry.attributes.position.array;
423
+ const colors = cometParticlesMesh.geometry.attributes.color.array;
424
+ const sizes = cometParticlesMesh.geometry.attributes.size.array;
425
+
426
+ let spawnCount = 5;
427
+ for(let i=0; i<COMET_PARTICLE_COUNT; i++) {
428
+ if(spawnCount > 0 && cometParticlesData[i].life < 0) {
429
+ cometParticlesData[i].life = 1.0;
430
+
431
+ positions[i*3] = cometGroup.position.x + (Math.random()-0.5);
432
+ positions[i*3+1] = cometGroup.position.y + (Math.random()-0.5);
433
+ positions[i*3+2] = cometGroup.position.z + (Math.random()-0.5);
434
+
435
+ colors[i*3] = 0.2; colors[i*3+1] = 1.0; colors[i*3+2] = 1.0;
436
+ sizes[i] = 1.2;
437
+ spawnCount--;
438
+ }
439
+ }
440
+
441
+ for(let i=0; i<COMET_PARTICLE_COUNT; i++) {
442
+ if(cometParticlesData[i].life > 0) {
443
+ const d = cometParticlesData[i];
444
+ d.life -= delta * 0.7;
445
+
446
+ if(d.life > 0.6) {
447
+ colors[i*3] = 0.2; colors[i*3+1] = 1.0; colors[i*3+2] = 1.0;
448
+ } else if (d.life > 0.3) {
449
+ colors[i*3] = 0.6; colors[i*3+1] = 0.2; colors[i*3+2] = 1.0;
450
+ } else {
451
+ colors[i*3] = 0.1; colors[i*3+1] = 0.1; colors[i*3+2] = 0.5;
452
+ }
453
+
454
+ sizes[i] = d.life * 1.8;
455
+
456
+ } else {
457
+ positions[i*3] = 99999;
458
+ }
459
+ }
460
+
461
+ cometParticlesMesh.geometry.attributes.position.needsUpdate = true;
462
+ cometParticlesMesh.geometry.attributes.color.needsUpdate = true;
463
+ cometParticlesMesh.geometry.attributes.size.needsUpdate = true;
464
+ }
465
+
466
+ function animate() {
467
+ requestAnimationFrame(animate);
468
+
469
+ const delta = clock.getDelta();
470
+ const time = clock.getElapsedTime();
471
+
472
+ controls.update();
473
+
474
+ if(bgParticles) {
475
+ bgParticles.rotation.y = time * 0.015;
476
+ bgParticles.rotation.z = time * 0.005;
477
+ }
478
+
479
+ updateAdvancedComet(delta, time);
480
+ updateRaycaster();
481
+ drawMinimap();
482
+
483
+ hashtagGroup.children.forEach(obj => {
484
+ if (obj.userData.isText) {
485
+ obj.lookAt(camera.position);
486
+ const dist = obj.position.distanceTo(camera.position);
487
+ let scale = (1/dist) * 12;
488
+ scale = Math.max(0.6, Math.min(5.0, scale));
489
+ obj.scale.set(scale, scale, scale);
490
+ }
491
+ });
492
+
493
+ composer.render();
494
+ }
495
+
496
+ function getCurrentUserCentroid() {
497
+ if (!userId || !userMaps[userId] || userMaps[userId].length === 0) return new THREE.Vector3(0,0,0);
498
+ const centroid = new THREE.Vector3(0,0,0);
499
+ userMaps[userId].forEach(o => centroid.add(o));
500
+ centroid.divideScalar(userMaps[userId].length);
501
+ return centroid;
502
+ }
503
+
504
+ function visualizeHashtags(dataList, origin, level, parentColor = null) {
505
+ if (!dataList || dataList.length === 0) return;
506
+ for (const item of dataList) {
507
+ let currentTag, variantsList;
508
+ if (level === 1) {
509
+ currentTag = normalizeString(item.palabra_principal);
510
+ variantsList = item.variantes || [];
511
+ } else if (level === 2) {
512
+ currentTag = normalizeString(item.palabra_variante);
513
+ variantsList = item.sub_variantes || [];
514
+ } else {
515
+ currentTag = normalizeString(item);
516
+ variantsList = [];
517
+ }
518
+ if (!currentTag) continue;
519
+
520
+ const { color, h } = stringToHslColor(currentTag);
521
+ const nodeColor = (level === 1) ? color : parentColor;
522
+
523
+ const nodeMaterial = new THREE.MeshPhysicalMaterial({
524
+ color: new THREE.Color(nodeColor),
525
+ emissive: new THREE.Color(nodeColor),
526
+ emissiveIntensity: level === 1 ? 0.8 : 0.4,
527
+ roughness: 0.2,
528
+ metalness: 0.1,
529
+ transmission: 0.1,
530
+ transparent: true,
531
+ opacity: 0.95
532
+ });
533
+
534
+ const theta = (h / 360) * Math.PI * 2;
535
+ let phiHash = 0;
536
+ for (let i = 0; i < currentTag.length; i++) phiHash = (phiHash + currentTag.charCodeAt(i) * 13) % 180;
537
+ const phi = ((phiHash / 180) * 90 + 45) * (Math.PI / 180);
538
+
539
+ const baseRadius = 12 / (level * level);
540
+ const cx = baseRadius * Math.sin(phi) * Math.cos(theta);
541
+ const cy = baseRadius * Math.cos(phi);
542
+ const cz = baseRadius * Math.sin(phi) * Math.sin(theta);
543
+ const clusterCenter = new THREE.Vector3(cx, cy, cz).add(origin);
544
+
545
+ const lineMat = new THREE.LineBasicMaterial({
546
+ color: new THREE.Color(nodeColor),
547
+ transparent: true,
548
+ opacity: 0.25
549
+ });
550
+ const line = new THREE.Line(new THREE.BufferGeometry().setFromPoints([origin, clusterCenter]), lineMat);
551
+ hashtagGroup.add(line);
552
+
553
+ let sRad = (level === 1) ? 0.35 : (level === 2 ? 0.18 : 0.1);
554
+ const sphere = new THREE.Mesh(new THREE.SphereGeometry(sRad, 16, 16), nodeMaterial);
555
+ sphere.position.copy(clusterCenter);
556
+ sphere.userData.hashtag = currentTag;
557
+ sphere.userData.level = level;
558
+ hashtagGroup.add(sphere);
559
+
560
+ let tSize = (level === 1) ? 0.35 : (level === 2 ? 0.18 : 0.12);
561
+ const tGeo = new THREE.TextGeometry(currentTag.toUpperCase(), { font: font, size: tSize, height: 0.01, bevelEnabled: false });
562
+ tGeo.computeBoundingBox();
563
+ const tMesh = new THREE.Mesh(tGeo, new THREE.MeshBasicMaterial({ color: nodeColor }));
564
+ tMesh.position.copy(clusterCenter);
565
+ tMesh.position.y += sRad + 0.15;
566
+ tMesh.position.x -= (tGeo.boundingBox.max.x - tGeo.boundingBox.min.x)/2;
567
+ tMesh.userData.isText = true;
568
+ hashtagGroup.add(tMesh);
569
+
570
+ visualizeHashtags(variantsList, clusterCenter, level + 1, nodeColor);
571
+ }
572
+ }
573
+
574
+ function visualizeRoot(topic, origin) {
575
+ const { color } = stringToHslColor(topic);
576
+ const mat = new THREE.MeshStandardMaterial({
577
+ color: new THREE.Color(color),
578
+ emissive: new THREE.Color(color),
579
+ emissiveIntensity: 2.2,
580
+ roughness: 0.4
581
+ });
582
+ const sphere = new THREE.Mesh(new THREE.SphereGeometry(0.7, 32, 32), mat);
583
+ sphere.position.copy(origin);
584
+ sphere.userData.hashtag = topic;
585
+ sphere.userData.level = 0;
586
+ hashtagGroup.add(sphere);
587
+
588
+ const tGeo = new THREE.TextGeometry(topic.toUpperCase(), { font: font, size: 0.6, height: 0.05, bevelEnabled: false });
589
+ tGeo.computeBoundingBox();
590
+ const tMesh = new THREE.Mesh(tGeo, new THREE.MeshBasicMaterial({ color: 0xffffff }));
591
+ tMesh.position.copy(origin);
592
+ tMesh.position.y += 1.0;
593
+ tMesh.position.x -= (tGeo.boundingBox.max.x - tGeo.boundingBox.min.x) / 2;
594
+ tMesh.userData.isText = true;
595
+ hashtagGroup.add(tMesh);
596
+ }
597
+
598
+ function createUserSun(pos, name, isMe) {
599
+ const col = isMe ? 0xffaa00 : 0x00ff88;
600
+ const mat = new THREE.MeshStandardMaterial({
601
+ color: col, emissive: col, emissiveIntensity: 1.8, roughness: 0.2
602
+ });
603
+ const mesh = new THREE.Mesh(new THREE.SphereGeometry(3, 32, 32), mat);
604
+ mesh.position.copy(pos);
605
+ mesh.userData.hashtag = `Usuario: ${name}`;
606
+ mesh.userData.isPlaceholder = true;
607
+ hashtagGroup.add(mesh);
608
+
609
+ const tGeo = new THREE.TextGeometry(name.toUpperCase(), { font: font, size: 1.2, height: 0.1 });
610
+ tGeo.computeBoundingBox();
611
+ const tMesh = new THREE.Mesh(tGeo, new THREE.MeshBasicMaterial({ color: 0xffffff }));
612
+ tMesh.position.copy(pos);
613
+ tMesh.position.y += 4;
614
+ tMesh.position.x -= (tGeo.boundingBox.max.x - tGeo.boundingBox.min.x)/2;
615
+ tMesh.userData.isText = true;
616
+ hashtagGroup.add(tMesh);
617
+ }
618
+
619
+ async function fetchWithTimeout(url, options = {}, timeoutMs = 25000) {
620
+ const controller = new AbortController();
621
+ const id = setTimeout(() => controller.abort(), timeoutMs);
622
+ try {
623
+ return await fetch(url, { ...options, signal: controller.signal });
624
+ } finally { clearTimeout(id); }
625
+ }
626
+
627
+ async function fetchWithBackoff(url, options, retries = 2, delay = 1000) {
628
+ try {
629
+ return await fetchWithTimeout(url, options);
630
+ } catch (err) {
631
+ if (retries > 0) {
632
+ await new Promise(r => setTimeout(r, delay));
633
+ return fetchWithBackoff(url, options, retries - 1, delay * 2);
634
+ } else { throw err; }
635
+ }
636
+ }
637
+
638
+ async function callGemini(topic, mc, vc, svc) {
639
+ const k = getLocalGeminiKey();
640
+ if(!k || k.length<10) throw new Error("Falta API Key");
641
+
642
+ const schema = {
643
+ type: "OBJECT",
644
+ properties: {
645
+ analisis: { type: "STRING" },
646
+ lista_palabras: { type: "ARRAY", items: { type: "OBJECT", properties: { palabra_principal: {type:"STRING"}, variantes: {type:"ARRAY", items: {type:"OBJECT", properties: {palabra_variante: {type:"STRING"}, sub_variantes: {type:"ARRAY", items:{type:"STRING"}}}} } } }
647
+ }
648
+ };
649
+ const prompt = `Tema: ${topic}. 1. Genera ${mc} palabras clave (Nivel 1). 2. Para cada una, ${vc} variantes (Nivel 2). 3. Para cada variante, ${svc} sub-variantes (Nivel 3). JSON Puro sin markdown.`;
650
+
651
+ const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-preview-09-2025:generateContent?key=${k}`;
652
+
653
+ if (!isHFStatic) {
654
+ try {
655
+ const proxyResp = await fetchWithBackoff('/.netlify/functions/gemini-proxy', {
656
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
657
+ body: JSON.stringify({ model: 'gemini-2.5-flash-preview-09-2025', payload: { contents: [{parts:[{text: prompt}]}] } })
658
+ }, 1, 1000);
659
+ if (proxyResp.ok) return await proxyResp.json();
660
+ } catch (e) {}
661
+ }
662
+
663
+ const resp = await fetchWithBackoff(url, {
664
+ method: 'POST', headers: {'Content-Type': 'application/json'},
665
+ body: JSON.stringify({ contents: [{parts:[{text: prompt}]}], generationConfig: { responseMimeType: "application/json", responseSchema: schema } })
666
+ });
667
+ if(!resp.ok) throw new Error(await resp.text());
668
+ return await resp.json();
669
+ }
670
+
671
+ async function handleAnalysisAndVisualization() {
672
+ if(!font) return;
673
+ const topic = normalizeString(document.getElementById('topicInput').value);
674
+ if(!topic) return;
675
+
676
+ const btn = document.getElementById('visualizeButton');
677
+ const pb = document.getElementById('progressBar');
678
+ const pbc = document.getElementById('progressBarContainer');
679
+ const originalBtnHtml = btn.innerHTML;
680
+
681
+ btn.disabled = true; btn.innerHTML = '<span class="animate-pulse">PROCESANDO RED...</span>';
682
+ pbc.classList.remove('hidden'); pb.style.width = "90%"; pb.style.transition = "width 15s ease-out";
683
+
684
+ try {
685
+ const mc = document.getElementById('level1Slider').value;
686
+ const vc = document.getElementById('level2Slider').value;
687
+ const svc = document.getElementById('level3Slider').value;
688
+
689
+ let origin = new THREE.Vector3(0,0,0);
690
+ const myMaps = userMaps[userId] || [];
691
+ if(myMaps.length === 0) {
692
+ const globalIdx = Object.keys(userMaps).length;
693
+ origin.set(globalIdx*800*Math.cos(globalIdx), 0, globalIdx*800*Math.sin(globalIdx));
694
+ } else {
695
+ let center = new THREE.Vector3(); myMaps.forEach(m=>center.add(m)); center.divideScalar(myMaps.length);
696
+ const r = Math.sqrt(myMaps.length)*40; const a = myMaps.length;
697
+ origin.set(center.x + r*Math.cos(a), 0, center.z + r*Math.sin(a));
698
+ }
699
+
700
+ const res = await callGemini(topic, mc, vc, svc);
701
+ const txt = res.candidates?.[0]?.content?.parts?.[0]?.text;
702
+ if(!txt) throw new Error("La IA no generó datos válidos.");
703
+ const json = JSON.parse(txt);
704
+
705
+ visualizeRoot(topic, origin);
706
+ visualizeHashtags(json.lista_palabras, origin, 1);
707
+ if(db && userId) await addDoc(collection(db,'artifacts',appId,'public','data','maps'), {
708
+ topic, depth: "3", origin: {x:origin.x, y:origin.y, z:origin.z}, data: JSON.stringify(json), createdAt: new Date(), userId
709
+ });
710
+
711
+ } catch(e) {
712
+ console.error(e);
713
+ alert("Error en análisis: " + e.message);
714
+ } finally {
715
+ btn.disabled = false; btn.innerHTML = originalBtnHtml;
716
+ pb.style.width = "100%"; setTimeout(()=>{pbc.classList.add('hidden'); pb.style.width="0%";}, 500);
717
+ }
718
+ }
719
+
720
+ function loadAllMaps() {
721
+ if(!db || !font) { setTimeout(loadAllMaps,500); return; }
722
+ const q = query(collection(db,'artifacts',appId,'public','data','maps'));
723
+
724
+ onSnapshot(q, async (snap) => {
725
+ while(hashtagGroup.children.length > 0){
726
+ let obj = hashtagGroup.children[0];
727
+ hashtagGroup.remove(obj);
728
+ if(obj.geometry) obj.geometry.dispose();
729
+ if(obj.material) {
730
+ if(Array.isArray(obj.material)) obj.material.forEach(m=>m.dispose());
731
+ else obj.material.dispose();
732
+ }
733
+ }
734
+
735
+ userMaps = {}; allMapsDataCache = {};
736
+
737
+ snap.docs.forEach(d => {
738
+ const m = d.data();
739
+ if(!m.origin) return;
740
+ const o = new THREE.Vector3(m.origin.x, m.origin.y, m.origin.z);
741
+ if(!userMaps[m.userId]) userMaps[m.userId] = [];
742
+ userMaps[m.userId].push(o);
743
+
744
+ try {
745
+ const data = JSON.parse(m.data);
746
+ visualizeRoot(m.topic, o);
747
+ visualizeHashtags(data.lista_palabras, o, 1);
748
+ } catch {}
749
+ });
750
+
751
+ const uids = Object.keys(userMaps);
752
+ const list = document.getElementById('userList');
753
+ if(list) list.innerHTML = '';
754
+
755
+ for(const uid of uids) {
756
+ const prof = await getProfile(uid);
757
+ const name = prof ? prof.username : "ANON " + uid.substring(0,4);
758
+
759
+ if(list) {
760
+ const item = document.createElement('div');
761
+ item.className = 'text-cyan-400 hover:text-white cursor-pointer hover:bg-white/5 p-1 rounded transition-colors text-[10px] tracking-wide';
762
+ item.innerHTML = `> <span class="font-bold">${name}</span>`;
763
+ item.onclick = () => teleportToUser(uid);
764
+ list.appendChild(item);
765
+ }
766
+
767
+ const origins = userMaps[uid];
768
+ if(origins.length > 0) {
769
+ const c = new THREE.Vector3(); origins.forEach(p=>c.add(p)); c.divideScalar(origins.length);
770
+ createUserSun(c, name, uid === userId);
771
+ }
772
+ }
773
+
774
+ initAdvancedComet();
775
+ drawMinimap();
776
+ focusOnUserMaps();
777
+ });
778
+ }
779
+
780
+ function focusOnUserMaps() {
781
+ if(!controls || !userId || !userMaps[userId]) return;
782
+ const c = getCurrentUserCentroid();
783
+ controls.target.copy(c);
784
+ camera.position.copy(c).add(new THREE.Vector3(0,10,40));
785
+ }
786
+
787
+ function teleportToUser(uid) {
788
+ if(!userMaps[uid]) return;
789
+ const origins = userMaps[uid];
790
+ const c = new THREE.Vector3(); origins.forEach(p=>c.add(p)); c.divideScalar(origins.length);
791
+ controls.target.copy(c);
792
+ camera.position.copy(c).add(new THREE.Vector3(0,10,40));
793
+ }
794
+
795
+ function drawMinimap() {
796
+ if(!minimapCtx) return;
797
+ const w = minimapCtx.canvas.width; const h = minimapCtx.canvas.height;
798
+ minimapCtx.clearRect(0,0,w,h);
799
+ minimapDotCoords = [];
800
+
801
+ const myC = getCurrentUserCentroid();
802
+
803
+ Object.keys(userMaps).forEach(uid => {
804
+ const origins = userMaps[uid];
805
+ const c = new THREE.Vector3(); origins.forEach(p=>c.add(p)); c.divideScalar(origins.length);
806
+
807
+ const x = w/2 + (c.x - myC.x)*minimapScale;
808
+ const y = h/2 + (c.z - myC.z)*minimapScale;
809
+
810
+ const isMe = (uid === userId);
811
+ const mainColor = isMe ? '#22d3ee' : '#64748b';
812
+
813
+ minimapDotCoords.push({x, y, uid});
814
+
815
+ if(origins.length > 0) {
816
+ const satColor = isMe ? 'rgba(34, 211, 238, 0.4)' : 'rgba(100, 116, 139, 0.4)';
817
+ origins.forEach(o => {
818
+ const sx = w/2 + (o.x - myC.x)*minimapScale;
819
+ const sy = h/2 + (o.z - myC.z)*minimapScale;
820
+ minimapCtx.beginPath();
821
+ minimapCtx.arc(sx, sy, 1, 0, Math.PI*2);
822
+ minimapCtx.fillStyle = satColor;
823
+ minimapCtx.fill();
824
+ });
825
+ }
826
+
827
+ minimapCtx.fillStyle = mainColor;
828
+ minimapCtx.beginPath();
829
+ minimapCtx.arc(x,y, isMe?4:2.5, 0, Math.PI*2);
830
+ minimapCtx.fill();
831
+
832
+ if(isMe) {
833
+ minimapCtx.strokeStyle = 'rgba(34, 211, 238, 0.3)';
834
+ minimapCtx.beginPath(); minimapCtx.arc(x,y, 8, 0, Math.PI*2); minimapCtx.stroke();
835
+ }
836
+ });
837
+
838
+ if (cometGroup) {
839
+ const cometX = w/2 + (cometGroup.position.x - myC.x) * minimapScale;
840
+ const cometY = h/2 + (cometGroup.position.z - myC.z) * minimapScale;
841
+
842
+ minimapCtx.beginPath();
843
+ minimapCtx.arc(cometX, cometY, 3, 0, Math.PI * 2);
844
+ minimapCtx.fillStyle = 'rgba(0, 255, 255, 0.4)';
845
+ minimapCtx.fill();
846
+
847
+ minimapCtx.beginPath();
848
+ minimapCtx.arc(cometX, cometY, 1.5, 0, Math.PI * 2);
849
+ minimapCtx.fillStyle = '#ffffff';
850
+ minimapCtx.fill();
851
+ }
852
+ }
853
+
854
+ function onMinimapClick(e) {
855
+ if(!minimapCtx) return;
856
+ const rect = minimapCtx.canvas.getBoundingClientRect();
857
+ const x = e.clientX - rect.left;
858
+ const y = e.clientY - rect.top;
859
+
860
+ let closest = null;
861
+ let minD = 20;
862
+
863
+ minimapDotCoords.forEach(dot => {
864
+ const d = Math.sqrt((x-dot.x)**2 + (y-dot.y)**2);
865
+ if(d < minD) { minD = d; closest = dot.uid; }
866
+ });
867
+
868
+ if(closest) teleportToUser(closest);
869
+ }
870
+
871
+ function onWindowResize() {
872
+ camera.aspect = window.innerWidth/window.innerHeight;
873
+ camera.updateProjectionMatrix();
874
+ renderer.setSize(window.innerWidth, window.innerHeight);
875
+ composer.setSize(window.innerWidth, window.innerHeight);
876
+ }
877
+
878
+ function onPointerMove(e) {
879
+ mouse.x = (e.clientX/window.innerWidth)*2-1;
880
+ mouse.y = -(e.clientY/window.innerHeight)*2+1;
881
+ tooltip.style.left = e.clientX+20+'px';
882
+ tooltip.style.top = e.clientY+'px';
883
+ }
884
+
885
+ function updateRaycaster() {
886
+ raycaster.setFromCamera(mouse, camera);
887
+ let targets = [...hashtagGroup.children];
888
+ if(cometHead) targets.push(cometHead);
889
+
890
+ const intersects = raycaster.intersectObjects(targets, false);
891
+
892
+ if(intersects.length > 0) {
893
+ let o = intersects[0].object;
894
+ if(o.parent === cometHead || o.parent === cometGroup) o = cometHead;
895
+
896
+ if(o === cometHead) {
897
+ tooltip.classList.remove('hidden');
898
+ const word = cometText ? cometText.geometry.parameters.text : "ENLACE";
899
+ tooltip.innerHTML = `
900
+ <div class="border-b border-cyan-500/50 pb-1 mb-1 text-cyan-300 font-bold tracking-widest text-xs">COMETA NEURAL</div>
901
+ <div class="text-white text-[10px]">SEÑAL: ${word}</div>
902
+ `;
903
+ document.body.style.cursor = 'pointer';
904
+ return;
905
+ }
906
+
907
+ const d = o.userData;
908
+ if(d.hashtag) {
909
+ tooltip.classList.remove('hidden');
910
+ let typeColor = d.isPlaceholder ? "text-yellow-400" : "text-cyan-300";
911
+ let typeText = d.isPlaceholder ? "NODO USUARIO" : `NIVEL ${d.level}`;
912
+
913
+ tooltip.innerHTML = `
914
+ <div class="${typeColor} font-bold tracking-widest text-sm mb-1">${d.hashtag}</div>
915
+ <div class="text-gray-400 text-[10px] uppercase">${typeText}</div>
916
+ `;
917
+ document.body.style.cursor = 'pointer';
918
+ }
919
+ } else {
920
+ tooltip.classList.add('hidden');
921
+ document.body.style.cursor = 'default';
922
+ }
923
+ }
924
+
925
+ function stringToHslColor(str) {
926
+ let hash = 0; for (let i = 0; i < str.length; i++) hash = str.charCodeAt(i) + ((hash << 5) - hash);
927
+ return { color: `hsl(${Math.abs(hash % 360)}, 75%, 60%)`, h: Math.abs(hash % 360) };
928
+ }
styles.css ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ body {
2
+ scrollbar-width: none;
3
+ -ms-overflow-style: none;
4
+ background-color: #020205;
5
+ }
6
+ body::-webkit-scrollbar { display: none; }
7
+
8
+ .glass-panel {
9
+ background: rgba(10, 15, 30, 0.75);
10
+ backdrop-filter: blur(16px);
11
+ -webkit-backdrop-filter: blur(16px);
12
+ border: 1px solid rgba(255, 255, 255, 0.08);
13
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
14
+ }
15
+
16
+ @keyframes shimmer {
17
+ 0% { transform: translateX(-100%); }
18
+ 100% { transform: translateX(100%); }
19
+ }
20
+ .animate-shimmer {
21
+ animation: shimmer 2s infinite;
22
+ }
23
+
24
+ .custom-scrollbar::-webkit-scrollbar {
25
+ width: 4px;
26
+ }
27
+ .custom-scrollbar::-webkit-scrollbar-track {
28
+ background: rgba(0,0,0,0.3);
29
+ }
30
+ .custom-scrollbar::-webkit-scrollbar-thumb {
31
+ background: #22d3ee;
32
+ border-radius: 4px;
33
+ }