salomonsky commited on
Commit
338bfd5
·
verified ·
1 Parent(s): fa2af09

Update index.html

Browse files
Files changed (1) hide show
  1. index.html +965 -2
index.html CHANGED
@@ -6,7 +6,41 @@
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">
@@ -163,6 +197,935 @@
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>
 
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
+ <style>
10
+ body {
11
+ scrollbar-width: none;
12
+ -ms-overflow-style: none;
13
+ background-color: #020205;
14
+ }
15
+ body::-webkit-scrollbar { display: none; }
16
+
17
+ .glass-panel {
18
+ background: rgba(10, 15, 30, 0.75);
19
+ backdrop-filter: blur(16px);
20
+ -webkit-backdrop-filter: blur(16px);
21
+ border: 1px solid rgba(255, 255, 255, 0.08);
22
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
23
+ }
24
+
25
+ @keyframes shimmer {
26
+ 0% { transform: translateX(-100%); }
27
+ 100% { transform: translateX(100%); }
28
+ }
29
+ .animate-shimmer {
30
+ animation: shimmer 2s infinite;
31
+ }
32
+
33
+ .custom-scrollbar::-webkit-scrollbar {
34
+ width: 4px;
35
+ }
36
+ .custom-scrollbar::-webkit-scrollbar-track {
37
+ background: rgba(0,0,0,0.3);
38
+ }
39
+ .custom-scrollbar::-webkit-scrollbar-thumb {
40
+ background: #22d3ee;
41
+ border-radius: 4px;
42
+ }
43
+ </style>
44
  </head>
45
 
46
  <body class="m-0 overflow-hidden font-[Orbitron] text-slate-200">
 
197
  <script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/shaders/LuminosityHighPassShader.js"></script>
198
  <script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/UnrealBloomPass.js"></script>
199
 
