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