200
+ <script type="module">
201
+ import { initializeApp } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-app.js";
202
+ import { getAnalytics } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-analytics.js";
203
+ import { getAuth, onAuthStateChanged, createUserWithEmailAndPassword, signInWithEmailAndPassword, signInAnonymously, signOut, setPersistence, browserLocalPersistence } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-auth.js";
204
+ import { getFirestore, doc, addDoc, onSnapshot, collection, setDoc, getDoc, query, setLogLevel } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-firestore.js";
205
+
206
+ const firebaseConfig = {
207
+ apiKey: "AIzaSyB4EZKkAH3weFSZNBQA8Y63gt5XbaeZGsQ",
208
+ authDomain: "neuronal-1f3b9.firebaseapp.com",
209
+ projectId: "neuronal-1f3b9",
210
+ storageBucket: "neuronal-1f3b9.firebasestorage.app",
211
+ messagingSenderId: "208887839866",
212
+ appId: "1:208887839866:web:adbb697dd0b63195b10fc3",
213
+ measurementId: "G-102SEBLQFJ"
214
+ };
215
+
216
+ const DEFAULT_GEMINI_KEY = 'AIzaSyDmoQNpzgzW21f_WFCU9YbaAeI1fdOJMlo';
217
+ function getLocalGeminiKey() {
218
+ try { return localStorage.getItem('GEMINI_API_KEY') || DEFAULT_GEMINI_KEY; } catch { return DEFAULT_GEMINI_KEY; }
219
+ }
220
+ function setLocalGeminiKey(k) {
221
+ try { localStorage.setItem('GEMINI_API_KEY', k || ''); } catch {}
222
+ }
223
+
224
+ const THREE = window.THREE;
225
+ const OrbitControls = THREE.OrbitControls;
226
+ const TextGeometry = THREE.TextGeometry;
227
+ const FontLoader = THREE.FontLoader;
228
+
229
+ let scene, camera, renderer, controls, raycaster, mouse;
230
+ let composer;
231
+ let hashtagGroup, tooltip;
232
+ let font;
233
+ let clock = new THREE.Clock();
234
+
235
+ let cometGroup, cometHead, cometLight, cometText;
236
+ let cometParticlesGeometry, cometParticlesMaterial, cometParticlesMesh;
237
+ let cometParticlesData = [];
238
+ const COMET_PARTICLE_COUNT = 400;
239
+ const COMET_WORDS = ["NEXUS", "DATA", "VOID", "SIGNAL", "CYBER", "PULSE", "NODE", "FLUX", "SYNTH", "CORE", "ORBIT", "LINK"];
240
+ let cometAngle = 0;
241
+ let userCentroidForComet = new THREE.Vector3(0,0,0);
242
+
243
+ let bgParticles;
244
+
245
+ let db, auth, analytics, userId = null, appId = "neuronal-1f3b9", userProfile = null;
246
+ let userMaps = {}, userProfileCache = {}, allMapsDataCache = {};
247
+ let isAuthReady = false, isFontReady = false;
248
+ let minimapCtx, minimapDotCoords = [], minimapScale = 0.025;
249
+ const MINIMAP_DOT_SIZE = 2;
250
+ let intersected = {};
251
+
252
+ const isHFStatic = /\.hf\.space$/.test(location.hostname);
253
+ let authMode = null;
254
+
255
+ const normalizeString = (str) => {
256
+ if (!str) return "";
257
+ return str.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
258
+ };
259
+
260
+ const loader = new FontLoader();
261
+ loader.load('https://cdn.jsdelivr.net/npm/three@0.128.0/examples/fonts/helvetiker_regular.typeface.json', (loadedFont) => {
262
+ font = loadedFont;
263
+ isFontReady = true;
264
+ checkAppReady();
265
+ });
266
+
267
+ initFirebase();
268
+
269
+ const gkInput = document.getElementById('geminiKeyInput');
270
+ const gkBtn = document.getElementById('saveGeminiKeyBtn');
271
+ const gkStatus = document.getElementById('geminiKeyStatus');
272
+ if (gkInput && gkBtn) {
273
+ const existing = getLocalGeminiKey();
274
+ if(existing && existing !== DEFAULT_GEMINI_KEY) gkInput.value = existing;
275
+ gkBtn.addEventListener('click', (e) => {
276
+ e.preventDefault();
277
+ setLocalGeminiKey(gkInput.value.trim());
278
+ gkStatus.textContent = 'GUARDADO';
279
+ setTimeout(() => gkStatus.textContent = '', 2000);
280
+ });
281
+ }
282
+
283
+ async function initFirebase() {
284
+ try {
285
+ const app = initializeApp(firebaseConfig);
286
+ db = getFirestore(app);
287
+ auth = getAuth(app);
288
+ analytics = getAnalytics(app);
289
+ setLogLevel('Silent');
290
+
291
+ const els = {
292
+ msg: document.getElementById('loginMessage'),
293
+ overlay: document.getElementById('loginOverlay'),
294
+ step1: document.getElementById('authStep1'),
295
+ step2: document.getElementById('authStep2'),
296
+ email: document.getElementById('loginEmail'),
297
+ pass: document.getElementById('loginPassword'),
298
+ loginBtn: document.getElementById('loginButton'),
299
+ regBtn: document.getElementById('registerButton'),
300
+ anonBtn: document.getElementById('btnGoToAnon'),
301
+ saveUserBtn: document.getElementById('saveUsernameButton'),
302
+ userInput: document.getElementById('usernameInput'),
303
+ backBtn: document.getElementById('backToStep1'),
304
+ logoutBtn: document.getElementById('mainLogoutButton'),
305
+ ui: document.getElementById('ui')
306
+ };
307
+
308
+ els.regBtn.addEventListener('click', async () => {
309
+ if(els.email.value.length < 6) {
310
+ els.msg.innerText = "Email/Pass muy corto"; return;
311
+ }
312
+ els.msg.innerText = "Procesando registro...";
313
+ authMode = 'email';
314
+ try {
315
+ await setPersistence(auth, browserLocalPersistence);
316
+ await createUserWithEmailAndPassword(auth, els.email.value, els.pass.value);
317
+ } catch(e) { els.msg.innerText = "Error: " + e.message; }
318
+ });
319
+
320
+ els.loginBtn.addEventListener('click', async () => {
321
+ els.msg.innerText = "Autenticando...";
322
+ authMode = 'email';
323
+ try {
324
+ await setPersistence(auth, browserLocalPersistence);
325
+ await signInWithEmailAndPassword(auth, els.email.value, els.pass.value);
326
+ } catch(e) { els.msg.innerText = "Error: " + e.message; }
327
+ });
328
+
329
+ els.anonBtn.addEventListener('click', () => {
330
+ authMode = 'anonymous';
331
+ els.step1.classList.add('hidden');
332
+ els.step2.classList.remove('hidden');
333
+ });
334
+
335
+ els.backBtn.addEventListener('click', () => {
336
+ els.step2.classList.add('hidden');
337
+ els.step1.classList.remove('hidden');
338
+ els.msg.innerText = "";
339
+ });
340
+
341
+ els.saveUserBtn.addEventListener('click', async () => {
342
+ const name = normalizeString(els.userInput.value.trim());
343
+ if(name.length < 3) {
344
+ els.userInput.classList.add('border-red-500'); return;
345
+ }
346
+ els.userInput.classList.remove('border-red-500');
347
+ els.saveUserBtn.innerText = "ESTABLECIENDO ENLACE...";
348
+ els.saveUserBtn.disabled = true;
349
+ try {
350
+ if(authMode === 'anonymous') await signInAnonymously(auth);
351
+ if(auth.currentUser) {
352
+ await saveUserProfile(auth.currentUser.uid, name);
353
+ els.overlay.style.opacity = '0';
354
+ setTimeout(() => els.overlay.style.display = 'none', 700);
355
+ initScene();
356
+ loadAllMaps();
357
+ els.ui.classList.remove('hidden');
358
+ setTimeout(() => {
359
+ els.ui.style.transform = 'translateX(0)';
360
+ els.ui.style.opacity = '1';
361
+ }, 100);
362
+ }
363
+ } catch(e) {
364
+ els.saveUserBtn.innerText = "ERROR DE CONEXIÓN";
365
+ els.saveUserBtn.disabled = false;
366
+ console.error(e);
367
+ }
368
+ });
369
+
370
+ els.logoutBtn.addEventListener('click', async () => { await signOut(auth); location.reload(); });
371
+
372
+ onAuthStateChanged(auth, async (user) => {
373
+ if(user) {
374
+ userId = user.uid;
375
+ isAuthReady = true;
376
+ await fetchUserProfile(userId);
377
+ if(userProfile) {
378
+ els.overlay.style.opacity = '0';
379
+ setTimeout(() => els.overlay.style.display = 'none', 700);
380
+ els.ui.classList.remove('hidden');
381
+ setTimeout(() => {
382
+ els.ui.style.transform = 'translateX(0)';
383
+ els.ui.style.opacity = '1';
384
+ }, 100);
385
+ document.getElementById('authStatus').textContent = userProfile.username;
386
+ if(!scene) { initScene(); loadAllMaps(); }
387
+ } else {
388
+ els.step1.classList.add('hidden');
389
+ els.step2.classList.remove('hidden');
390
+ if(authMode === 'email') els.userInput.focus();
391
+ }
392
+ } else {
393
+ els.overlay.style.display = 'flex';
394
+ els.overlay.style.opacity = '1';
395
+ els.step1.classList.remove('hidden');
396
+ els.step2.classList.add('hidden');
397
+ els.ui.classList.add('hidden');
398
+ }
399
+ });
400
+
401
+ } catch (e) { console.error("Firebase Error", e); }
402
+ }
403
+
404
+ async function fetchUserProfile(uid) {
405
+ if(!db) return;
406
+ try {
407
+ const snap = await getDoc(doc(db, 'artifacts', appId, 'users', uid, 'user_data', 'profile'));
408
+ if(snap.exists()) { userProfile = snap.data(); userProfileCache[uid] = userProfile; }
409
+ } catch {}
410
+ }
411
+ async function saveUserProfile(uid, name) {
412
+ if(!db) return;
413
+ await setDoc(doc(db, 'artifacts', appId, 'users', uid, 'user_data', 'profile'), { username: name });
414
+ userProfile = { username: name };
415
+ userProfileCache[uid] = userProfile;
416
+ document.getElementById('authStatus').textContent = name;
417
+ }
418
+ async function getProfile(uid) {
419
+ if(userProfileCache[uid]) return userProfileCache[uid];
420
+ try {
421
+ const snap = await getDoc(doc(db, 'artifacts', appId, 'users', uid, 'user_data', 'profile'));
422
+ if(snap.exists()) { userProfileCache[uid] = snap.data(); return snap.data(); }
423
+ } catch {}
424
+ return null;
425
+ }
426
+
427
+ function checkAppReady() { if(isAuthReady && isFontReady && userProfile) { initScene(); loadAllMaps(); } }
428
+
429
+ function initScene() {
430
+ if(scene) return;
431
+ scene = new THREE.Scene();
432
+ scene.fog = new THREE.FogExp2(0x020205, 0.005);
433
+
434
+ camera = new THREE.PerspectiveCamera(60, window.innerWidth/window.innerHeight, 0.1, 2500);
435
+ camera.position.set(0, 5, 30);
436
+
437
+ renderer = new THREE.WebGLRenderer({ antialias: false, powerPreference: "high-performance" });
438
+ renderer.setSize(window.innerWidth, window.innerHeight);
439
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
440
+ renderer.toneMapping = THREE.ReinhardToneMapping;
441
+ renderer.toneMappingExposure = 1.2;
442
+ document.getElementById('container').appendChild(renderer.domElement);
443
+
444
+ tooltip = document.getElementById('tooltip');
445
+
446
+ controls = new OrbitControls(camera, renderer.domElement);
447
+ controls.enableDamping = true;
448
+ controls.dampingFactor = 0.04;
449
+ controls.rotateSpeed = 0.5;
450
+ controls.zoomSpeed = 0.7;
451
+ controls.maxDistance = 600;
452
+ controls.target.set(0,0,0);
453
+
454
+ scene.add(new THREE.AmbientLight(0x404040, 1.0));
455
+ const dirLight = new THREE.DirectionalLight(0xaaccff, 1.2);
456
+ dirLight.position.set(50, 80, 50);
457
+ scene.add(dirLight);
458
+
459
+ const renderScene = new THREE.RenderPass(scene, camera);
460
+
461
+ const bloomPass = new THREE.UnrealBloomPass(new THREE.Vector2(window.innerWidth, window.innerHeight), 1.5, 0.4, 0.85);
462
+ bloomPass.threshold = 0.15;
463
+ bloomPass.strength = 1.4;
464
+ bloomPass.radius = 0.6;
465
+
466
+ composer = new THREE.EffectComposer(renderer);
467
+ composer.addPass(renderScene);
468
+ composer.addPass(bloomPass);
469
+
470
+ raycaster = new THREE.Raycaster();
471
+ mouse = new THREE.Vector2();
472
+
473
+ hashtagGroup = new THREE.Group();
474
+ scene.add(hashtagGroup);
475
+
476
+ createAdvancedBackground();
477
+ initAdvancedComet();
478
+
479
+ const visBtn = document.getElementById('visualizeButton');
480
+ if(!visBtn.dataset.bound) {
481
+ visBtn.addEventListener('click', handleAnalysisAndVisualization);
482
+ visBtn.dataset.bound = '1';
483
+ }
484
+
485
+ ['level1', 'level2', 'level3'].forEach(l => {
486
+ document.getElementById(`${l}Slider`).addEventListener('input', e => document.getElementById(`${l}Value`).innerText = e.target.value);
487
+ });
488
+
489
+ window.addEventListener('resize', onWindowResize);
490
+ window.addEventListener('mousemove', onPointerMove);
491
+
492
+ const mmCanvas = document.getElementById('minimap');
493
+ if(mmCanvas) {
494
+ minimapCtx = mmCanvas.getContext('2d');
495
+ mmCanvas.addEventListener('click', onMinimapClick);
496
+ }
497
+ document.getElementById('zoomInButton').addEventListener('click', () => { minimapScale *= 1.5; drawMinimap(); });
498
+ document.getElementById('zoomOutButton').addEventListener('click', () => { minimapScale /= 1.5; drawMinimap(); });
499
+
500
+ animate();
501
+ }
502
+
503
+ function createAdvancedBackground() {
504
+ const pGeo = new THREE.BufferGeometry();
505
+ const count = 5000;
506
+ const pos = new Float32Array(count * 3);
507
+ const sizes = new Float32Array(count);
508
+
509
+ for(let i=0; i<count; i++) {
510
+ pos[i*3] = (Math.random()-0.5) * 1500;
511
+ pos[i*3+1] = (Math.random()-0.5) * 1500;
512
+ pos[i*3+2] = (Math.random()-0.5) * 1500;
513
+ sizes[i] = Math.random();
514
+ }
515
+ pGeo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
516
+ pGeo.setAttribute('size', new THREE.BufferAttribute(sizes, 1));
517
+
518
+ const pMat = new THREE.PointsMaterial({
519
+ color: 0x88ccff,
520
+ size: 1.0,
521
+ transparent: true,
522
+ opacity: 0.6,
523
+ sizeAttenuation: true
524
+ });
525
+
526
+ bgParticles = new THREE.Points(pGeo, pMat);
527
+ scene.add(bgParticles);
528
+ }
529
+
530
+ function initAdvancedComet() {
531
+ if(cometGroup) { scene.remove(cometGroup); }
532
+ if(cometParticlesMesh) { scene.remove(cometParticlesMesh); }
533
+
534
+ if(!font || !userId) return;
535
+
536
+ userCentroidForComet.copy(getCurrentUserCentroid());
537
+
538
+ cometGroup = new THREE.Group();
539
+
540
+ const coreGeo = new THREE.SphereGeometry(0.5, 32, 32);
541
+ const coreMat = new THREE.MeshBasicMaterial({ color: 0xffffff });
542
+ cometHead = new THREE.Mesh(coreGeo, coreMat);
543
+
544
+ const haloGeo = new THREE.SphereGeometry(0.9, 32, 32);
545
+ const haloMat = new THREE.MeshBasicMaterial({
546
+ color: 0x00ffff,
547
+ transparent: true,
548
+ opacity: 0.25,
549
+ blending: THREE.AdditiveBlending
550
+ });
551
+ const halo = new THREE.Mesh(haloGeo, haloMat);
552
+ cometHead.add(halo);
553
+ cometGroup.add(cometHead);
554
+
555
+ cometLight = new THREE.PointLight(0x00ffff, 2.5, 60);
556
+ cometGroup.add(cometLight);
557
+
558
+ const word = COMET_WORDS[Math.floor(Math.random() * COMET_WORDS.length)];
559
+ const tGeo = new TextGeometry(word, { font: font, size: 0.4, height: 0.02, bevelEnabled: false });
560
+ tGeo.center();
561
+ const tMat = new THREE.MeshBasicMaterial({ color: 0xccffff });
562
+ cometText = new THREE.Mesh(tGeo, tMat);
563
+ cometText.position.y = 1.3;
564
+ cometGroup.add(cometText);
565
+
566
+ scene.add(cometGroup);
567
+
568
+ const pGeo = new THREE.BufferGeometry();
569
+ const positions = new Float32Array(COMET_PARTICLE_COUNT * 3);
570
+ const colors = new Float32Array(COMET_PARTICLE_COUNT * 3);
571
+ const sizes = new Float32Array(COMET_PARTICLE_COUNT);
572
+
573
+ pGeo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
574
+ pGeo.setAttribute('color', new THREE.BufferAttribute(colors, 3));
575
+ pGeo.setAttribute('size', new THREE.BufferAttribute(sizes, 1));
576
+
577
+ const pMat = new THREE.PointsMaterial({
578
+ vertexColors: true,
579
+ size: 1.0,
580
+ transparent: true,
581
+ opacity: 0.9,
582
+ blending: THREE.AdditiveBlending,
583
+ depthWrite: false,
584
+ sizeAttenuation: true
585
+ });
586
+
587
+ cometParticlesMesh = new THREE.Points(pGeo, pMat);
588
+ scene.add(cometParticlesMesh);
589
+
590
+ cometParticlesData = [];
591
+ for(let i=0; i<COMET_PARTICLE_COUNT; i++) {
592
+ cometParticlesData.push({
593
+ life: -1,
594
+ velocity: new THREE.Vector3()
595
+ });
596
+ positions[i*3] = 99999;
597
+ }
598
+ }
599
+
600
+ function updateAdvancedComet(delta, time) {
601
+ if(!cometGroup || !cometParticlesMesh) return;
602
+
603
+ cometAngle += delta * 0.35;
604
+ const rX = 80; const rZ = 60;
605
+
606
+ const x = userCentroidForComet.x + Math.cos(cometAngle) * rX;
607
+ const z = userCentroidForComet.z + Math.sin(cometAngle) * rZ;
608
+ const y = userCentroidForComet.y + Math.sin(cometAngle * 2.0) * 20;
609
+
610
+ const targetPos = new THREE.Vector3(x, y, z);
611
+
612
+ const nextPos = new THREE.Vector3(
613
+ userCentroidForComet.x + Math.cos(cometAngle + 0.1) * rX,
614
+ userCentroidForComet.y + Math.sin((cometAngle + 0.1) * 2.0) * 20,
615
+ userCentroidForComet.z + Math.sin(cometAngle + 0.1) * rZ
616
+ );
617
+ cometGroup.position.copy(targetPos);
618
+ cometGroup.lookAt(nextPos);
619
+
620
+ if(cometText) cometText.quaternion.copy(camera.quaternion);
621
+
622
+ const positions = cometParticlesMesh.geometry.attributes.position.array;
623
+ const colors = cometParticlesMesh.geometry.attributes.color.array;
624
+ const sizes = cometParticlesMesh.geometry.attributes.size.array;
625
+
626
+ let spawnCount = 5;
627
+ for(let i=0; i<COMET_PARTICLE_COUNT; i++) {
628
+ if(spawnCount > 0 && cometParticlesData[i].life < 0) {
629
+ cometParticlesData[i].life = 1.0;
630
+
631
+ positions[i*3] = cometGroup.position.x + (Math.random()-0.5);
632
+ positions[i*3+1] = cometGroup.position.y + (Math.random()-0.5);
633
+ positions[i*3+2] = cometGroup.position.z + (Math.random()-0.5);
634
+
635
+ colors[i*3] = 0.2; colors[i*3+1] = 1.0; colors[i*3+2] = 1.0;
636
+ sizes[i] = 1.2;
637
+ spawnCount--;
638
+ }
639
+ }
640
+
641
+ for(let i=0; i<COMET_PARTICLE_COUNT; i++) {
642
+ if(cometParticlesData[i].life > 0) {
643
+ const d = cometParticlesData[i];
644
+ d.life -= delta * 0.7;
645
+
646
+ if(d.life > 0.6) {
647
+ colors[i*3] = 0.2; colors[i*3+1] = 1.0; colors[i*3+2] = 1.0;
648
+ } else if (d.life > 0.3) {
649
+ colors[i*3] = 0.6; colors[i*3+1] = 0.2; colors[i*3+2] = 1.0;
650
+ } else {
651
+ colors[i*3] = 0.1; colors[i*3+1] = 0.1; colors[i*3+2] = 0.5;
652
+ }
653
+
654
+ sizes[i] = d.life * 1.8;
655
+
656
+ } else {
657
+ positions[i*3] = 99999;
658
+ }
659
+ }
660
+
661
+ cometParticlesMesh.geometry.attributes.position.needsUpdate = true;
662
+ cometParticlesMesh.geometry.attributes.color.needsUpdate = true;
663
+ cometParticlesMesh.geometry.attributes.size.needsUpdate = true;
664
+ }
665
+
666
+ function animate() {
667
+ requestAnimationFrame(animate);
668
+
669
+ const delta = clock.getDelta();
670
+ const time = clock.getElapsedTime();
671
+
672
+ controls.update();
673
+
674
+ if(bgParticles) {
675
+ bgParticles.rotation.y = time * 0.015;
676
+ bgParticles.rotation.z = time * 0.005;
677
+ }
678
+
679
+ updateAdvancedComet(delta, time);
680
+ updateRaycaster();
681
+ drawMinimap();
682
+
683
+ hashtagGroup.children.forEach(obj => {
684
+ if (obj.userData.isText) {
685
+ obj.lookAt(camera.position);
686
+ const dist = obj.position.distanceTo(camera.position);
687
+ let scale = (1/dist) * 12;
688
+ scale = Math.max(0.6, Math.min(5.0, scale));
689
+ obj.scale.set(scale, scale, scale);
690
+ }
691
+ });
692
+
693
+ composer.render();
694
+ }
695
+
696
+ function getCurrentUserCentroid() {
697
+ if (!userId || !userMaps[userId] || userMaps[userId].length === 0) return new THREE.Vector3(0,0,0);
698
+ const centroid = new THREE.Vector3(0,0,0);
699
+ userMaps[userId].forEach(o => centroid.add(o));
700
+ centroid.divideScalar(userMaps[userId].length);
701
+ return centroid;
702
+ }
703
+
704
+ function visualizeHashtags(dataList, origin, level, parentColor = null) {
705
+ if (!dataList || dataList.length === 0) return;
706
+ for (const item of dataList) {
707
+ let currentTag, variantsList;
708
+ if (level === 1) {
709
+ currentTag = normalizeString(item.palabra_principal);
710
+ variantsList = item.variantes || [];
711
+ } else if (level === 2) {
712
+ currentTag = normalizeString(item.palabra_variante);
713
+ variantsList = item.sub_variantes || [];
714
+ } else {
715
+ currentTag = normalizeString(item);
716
+ variantsList = [];
717
+ }
718
+ if (!currentTag) continue;
719
+
720
+ const { color, h } = stringToHslColor(currentTag);
721
+ const nodeColor = (level === 1) ? color : parentColor;
722
+
723
+ const nodeMaterial = new THREE.MeshPhysicalMaterial({
724
+ color: new THREE.Color(nodeColor),
725
+ emissive: new THREE.Color(nodeColor),
726
+ emissiveIntensity: level === 1 ? 0.8 : 0.4,
727
+ roughness: 0.2,
728
+ metalness: 0.1,
729
+ transmission: 0.1,
730
+ transparent: true,
731
+ opacity: 0.95
732
+ });
733
+
734
+ const theta = (h / 360) * Math.PI * 2;
735
+ let phiHash = 0;
736
+ for (let i = 0; i < currentTag.length; i++) phiHash = (phiHash + currentTag.charCodeAt(i) * 13) % 180;
737
+ const phi = ((phiHash / 180) * 90 + 45) * (Math.PI / 180);
738
+
739
+ const baseRadius = 12 / (level * level);
740
+ const cx = baseRadius * Math.sin(phi) * Math.cos(theta);
741
+ const cy = baseRadius * Math.cos(phi);
742
+ const cz = baseRadius * Math.sin(phi) * Math.sin(theta);
743
+ const clusterCenter = new THREE.Vector3(cx, cy, cz).add(origin);
744
+
745
+ const lineMat = new THREE.LineBasicMaterial({
746
+ color: new THREE.Color(nodeColor),
747
+ transparent: true,
748
+ opacity: 0.25
749
+ });
750
+ const line = new THREE.Line(new THREE.BufferGeometry().setFromPoints([origin, clusterCenter]), lineMat);
751
+ hashtagGroup.add(line);
752
+
753
+ let sRad = (level === 1) ? 0.35 : (level === 2 ? 0.18 : 0.1);
754
+ const sphere = new THREE.Mesh(new THREE.SphereGeometry(sRad, 16, 16), nodeMaterial);
755
+ sphere.position.copy(clusterCenter);
756
+ sphere.userData.hashtag = currentTag;
757
+ sphere.userData.level = level;
758
+ hashtagGroup.add(sphere);
759
+
760
+ let tSize = (level === 1) ? 0.35 : (level === 2 ? 0.18 : 0.12);
761
+ const tGeo = new TextGeometry(currentTag.toUpperCase(), { font: font, size: tSize, height: 0.01, bevelEnabled: false });
762
+ tGeo.computeBoundingBox();
763
+ const tMesh = new THREE.Mesh(tGeo, new THREE.MeshBasicMaterial({ color: nodeColor }));
764
+ tMesh.position.copy(clusterCenter);
765
+ tMesh.position.y += sRad + 0.15;
766
+ tMesh.position.x -= (tGeo.boundingBox.max.x - tGeo.boundingBox.min.x)/2;
767
+ tMesh.userData.isText = true;
768
+ hashtagGroup.add(tMesh);
769
+
770
+ visualizeHashtags(variantsList, clusterCenter, level + 1, nodeColor);
771
+ }
772
+ }
773
+
774
+ function visualizeRoot(topic, origin) {
775
+ const { color } = stringToHslColor(topic);
776
+ const mat = new THREE.MeshStandardMaterial({
777
+ color: new THREE.Color(color),
778
+ emissive: new THREE.Color(color),
779
+ emissiveIntensity: 2.2,
780
+ roughness: 0.4
781
+ });
782
+ const sphere = new THREE.Mesh(new THREE.SphereGeometry(0.7, 32, 32), mat);
783
+ sphere.position.copy(origin);
784
+ sphere.userData.hashtag = topic;
785
+ sphere.userData.level = 0;
786
+ hashtagGroup.add(sphere);
787
+
788
+ const tGeo = new TextGeometry(topic.toUpperCase(), { font: font, size: 0.6, height: 0.05, bevelEnabled: false });
789
+ tGeo.computeBoundingBox();
790
+ const tMesh = new THREE.Mesh(tGeo, new THREE.MeshBasicMaterial({ color: 0xffffff }));
791
+ tMesh.position.copy(origin);
792
+ tMesh.position.y += 1.0;
793
+ tMesh.position.x -= (tGeo.boundingBox.max.x - tGeo.boundingBox.min.x) / 2;
794
+ tMesh.userData.isText = true;
795
+ hashtagGroup.add(tMesh);
796
+ }
797
+
798
+ function createUserSun(pos, name, isMe) {
799
+ const col = isMe ? 0xffaa00 : 0x00ff88;
800
+ const mat = new THREE.MeshStandardMaterial({
801
+ color: col, emissive: col, emissiveIntensity: 1.8, roughness: 0.2
802
+ });
803
+ const mesh = new THREE.Mesh(new THREE.SphereGeometry(3, 32, 32), mat);
804
+ mesh.position.copy(pos);
805
+ mesh.userData.hashtag = `Usuario: ${name}`;
806
+ mesh.userData.isPlaceholder = true;
807
+ hashtagGroup.add(mesh);
808
+
809
+ const tGeo = new TextGeometry(name.toUpperCase(), { font: font, size: 1.2, height: 0.1 });
810
+ tGeo.computeBoundingBox();
811
+ const tMesh = new THREE.Mesh(tGeo, new THREE.MeshBasicMaterial({ color: 0xffffff }));
812
+ tMesh.position.copy(pos);
813
+ tMesh.position.y += 4;
814
+ tMesh.position.x -= (tGeo.boundingBox.max.x - tGeo.boundingBox.min.x)/2;
815
+ tMesh.userData.isText = true;
816
+ hashtagGroup.add(tMesh);
817
+ }
818
+
819
+ async function fetchWithTimeout(url, options = {}, timeoutMs = 25000) {
820
+ const controller = new AbortController();
821
+ const id = setTimeout(() => controller.abort(), timeoutMs);
822
+ try {
823
+ return await fetch(url, { ...options, signal: controller.signal });
824
+ } finally { clearTimeout(id); }
825
+ }
826
+
827
+ async function fetchWithBackoff(url, options, retries = 2, delay = 1000) {
828
+ try {
829
+ return await fetchWithTimeout(url, options);
830
+ } catch (err) {
831
+ if (retries > 0) {
832
+ await new Promise(r => setTimeout(r, delay));
833
+ return fetchWithBackoff(url, options, retries - 1, delay * 2);
834
+ } else { throw err; }
835
+ }
836
+ }
837
+
838
+ async function callGemini(topic, mc, vc, svc) {
839
+ const k = getLocalGeminiKey();
840
+ if(!k || k.length<10) throw new Error("Falta API Key");
841
+
842
+ const schema = {
843
+ type: "OBJECT",
844
+ properties: {
845
+ analisis: { type: "STRING" },
846
+ 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"}}}}} } } }
847
+ }
848
+ };
849
+ 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.`;
850
+
851
+ const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-preview-09-2025:generateContent?key=${k}`;
852
+
853
+ if (!isHFStatic) {
854
+ try {
855
+ const proxyResp = await fetchWithBackoff('/.netlify/functions/gemini-proxy', {
856
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
857
+ body: JSON.stringify({ model: 'gemini-2.5-flash-preview-09-2025', payload: { contents: [{parts:[{text: prompt}]}] } })
858
+ }, 1, 1000);
859
+ if (proxyResp.ok) return await proxyResp.json();
860
+ } catch (e) {}
861
+ }
862
+
863
+ const resp = await fetchWithBackoff(url, {
864
+ method: 'POST', headers: {'Content-Type': 'application/json'},
865
+ body: JSON.stringify({ contents: [{parts:[{text: prompt}]}], generationConfig: { responseMimeType: "application/json", responseSchema: schema } })
866
+ });
867
+ if(!resp.ok) throw new Error(await resp.text());
868
+ return await resp.json();
869
+ }
870
+
871
+ async function handleAnalysisAndVisualization() {
872
+ if(!font) return;
873
+ const topic = normalizeString(document.getElementById('topicInput').value);
874
+ if(!topic) return;
875
+
876
+ const btn = document.getElementById('visualizeButton');
877
+ const pb = document.getElementById('progressBar');
878
+ const pbc = document.getElementById('progressBarContainer');
879
+ const originalBtnHtml = btn.innerHTML;
880
+
881
+ btn.disabled = true; btn.innerHTML = '<span class="animate-pulse">PROCESANDO RED...</span>';
882
+ pbc.classList.remove('hidden'); pb.style.width = "90%"; pb.style.transition = "width 15s ease-out";
883
+
884
+ try {
885
+ const mc = document.getElementById('level1Slider').value;
886
+ const vc = document.getElementById('level2Slider').value;
887
+ const svc = document.getElementById('level3Slider').value;
888
+
889
+ let origin = new THREE.Vector3(0,0,0);
890
+ const myMaps = userMaps[userId] || [];
891
+ if(myMaps.length === 0) {
892
+ const globalIdx = Object.keys(userMaps).length;
893
+ origin.set(globalIdx*800*Math.cos(globalIdx), 0, globalIdx*800*Math.sin(globalIdx));
894
+ } else {
895
+ let center = new THREE.Vector3(); myMaps.forEach(m=>center.add(m)); center.divideScalar(myMaps.length);
896
+ const r = Math.sqrt(myMaps.length)*40; const a = myMaps.length;
897
+ origin.set(center.x + r*Math.cos(a), 0, center.z + r*Math.sin(a));
898
+ }
899
+
900
+ const res = await callGemini(topic, mc, vc, svc);
901
+ const txt = res.candidates?.[0]?.content?.parts?.[0]?.text;
902
+ if(!txt) throw new Error("La IA no generó datos válidos.");
903
+ const json = JSON.parse(txt);
904
+
905
+ visualizeRoot(topic, origin);
906
+ visualizeHashtags(json.lista_palabras, origin, 1);
907
+ if(db && userId) await addDoc(collection(db,'artifacts',appId,'public','data','maps'), {
908
+ topic, depth: "3", origin: {x:origin.x, y:origin.y, z:origin.z}, data: JSON.stringify(json), createdAt: new Date(), userId
909
+ });
910
+
911
+ } catch(e) {
912
+ console.error(e);
913
+ alert("Error en análisis: " + e.message);
914
+ } finally {
915
+ btn.disabled = false; btn.innerHTML = originalBtnHtml;
916
+ pb.style.width = "100%"; setTimeout(()=>{pbc.classList.add('hidden'); pb.style.width="0%";}, 500);
917
+ }
918
+ }
919
+
920
+ function loadAllMaps() {
921
+ if(!db || !font) { setTimeout(loadAllMaps,500); return; }
922
+ const q = query(collection(db,'artifacts',appId,'public','data','maps'));
923
+
924
+ onSnapshot(q, async (snap) => {
925
+ while(hashtagGroup.children.length > 0){
926
+ let obj = hashtagGroup.children[0];
927
+ hashtagGroup.remove(obj);
928
+ if(obj.geometry) obj.geometry.dispose();
929
+ if(obj.material) {
930
+ if(Array.isArray(obj.material)) obj.material.forEach(m=>m.dispose());
931
+ else obj.material.dispose();
932
+ }
933
+ }
934
+
935
+ userMaps = {}; allMapsDataCache = {};
936
+
937
+ snap.docs.forEach(d => {
938
+ const m = d.data();
939
+ if(!m.origin) return;
940
+ const o = new THREE.Vector3(m.origin.x, m.origin.y, m.origin.z);
941
+ if(!userMaps[m.userId]) userMaps[m.userId] = [];
942
+ userMaps[m.userId].push(o);
943
+
944
+ try {
945
+ const data = JSON.parse(m.data);
946
+ visualizeRoot(m.topic, o);
947
+ visualizeHashtags(data.lista_palabras, o, 1);
948
+ } catch {}
949
+ });
950
+
951
+ const uids = Object.keys(userMaps);
952
+ const list = document.getElementById('userList');
953
+ if(list) list.innerHTML = '';
954
+
955
+ for(const uid of uids) {
956
+ const prof = await getProfile(uid);
957
+ const name = prof ? prof.username : "ANON " + uid.substring(0,4);
958
+
959
+ if(list) {
960
+ const item = document.createElement('div');
961
+ item.className = 'text-cyan-400 hover:text-white cursor-pointer hover:bg-white/5 p-1 rounded transition-colors text-[10px] tracking-wide';
962
+ item.innerHTML = `> <span class="font-bold">${name}</span>`;
963
+ item.onclick = () => teleportToUser(uid);
964
+ list.appendChild(item);
965
+ }
966
+
967
+ const origins = userMaps[uid];
968
+ if(origins.length > 0) {
969
+ const c = new THREE.Vector3(); origins.forEach(p=>c.add(p)); c.divideScalar(origins.length);
970
+ createUserSun(c, name, uid === userId);
971
+ }
972
+ }
973
+
974
+ initAdvancedComet();
975
+ drawMinimap();
976
+ focusOnUserMaps();
977
+ });
978
+ }
979
+
980
+ function focusOnUserMaps() {
981
+ if(!controls || !userId || !userMaps[userId]) return;
982
+ const c = getCurrentUserCentroid();
983
+ controls.target.copy(c);
984
+ camera.position.copy(c).add(new THREE.Vector3(0,10,40));
985
+ }
986
+
987
+ function teleportToUser(uid) {
988
+ if(!userMaps[uid]) return;
989
+ const origins = userMaps[uid];
990
+ const c = new THREE.Vector3(); origins.forEach(p=>c.add(p)); c.divideScalar(origins.length);
991
+ controls.target.copy(c);
992
+ camera.position.copy(c).add(new THREE.Vector3(0,10,40));
993
+ }
994
+
995
+ function drawMinimap() {
996
+ if(!minimapCtx) return;
997
+ const w = minimapCtx.canvas.width; const h = minimapCtx.canvas.height;
998
+ minimapCtx.clearRect(0,0,w,h);
999
+ minimapDotCoords = [];
1000
+
1001
+ const myC = getCurrentUserCentroid();
1002
+
1003
+ Object.keys(userMaps).forEach(uid => {
1004
+ const origins = userMaps[uid];
1005
+ const c = new THREE.Vector3(); origins.forEach(p=>c.add(p)); c.divideScalar(origins.length);
1006
+
1007
+ const x = w/2 + (c.x - myC.x)*minimapScale;
1008
+ const y = h/2 + (c.z - myC.z)*minimapScale;
1009
+
1010
+ const isMe = (uid === userId);
1011
+ const mainColor = isMe ? '#22d3ee' : '#64748b';
1012
+
1013
+ minimapDotCoords.push({x, y, uid});
1014
+
1015
+ if(origins.length > 0) {
1016
+ const satColor = isMe ? 'rgba(34, 211, 238, 0.4)' : 'rgba(100, 116, 139, 0.4)';
1017
+ origins.forEach(o => {
1018
+ const sx = w/2 + (o.x - myC.x)*minimapScale;
1019
+ const sy = h/2 + (o.z - myC.z)*minimapScale;
1020
+ minimapCtx.beginPath();
1021
+ minimapCtx.arc(sx, sy, 1, 0, Math.PI*2);
1022
+ minimapCtx.fillStyle = satColor;
1023
+ minimapCtx.fill();
1024
+ });
1025
+ }
1026
+
1027
+ minimapCtx.fillStyle = mainColor;
1028
+ minimapCtx.beginPath();
1029
+ minimapCtx.arc(x,y, isMe?4:2.5, 0, Math.PI*2);
1030
+ minimapCtx.fill();
1031
+
1032
+ if(isMe) {
1033
+ minimapCtx.strokeStyle = 'rgba(34, 211, 238, 0.3)';
1034
+ minimapCtx.beginPath(); minimapCtx.arc(x,y, 8, 0, Math.PI*2); minimapCtx.stroke();
1035
+ }
1036
+ });
1037
+
1038
+ if (cometGroup) {
1039
+ const cometX = w/2 + (cometGroup.position.x - myC.x) * minimapScale;
1040
+ const cometY = h/2 + (cometGroup.position.z - myC.z) * minimapScale;
1041
+
1042
+ minimapCtx.beginPath();
1043
+ minimapCtx.arc(cometX, cometY, 3, 0, Math.PI * 2);
1044
+ minimapCtx.fillStyle = 'rgba(0, 255, 255, 0.4)';
1045
+ minimapCtx.fill();
1046
+
1047
+ minimapCtx.beginPath();
1048
+ minimapCtx.arc(cometX, cometY, 1.5, 0, Math.PI * 2);
1049
+ minimapCtx.fillStyle = '#ffffff';
1050
+ minimapCtx.fill();
1051
+ }
1052
+ }
1053
+
1054
+ function onMinimapClick(e) {
1055
+ if(!minimapCtx) return;
1056
+ const rect = minimapCtx.canvas.getBoundingClientRect();
1057
+ const x = e.clientX - rect.left;
1058
+ const y = e.clientY - rect.top;
1059
+
1060
+ let closest = null;
1061
+ let minD = 20;
1062
+
1063
+ minimapDotCoords.forEach(dot => {
1064
+ const d = Math.sqrt((x-dot.x)**2 + (y-dot.y)**2);
1065
+ if(d < minD) { minD = d; closest = dot.uid; }
1066
+ });
1067
+
1068
+ if(closest) teleportToUser(closest);
1069
+ }
1070
+
1071
+ function onWindowResize() {
1072
+ camera.aspect = window.innerWidth/window.innerHeight;
1073
+ camera.updateProjectionMatrix();
1074
+ renderer.setSize(window.innerWidth, window.innerHeight);
1075
+ composer.setSize(window.innerWidth, window.innerHeight);
1076
+ }
1077
+
1078
+ function onPointerMove(e) {
1079
+ mouse.x = (e.clientX/window.innerWidth)*2-1;
1080
+ mouse.y = -(e.clientY/window.innerHeight)*2+1;
1081
+ tooltip.style.left = e.clientX+20+'px';
1082
+ tooltip.style.top = e.clientY+'px';
1083
+ }
1084
+
1085
+ function updateRaycaster() {
1086
+ raycaster.setFromCamera(mouse, camera);
1087
+ let targets = [...hashtagGroup.children];
1088
+ if(cometHead) targets.push(cometHead);
1089
+
1090
+ const intersects = raycaster.intersectObjects(targets, false);
1091
+
1092
+ if(intersects.length > 0) {
1093
+ let o = intersects[0].object;
1094
+ if(o.parent === cometHead || o.parent === cometGroup) o = cometHead;
1095
+
1096
+ if(o === cometHead) {
1097
+ tooltip.classList.remove('hidden');
1098
+ const word = cometText ? cometText.geometry.parameters.text : "ENLACE";
1099
+ tooltip.innerHTML = `
1100
+ <div class="border-b border-cyan-500/50 pb-1 mb-1 text-cyan-300 font-bold tracking-widest text-xs">COMETA NEURAL</div>
1101
+ <div class="text-white text-[10px]">SEÑAL: ${word}</div>
1102
+ `;
1103
+ document.body.style.cursor = 'pointer';
1104
+ return;
1105
+ }
1106
+
1107
+ const d = o.userData;
1108
+ if(d.hashtag) {
1109
+ tooltip.classList.remove('hidden');
1110
+ let typeColor = d.isPlaceholder ? "text-yellow-400" : "text-cyan-300";
1111
+ let typeText = d.isPlaceholder ? "NODO USUARIO" : `NIVEL ${d.level}`;
1112
+
1113
+ tooltip.innerHTML = `
1114
+ <div class="${typeColor} font-bold tracking-widest text-sm mb-1">${d.hashtag}</div>
1115
+ <div class="text-gray-400 text-[10px] uppercase">${typeText}</div>
1116
+ `;
1117
+ document.body.style.cursor = 'pointer';
1118
+ }
1119
+ } else {
1120
+ tooltip.classList.add('hidden');
1121
+ document.body.style.cursor = 'default';
1122
+ }
1123
+ }
1124
+
1125
+ function stringToHslColor(str) {
1126
+ let hash = 0; for (let i = 0; i < str.length; i++) hash = str.charCodeAt(i) + ((hash << 5) - hash);
1127
+ return { color: `hsl(${Math.abs(hash % 360)}, 75%, 60%)`, h: Math.abs(hash % 360) };
1128
+ }
1129
+ </script>
1130
  </body>
1131
  </html>