Spaces:
Paused
Paused
Upload 4 files
#1
by salomonsky - opened
- README.md +64 -7
- index.html +1475 -0
- netlify.toml +9 -0
- styles.css +13 -0
README.md
CHANGED
|
@@ -1,7 +1,64 @@
|
|
| 1 |
-
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ECOTAGS 3D - Deploy en Hugging Face Spaces (Static)
|
| 2 |
+
|
| 3 |
+
Este proyecto es una app web 100% cliente (Three.js + Firebase + Gemini) que puede correr en un Space estático de Hugging Face.
|
| 4 |
+
|
| 5 |
+
## Estructura
|
| 6 |
+
- `index.html`: Aplicación principal.
|
| 7 |
+
- `styles.css`: Estilos opcionales.
|
| 8 |
+
- `scripts/`: Scripts auxiliares (si los usas).
|
| 9 |
+
- `netlify/` y `netlify.toml`: Configuración de Netlify (opcional; en Hugging Face Static no se usan).
|
| 10 |
+
|
| 11 |
+
## Importante sobre la API de Gemini
|
| 12 |
+
En un Space estático NO existe backend. Por lo tanto, la app hace llamadas directas a la API de Gemini con una clave guardada localmente en el navegador.
|
| 13 |
+
|
| 14 |
+
- Abre la sección "Configuración" dentro de la app (`index.html`).
|
| 15 |
+
- Pega tu clave de Gemini en "Gemini API Key (guardada localmente)" y pulsa "Guardar clave".
|
| 16 |
+
- La clave se almacena en `localStorage` del navegador y no se sube al servidor.
|
| 17 |
+
|
| 18 |
+
Si necesitas ocultar la clave del cliente, migra a un Space con backend (por ejemplo Gradio/Streamlit en Python) y usa los Secrets del Space para acceder a la API desde el servidor.
|
| 19 |
+
|
| 20 |
+
## Firebase: dominios autorizados
|
| 21 |
+
Si usas Firebase Auth/Firestore, añade los dominios del Space a la lista de dominios autorizados en la consola de Firebase:
|
| 22 |
+
|
| 23 |
+
- `https://<tu-usuario>-<tu-space>.hf.space`
|
| 24 |
+
- En previews/PRs, también pueden generarse subdominios temporales `https://<algo>.hf.space`.
|
| 25 |
+
|
| 26 |
+
En Firebase Console:
|
| 27 |
+
- Auth > Settings > Authorized domains: agrega los dominios del Space.
|
| 28 |
+
- Firestore Rules: asegúrate de tener las reglas que necesitas para lectura/escritura.
|
| 29 |
+
|
| 30 |
+
## Crear el Space
|
| 31 |
+
1. Ve a https://huggingface.co/spaces y crea un nuevo Space.
|
| 32 |
+
2. Tipo de Space: "Static".
|
| 33 |
+
3. Conecta tu repositorio o sube los archivos del proyecto (`index.html`, `styles.css`, etc.).
|
| 34 |
+
4. Configuración del Space (Static):
|
| 35 |
+
- Build command: vacío (no hay build si no usas bundler)
|
| 36 |
+
- Build output directory: `/` (raíz) o la carpeta donde esté tu `index.html`
|
| 37 |
+
5. Guarda y espera a que el Space se construya.
|
| 38 |
+
|
| 39 |
+
La app debería quedar accesible en `https://<tu-usuario>-<tu-space>.hf.space/`.
|
| 40 |
+
|
| 41 |
+
## Notas sobre el antiguo proxy de Netlify
|
| 42 |
+
Este proyecto incluye un proxy de Netlify en `netlify/functions/gemini-proxy.js` para ocultar la clave de Gemini cuando se despliega en Netlify. En Hugging Face (Static) ese endpoint no existe, por lo que la app intentará primero el proxy y, si falla, usará automáticamente la clave local.
|
| 43 |
+
|
| 44 |
+
- Puedes dejar esos archivos sin problema.
|
| 45 |
+
- Si deseas limpiar el repositorio para Hugging Face, elimina `netlify/` y `netlify.toml` (opcional).
|
| 46 |
+
|
| 47 |
+
## Migrar a Space con backend (opcional)
|
| 48 |
+
Si quieres ocultar la clave y hacer llamadas a Gemini desde el servidor:
|
| 49 |
+
- Crea un Space nuevo tipo Gradio o Streamlit (Python) o Node.
|
| 50 |
+
- Mueve la lógica del proxy (actualmente en `netlify/functions/gemini-proxy.js`) a una ruta del backend.
|
| 51 |
+
- Configura `GEMINI_API_KEY` como Secret del Space.
|
| 52 |
+
- En `index.html`, elimina el fallback local o invierte el orden para forzar el backend.
|
| 53 |
+
|
| 54 |
+
## Desarrollo local
|
| 55 |
+
Puedes abrir `index.html` en un servidor local sencillo (para evitar problemas de CORS y rutas):
|
| 56 |
+
|
| 57 |
+
- Con Python 3: `python -m http.server 8000`
|
| 58 |
+
- Con Node (serve): `npx serve .`
|
| 59 |
+
|
| 60 |
+
Luego visita `http://localhost:8000`.
|
| 61 |
+
|
| 62 |
+
Asegúrate de:
|
| 63 |
+
- Configurar la API key de Gemini localmente en la app.
|
| 64 |
+
- Tener los dominios locales autorizados en Firebase (`localhost`, `127.0.0.1`).
|
index.html
ADDED
|
@@ -0,0 +1,1475 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="es">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
| 6 |
+
<title>Analizador de Temas 3D con Gemini</title>
|
| 7 |
+
<script src="https://cdn.tailwindcss.com"></script>
|
| 8 |
+
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@700&display=swap" rel="stylesheet">
|
| 9 |
+
<style>
|
| 10 |
+
body {
|
| 11 |
+
font-family: 'Orbitron', sans-serif;
|
| 12 |
+
margin: 0;
|
| 13 |
+
overflow: hidden;
|
| 14 |
+
}
|
| 15 |
+
#container {
|
| 16 |
+
width: 100vw;
|
| 17 |
+
height: 100vh;
|
| 18 |
+
position: fixed;
|
| 19 |
+
top: 0;
|
| 20 |
+
left: 0;
|
| 21 |
+
background-color: #111827;
|
| 22 |
+
}
|
| 23 |
+
canvas {
|
| 24 |
+
display: block;
|
| 25 |
+
}
|
| 26 |
+
#ui {
|
| 27 |
+
position: fixed;
|
| 28 |
+
top: 20px;
|
| 29 |
+
left: 20px;
|
| 30 |
+
z-index: 100;
|
| 31 |
+
background-color: rgba(31, 41, 55, 0.9);
|
| 32 |
+
padding: 20px;
|
| 33 |
+
border-radius: 12px;
|
| 34 |
+
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.4);
|
| 35 |
+
max-width: 400px;
|
| 36 |
+
color: white;
|
| 37 |
+
height: calc(100vh - 40px);
|
| 38 |
+
display: flex;
|
| 39 |
+
flex-direction: column;
|
| 40 |
+
}
|
| 41 |
+
#tooltip {
|
| 42 |
+
position: absolute;
|
| 43 |
+
display: none;
|
| 44 |
+
background: rgba(0, 0, 0, 0.8);
|
| 45 |
+
color: white;
|
| 46 |
+
padding: 8px 12px;
|
| 47 |
+
border-radius: 6px;
|
| 48 |
+
z-index: 101;
|
| 49 |
+
pointer-events: none;
|
| 50 |
+
font-size: 14px;
|
| 51 |
+
}
|
| 52 |
+
input[type="range"] {
|
| 53 |
+
-webkit-appearance: none;
|
| 54 |
+
appearance: none;
|
| 55 |
+
width: 100%;
|
| 56 |
+
height: 8px;
|
| 57 |
+
background: #4b5563;
|
| 58 |
+
border-radius: 5px;
|
| 59 |
+
outline: none;
|
| 60 |
+
opacity: 0.7;
|
| 61 |
+
transition: opacity .2s;
|
| 62 |
+
}
|
| 63 |
+
input[type="range"]:hover {
|
| 64 |
+
opacity: 1;
|
| 65 |
+
}
|
| 66 |
+
input[type="range"]::-webkit-slider-thumb {
|
| 67 |
+
-webkit-appearance: none;
|
| 68 |
+
appearance: none;
|
| 69 |
+
width: 20px;
|
| 70 |
+
height: 20px;
|
| 71 |
+
background: #3b82f6;
|
| 72 |
+
border-radius: 50%;
|
| 73 |
+
cursor: pointer;
|
| 74 |
+
}
|
| 75 |
+
input[type="range"]::-moz-range-thumb {
|
| 76 |
+
width: 20px;
|
| 77 |
+
height: 20px;
|
| 78 |
+
background: #3b82f6;
|
| 79 |
+
border-radius: 50%;
|
| 80 |
+
cursor: pointer;
|
| 81 |
+
}
|
| 82 |
+
#progressBarContainer {
|
| 83 |
+
width: 100%;
|
| 84 |
+
background-color: #374151;
|
| 85 |
+
border-radius: 4px;
|
| 86 |
+
overflow: hidden;
|
| 87 |
+
display: none;
|
| 88 |
+
height: 6px;
|
| 89 |
+
margin-top: 12px;
|
| 90 |
+
}
|
| 91 |
+
#progressBar {
|
| 92 |
+
width: 0%;
|
| 93 |
+
height: 6px;
|
| 94 |
+
background-color: #3b82f6;
|
| 95 |
+
}
|
| 96 |
+
#loginOverlay {
|
| 97 |
+
transition: opacity 0.5s ease-in-out;
|
| 98 |
+
display: flex;
|
| 99 |
+
opacity: 0;
|
| 100 |
+
}
|
| 101 |
+
#toast {
|
| 102 |
+
position: fixed;
|
| 103 |
+
top: 20px;
|
| 104 |
+
left: 50%;
|
| 105 |
+
background-color: #22c55e;
|
| 106 |
+
color: white;
|
| 107 |
+
padding: 16px;
|
| 108 |
+
border-radius: 8px;
|
| 109 |
+
box-shadow: 0 4px 10px rgba(0,0,0,.3);
|
| 110 |
+
z-index: 200;
|
| 111 |
+
transform: translateY(-120%) translateX(-50%);
|
| 112 |
+
transition: transform 0.5s ease-in-out;
|
| 113 |
+
font-weight: bold;
|
| 114 |
+
}
|
| 115 |
+
#toast.show {
|
| 116 |
+
transform: translateY(0) translateX(-50%);
|
| 117 |
+
}
|
| 118 |
+
#minimapContainer {
|
| 119 |
+
width: 100%;
|
| 120 |
+
height: 250px;
|
| 121 |
+
margin-top: 16px;
|
| 122 |
+
flex-shrink: 0;
|
| 123 |
+
}
|
| 124 |
+
#minimap {
|
| 125 |
+
width: 100%;
|
| 126 |
+
height: 100%;
|
| 127 |
+
background-color: #1f2937;
|
| 128 |
+
border-radius: 8px;
|
| 129 |
+
border: 1px solid #4b5563;
|
| 130 |
+
cursor: pointer;
|
| 131 |
+
}
|
| 132 |
+
/* main auth buttons inside the main UI header */
|
| 133 |
+
#mainAuthButtons {
|
| 134 |
+
display: flex;
|
| 135 |
+
gap: 8px;
|
| 136 |
+
align-items: center;
|
| 137 |
+
}
|
| 138 |
+
</style>
|
| 139 |
+
</head>
|
| 140 |
+
|
| 141 |
+
<body>
|
| 142 |
+
<!-- Login modal / overlay -->
|
| 143 |
+
<div id="loginOverlay" class="fixed inset-0 bg-gray-900 bg-opacity-75 backdrop-blur-sm z-[200] items-center justify-center transition-opacity duration-500">
|
| 144 |
+
<div id="loginModal" class="bg-gray-800 p-8 rounded-lg shadow-xl w-full max-w-sm">
|
| 145 |
+
<div id="loginForm">
|
| 146 |
+
<h2 class="text-2xl font-bold text-green-400 mb-4">Iniciar Sesión</h2>
|
| 147 |
+
<p class="text-gray-300 mb-6" id="loginMessage">Por favor, inicia sesión o regístrate.</p>
|
| 148 |
+
|
| 149 |
+
<input type="email" id="loginEmail" class="w-full p-3 rounded-lg bg-gray-700 text-white border border-gray-600 focus:outline-none focus:border-blue-500" placeholder="Correo electrónico" autocomplete="email">
|
| 150 |
+
<input type="password" id="loginPassword" class="w-full p-3 mt-3 rounded-lg bg-gray-700 text-white border border-gray-600 focus:outline-none focus:border-blue-500" placeholder="Contraseña" autocomplete="current-password">
|
| 151 |
+
|
| 152 |
+
<div class="flex justify-between items-center mt-4 text-sm">
|
| 153 |
+
<label class="flex items-center text-gray-400">
|
| 154 |
+
<input type="checkbox" id="showPasswordCheck" class="mr-2 rounded bg-gray-700 border-gray-600 text-blue-500 focus:ring-blue-500">
|
| 155 |
+
Mostrar contraseña
|
| 156 |
+
</label>
|
| 157 |
+
<label class="flex items-center text-gray-400">
|
| 158 |
+
<input type="checkbox" id="rememberMeCheck" class="mr-2 rounded bg-gray-700 border-gray-600 text-blue-500 focus:ring-blue-500" checked>
|
| 159 |
+
Recordarme
|
| 160 |
+
</label>
|
| 161 |
+
</div>
|
| 162 |
+
|
| 163 |
+
<div class="flex flex-col space-y-2 mt-6">
|
| 164 |
+
<button id="loginButton" class="w-full bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 px-4 rounded-lg transition duration-300">Entrar</button>
|
| 165 |
+
<button id="registerButton" class="w-full bg-gray-600 hover:bg-gray-700 text-white font-bold py-3 px-4 rounded-lg transition duration-300">Registrar</button>
|
| 166 |
+
<button id="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>
|
| 167 |
+
<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>
|
| 168 |
+
</div>
|
| 169 |
+
</div>
|
| 170 |
+
|
| 171 |
+
<div id="usernameForm" style="display: none;">
|
| 172 |
+
<h2 class="text-2xl font-bold text-green-400 mb-4">¡Bienvenido!</h2>
|
| 173 |
+
<p class="text-gray-300 mb-6" id="usernameMessage">Elige un nombre de usuario público para continuar.</p>
|
| 174 |
+
<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">
|
| 175 |
+
<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>
|
| 176 |
+
</div>
|
| 177 |
+
</div>
|
| 178 |
+
</div>
|
| 179 |
+
|
| 180 |
+
<div id="container"></div>
|
| 181 |
+
<div id="tooltip" class="z-[101]"></div>
|
| 182 |
+
|
| 183 |
+
<div id="ui">
|
| 184 |
+
<div class="flex items-center justify-between mb-4">
|
| 185 |
+
<h1 class="text-2xl font-bold text-white flex items-center space-x-2">
|
| 186 |
+
<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">
|
| 187 |
+
<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" />
|
| 188 |
+
</svg>
|
| 189 |
+
<span class="text-green-400">ECOTAGS</span>
|
| 190 |
+
</h1>
|
| 191 |
+
|
| 192 |
+
<div id="mainAuthButtons" class="flex items-center">
|
| 193 |
+
<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>
|
| 194 |
+
<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>
|
| 195 |
+
</div>
|
| 196 |
+
</div>
|
| 197 |
+
|
| 198 |
+
<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">
|
| 199 |
+
|
| 200 |
+
<label for="level1Slider" class="text-sm text-gray-400">Nivel 1 (1-15): <span id="level1Value">10</span></label>
|
| 201 |
+
<input type="range" id="level1Slider" min="1" max="15" value="10" class="w-full mb-2">
|
| 202 |
+
|
| 203 |
+
<label for="level2Slider" class="text-sm text-gray-400">Nivel 2 (5-8): <span id="level2Value">5</span></label>
|
| 204 |
+
<input type="range" id="level2Slider" min="5" max="8" value="5" class="w-full mb-2">
|
| 205 |
+
|
| 206 |
+
<label for="level3Slider" class="text-sm text-gray-400">Nivel 3 (1-3): <span id="level3Value">3</span></label>
|
| 207 |
+
<input type="range" id="level3Slider" min="1" max="3" value="3" class="w-full mb-4">
|
| 208 |
+
|
| 209 |
+
<details class="mb-3 bg-gray-700/50 rounded-md p-3 border border-gray-600">
|
| 210 |
+
<summary class="cursor-pointer text-sm text-blue-300 font-semibold">Configuración</summary>
|
| 211 |
+
<div class="mt-3 space-y-2 text-sm">
|
| 212 |
+
<div>
|
| 213 |
+
<label for="geminiKeyInput" class="block text-gray-300">Gemini API Key (guardada localmente)</label>
|
| 214 |
+
<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...">
|
| 215 |
+
</div>
|
| 216 |
+
<div class="flex items-center gap-2">
|
| 217 |
+
<button id="saveGeminiKeyBtn" class="bg-gray-600 hover:bg-gray-500 px-3 py-1 rounded">Guardar clave</button>
|
| 218 |
+
<span id="geminiKeyStatus" class="text-gray-400"></span>
|
| 219 |
+
</div>
|
| 220 |
+
<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>
|
| 221 |
+
</div>
|
| 222 |
+
</details>
|
| 223 |
+
|
| 224 |
+
<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">
|
| 225 |
+
<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">
|
| 226 |
+
<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" />
|
| 227 |
+
</svg>
|
| 228 |
+
<span>Sembrar</span>
|
| 229 |
+
</button>
|
| 230 |
+
|
| 231 |
+
<div id="progressBarContainer"><div id="progressBar"></div></div>
|
| 232 |
+
|
| 233 |
+
<div id="userListContainer" class="mt-4 flex flex-col" style="max-height:25vh;">
|
| 234 |
+
<h2 class="font-bold text-lg mb-2 text-blue-300">Galaxias de Usuarios</h2>
|
| 235 |
+
<div id="userList" class="w-full overflow-y-auto text-gray-300 pr-2"></div>
|
| 236 |
+
</div>
|
| 237 |
+
|
| 238 |
+
<div id="minimapContainer">
|
| 239 |
+
<div class="flex justify-between items-center mb-2">
|
| 240 |
+
<h2 class="font-bold text-lg text-blue-300">Minimapa Galáctico</h2>
|
| 241 |
+
<div class="flex space-x-1">
|
| 242 |
+
<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>
|
| 243 |
+
<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>
|
| 244 |
+
</div>
|
| 245 |
+
</div>
|
| 246 |
+
<canvas id="minimap" width="360" height="200"></canvas>
|
| 247 |
+
</div>
|
| 248 |
+
</div>
|
| 249 |
+
|
| 250 |
+
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/build/three.min.js"></script>
|
| 251 |
+
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"></script>
|
| 252 |
+
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/geometries/TextGeometry.js"></script>
|
| 253 |
+
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/loaders/FontLoader.js"></script>
|
| 254 |
+
|
| 255 |
+
<script type="module">
|
| 256 |
+
import { initializeApp } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-app.js";
|
| 257 |
+
import {
|
| 258 |
+
getAuth,
|
| 259 |
+
onAuthStateChanged,
|
| 260 |
+
createUserWithEmailAndPassword,
|
| 261 |
+
signInWithEmailAndPassword,
|
| 262 |
+
setPersistence,
|
| 263 |
+
browserLocalPersistence,
|
| 264 |
+
browserSessionPersistence,
|
| 265 |
+
signInAnonymously,
|
| 266 |
+
GoogleAuthProvider,
|
| 267 |
+
signInWithPopup,
|
| 268 |
+
signOut
|
| 269 |
+
} from "https://www.gstatic.com/firebasejs/11.6.1/firebase-auth.js";
|
| 270 |
+
import {
|
| 271 |
+
getFirestore,
|
| 272 |
+
doc,
|
| 273 |
+
addDoc,
|
| 274 |
+
onSnapshot,
|
| 275 |
+
collection,
|
| 276 |
+
setDoc,
|
| 277 |
+
getDoc,
|
| 278 |
+
query,
|
| 279 |
+
setLogLevel
|
| 280 |
+
} from "https://www.gstatic.com/firebasejs/11.6.1/firebase-firestore.js";
|
| 281 |
+
|
| 282 |
+
// --- FIREBASE CONFIG (usar la API key del proyecto Firebase) ---
|
| 283 |
+
const firebaseConfig = {
|
| 284 |
+
apiKey: "AIzaSyB4EZKkAH3weFSZNBQA8Y63gt5XbaeZGsQ",
|
| 285 |
+
authDomain: "neuronal-1f3b9.firebaseapp.com",
|
| 286 |
+
projectId: "neuronal-1f3b9",
|
| 287 |
+
storageBucket: "neuronal-1f3b9.firebasestorage.app",
|
| 288 |
+
messagingSenderId: "208887839866",
|
| 289 |
+
appId: "1:208887839866:web:adbb697dd0b63195b10fc3",
|
| 290 |
+
measurementId: "G-102SEBLQFJ"
|
| 291 |
+
};
|
| 292 |
+
|
| 293 |
+
// --- Gemini API Key helpers (local fallback) ---
|
| 294 |
+
function getLocalGeminiKey() {
|
| 295 |
+
try { return localStorage.getItem('GEMINI_API_KEY') || ''; } catch { return ''; }
|
| 296 |
+
}
|
| 297 |
+
function setLocalGeminiKey(k) {
|
| 298 |
+
try { localStorage.setItem('GEMINI_API_KEY', k || ''); } catch {}
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
// Three.js aliases (for older included builds)
|
| 302 |
+
const THREE = window.THREE;
|
| 303 |
+
const OrbitControls = THREE.OrbitControls;
|
| 304 |
+
const TextGeometry = THREE.TextGeometry;
|
| 305 |
+
const FontLoader = THREE.FontLoader;
|
| 306 |
+
|
| 307 |
+
// Scene variables
|
| 308 |
+
let scene, camera, renderer, controls, raycaster, mouse;
|
| 309 |
+
let hashtagGroup, tooltip;
|
| 310 |
+
let font;
|
| 311 |
+
let mapCount = 0;
|
| 312 |
+
|
| 313 |
+
// Firebase / app state
|
| 314 |
+
let db, auth;
|
| 315 |
+
let userId = null;
|
| 316 |
+
const appId = "neuronal-1f3b9";
|
| 317 |
+
let userProfile = null;
|
| 318 |
+
let userMaps = {};
|
| 319 |
+
let isAuthReady = false;
|
| 320 |
+
let isFontReady = false;
|
| 321 |
+
let isAnonymous = false;
|
| 322 |
+
let userProfileCache = {};
|
| 323 |
+
let allMapsDataCache = {};
|
| 324 |
+
const LOAD_DISTANCE = 30.0;
|
| 325 |
+
const intersected = {};
|
| 326 |
+
let minimapCtx;
|
| 327 |
+
let minimapDotCoords = [];
|
| 328 |
+
let minimapScale = 0.025;
|
| 329 |
+
const MINIMAP_DOT_SIZE = 2;
|
| 330 |
+
|
| 331 |
+
// Detectar si estamos en un Space estático de Hugging Face
|
| 332 |
+
const isHFStatic = /\.hf\.space$/.test(location.hostname);
|
| 333 |
+
|
| 334 |
+
const normalizeString = (str) => {
|
| 335 |
+
if (!str) return "";
|
| 336 |
+
return str.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
|
| 337 |
+
};
|
| 338 |
+
|
| 339 |
+
const loader = new FontLoader();
|
| 340 |
+
loader.load(
|
| 341 |
+
'https://cdn.jsdelivr.net/npm/three@0.128.0/examples/fonts/helvetiker_regular.typeface.json',
|
| 342 |
+
function (loadedFont) {
|
| 343 |
+
font = loadedFont;
|
| 344 |
+
isFontReady = true;
|
| 345 |
+
tryStartApp();
|
| 346 |
+
},
|
| 347 |
+
undefined,
|
| 348 |
+
function (err) {
|
| 349 |
+
console.error('Error al cargar la fuente 3D:', err);
|
| 350 |
+
}
|
| 351 |
+
);
|
| 352 |
+
|
| 353 |
+
initFirebase();
|
| 354 |
+
|
| 355 |
+
// Setup config UI
|
| 356 |
+
const geminiKeyInput = document.getElementById('geminiKeyInput');
|
| 357 |
+
const saveGeminiKeyBtn = document.getElementById('saveGeminiKeyBtn');
|
| 358 |
+
const geminiKeyStatus = document.getElementById('geminiKeyStatus');
|
| 359 |
+
if (geminiKeyInput && saveGeminiKeyBtn && geminiKeyStatus) {
|
| 360 |
+
const existing = getLocalGeminiKey();
|
| 361 |
+
if (existing) geminiKeyInput.value = existing;
|
| 362 |
+
saveGeminiKeyBtn.addEventListener('click', (e) => {
|
| 363 |
+
e.preventDefault();
|
| 364 |
+
setLocalGeminiKey(geminiKeyInput.value.trim());
|
| 365 |
+
geminiKeyStatus.textContent = 'Clave guardada localmente';
|
| 366 |
+
setTimeout(() => { geminiKeyStatus.textContent = ''; }, 2000);
|
| 367 |
+
});
|
| 368 |
+
}
|
| 369 |
+
|
| 370 |
+
async function initFirebase() {
|
| 371 |
+
try {
|
| 372 |
+
const app = initializeApp(firebaseConfig);
|
| 373 |
+
db = getFirestore(app);
|
| 374 |
+
auth = getAuth(app);
|
| 375 |
+
setLogLevel('Debug');
|
| 376 |
+
|
| 377 |
+
const loginMessage = document.getElementById('loginMessage');
|
| 378 |
+
const loginOverlay = document.getElementById('loginOverlay');
|
| 379 |
+
const loginForm = document.getElementById('loginForm');
|
| 380 |
+
const usernameForm = document.getElementById('usernameForm');
|
| 381 |
+
const loginEmail = document.getElementById('loginEmail');
|
| 382 |
+
const loginPassword = document.getElementById('loginPassword');
|
| 383 |
+
const showPasswordCheck = document.getElementById('showPasswordCheck');
|
| 384 |
+
const rememberMeCheck = document.getElementById('rememberMeCheck');
|
| 385 |
+
const loginButton = document.getElementById('loginButton');
|
| 386 |
+
const registerButton = document.getElementById('registerButton');
|
| 387 |
+
const saveUsernameButton = document.getElementById('saveUsernameButton');
|
| 388 |
+
const usernameInput = document.getElementById('usernameInput');
|
| 389 |
+
const usernameMessage = document.getElementById('usernameMessage');
|
| 390 |
+
const googleLoginButton = document.getElementById('googleLoginButton');
|
| 391 |
+
const anonymousLoginButton = document.getElementById('anonymousLoginButton');
|
| 392 |
+
const mainLoginButton = document.getElementById('mainLoginButton');
|
| 393 |
+
const mainLogoutButton = document.getElementById('mainLogoutButton');
|
| 394 |
+
|
| 395 |
+
// show/hide password
|
| 396 |
+
showPasswordCheck.addEventListener('change', () => {
|
| 397 |
+
loginPassword.type = showPasswordCheck.checked ? 'text' : 'password';
|
| 398 |
+
});
|
| 399 |
+
|
| 400 |
+
// register email/password
|
| 401 |
+
registerButton.addEventListener('click', async () => {
|
| 402 |
+
try {
|
| 403 |
+
const email = loginEmail.value;
|
| 404 |
+
const password = loginPassword.value;
|
| 405 |
+
if (email.length < 6 || password.length < 6) {
|
| 406 |
+
loginMessage.innerText = "Correo y contraseña deben tener al menos 6 caracteres.";
|
| 407 |
+
loginMessage.classList.add('text-red-500');
|
| 408 |
+
return;
|
| 409 |
+
}
|
| 410 |
+
loginMessage.innerText = "Registrando...";
|
| 411 |
+
loginMessage.classList.remove('text-red-500');
|
| 412 |
+
await createUserWithEmailAndPassword(auth, email, password);
|
| 413 |
+
} catch (error) {
|
| 414 |
+
console.error("Error al registrar:", error);
|
| 415 |
+
loginMessage.innerText = `Error: ${error.message}`;
|
| 416 |
+
loginMessage.classList.add('text-red-500');
|
| 417 |
+
}
|
| 418 |
+
});
|
| 419 |
+
|
| 420 |
+
// login email/password
|
| 421 |
+
loginButton.addEventListener('click', async () => {
|
| 422 |
+
try {
|
| 423 |
+
const email = loginEmail.value;
|
| 424 |
+
const password = loginPassword.value;
|
| 425 |
+
const persistence = rememberMeCheck.checked ? browserLocalPersistence : browserSessionPersistence;
|
| 426 |
+
loginMessage.innerText = "Iniciando sesión...";
|
| 427 |
+
loginMessage.classList.remove('text-red-500');
|
| 428 |
+
await setPersistence(auth, persistence);
|
| 429 |
+
await signInWithEmailAndPassword(auth, email, password);
|
| 430 |
+
} catch (error) {
|
| 431 |
+
console.error("Error al iniciar sesión:", error);
|
| 432 |
+
loginMessage.innerText = `Error: ${error.message}`;
|
| 433 |
+
loginMessage.classList.add('text-red-500');
|
| 434 |
+
}
|
| 435 |
+
});
|
| 436 |
+
|
| 437 |
+
// Google login (from modal)
|
| 438 |
+
googleLoginButton.addEventListener('click', async () => {
|
| 439 |
+
try {
|
| 440 |
+
const provider = new GoogleAuthProvider();
|
| 441 |
+
await signInWithPopup(auth, provider);
|
| 442 |
+
} catch (error) {
|
| 443 |
+
console.error("Error con Google Sign-In:", error);
|
| 444 |
+
if (loginMessage) {
|
| 445 |
+
loginMessage.innerText = `Error con Google: ${error.message}`;
|
| 446 |
+
loginMessage.classList.add('text-red-500');
|
| 447 |
+
}
|
| 448 |
+
}
|
| 449 |
+
});
|
| 450 |
+
|
| 451 |
+
// anonymous login (from modal)
|
| 452 |
+
anonymousLoginButton.addEventListener('click', async () => {
|
| 453 |
+
try {
|
| 454 |
+
loginMessage.innerText = "Entrando como anónimo...";
|
| 455 |
+
loginMessage.classList.remove('text-red-500');
|
| 456 |
+
await signInAnonymously(auth);
|
| 457 |
+
} catch (error) {
|
| 458 |
+
console.error("Error al iniciar sesión anónima:", error);
|
| 459 |
+
loginMessage.innerText = `Error: ${error.message}`;
|
| 460 |
+
loginMessage.classList.add('text-red-500');
|
| 461 |
+
}
|
| 462 |
+
});
|
| 463 |
+
|
| 464 |
+
// mainLoginButton (in UI header) -> open login modal for upgrade from anonymous or to login
|
| 465 |
+
mainLoginButton.addEventListener('click', async () => {
|
| 466 |
+
// show the login modal so user can choose Google or email
|
| 467 |
+
loginOverlay.style.display = 'flex';
|
| 468 |
+
setTimeout(() => { loginOverlay.style.opacity = '1'; }, 10);
|
| 469 |
+
});
|
| 470 |
+
|
| 471 |
+
// mainLogoutButton (in UI header)
|
| 472 |
+
mainLogoutButton.addEventListener('click', async () => {
|
| 473 |
+
try {
|
| 474 |
+
await signOut(auth);
|
| 475 |
+
} catch (err) {
|
| 476 |
+
console.error('Error al cerrar sesión:', err);
|
| 477 |
+
}
|
| 478 |
+
});
|
| 479 |
+
|
| 480 |
+
// save username for new accounts (modal)
|
| 481 |
+
saveUsernameButton.addEventListener('click', async () => {
|
| 482 |
+
if (!userId) {
|
| 483 |
+
usernameMessage.innerText = "Error, no se ha detectado usuario. Refresca la página.";
|
| 484 |
+
usernameMessage.classList.add('text-red-500');
|
| 485 |
+
return;
|
| 486 |
+
}
|
| 487 |
+
const username = normalizeString(usernameInput.value.trim());
|
| 488 |
+
if (username.length < 3) {
|
| 489 |
+
usernameMessage.innerText = "El nombre debe tener al menos 3 caracteres.";
|
| 490 |
+
usernameMessage.classList.add('text-red-500');
|
| 491 |
+
return;
|
| 492 |
+
}
|
| 493 |
+
await saveUserProfile(userId, username);
|
| 494 |
+
loginOverlay.style.opacity = '0';
|
| 495 |
+
setTimeout(() => { loginOverlay.style.display = 'none'; }, 500);
|
| 496 |
+
initScene();
|
| 497 |
+
loadAllMaps();
|
| 498 |
+
});
|
| 499 |
+
|
| 500 |
+
// auth state changes
|
| 501 |
+
onAuthStateChanged(auth, async (user) => {
|
| 502 |
+
if (user) {
|
| 503 |
+
console.log("Usuario autenticado:", user.uid);
|
| 504 |
+
userId = user.uid;
|
| 505 |
+
isAuthReady = true;
|
| 506 |
+
isAnonymous = user.isAnonymous;
|
| 507 |
+
|
| 508 |
+
if (user.isAnonymous) {
|
| 509 |
+
console.log("Usuario es anónimo.");
|
| 510 |
+
userProfile = { username: "Anónimo" };
|
| 511 |
+
// show the login button in UI header to allow upgrade
|
| 512 |
+
mainLoginButton.style.display = 'inline-block';
|
| 513 |
+
mainLogoutButton.style.display = 'none';
|
| 514 |
+
tryStartApp();
|
| 515 |
+
} else {
|
| 516 |
+
console.log("Usuario registrado.");
|
| 517 |
+
isAnonymous = false;
|
| 518 |
+
// try fetch profile; if none, show username form
|
| 519 |
+
await fetchUserProfile(userId);
|
| 520 |
+
mainLoginButton.style.display = 'none';
|
| 521 |
+
mainLogoutButton.style.display = 'inline-block';
|
| 522 |
+
tryStartApp();
|
| 523 |
+
}
|
| 524 |
+
// hide login overlay if it's open
|
| 525 |
+
loginOverlay.style.opacity = '0';
|
| 526 |
+
setTimeout(() => { loginOverlay.style.display = 'none'; }, 500);
|
| 527 |
+
} else {
|
| 528 |
+
console.log("Ningún usuario autenticado.");
|
| 529 |
+
userId = null;
|
| 530 |
+
isAuthReady = false;
|
| 531 |
+
isAnonymous = false;
|
| 532 |
+
userProfile = null;
|
| 533 |
+
|
| 534 |
+
loginOverlay.style.display = 'flex';
|
| 535 |
+
loginOverlay.style.opacity = '1';
|
| 536 |
+
loginForm.style.display = 'block';
|
| 537 |
+
usernameForm.style.display = 'none';
|
| 538 |
+
loginMessage.innerText = "Por favor, inicia sesión o regístrate.";
|
| 539 |
+
loginMessage.classList.remove('text-red-500');
|
| 540 |
+
|
| 541 |
+
mainLoginButton.style.display = 'inline-block';
|
| 542 |
+
mainLogoutButton.style.display = 'none';
|
| 543 |
+
}
|
| 544 |
+
});
|
| 545 |
+
} catch (error) {
|
| 546 |
+
console.error("Error inicializando Firebase:", error);
|
| 547 |
+
const loginMessage = document.getElementById('loginMessage');
|
| 548 |
+
if (loginMessage) {
|
| 549 |
+
loginMessage.innerText = "Error de conexión. Intenta recargar.";
|
| 550 |
+
loginMessage.classList.add('text-red-500');
|
| 551 |
+
}
|
| 552 |
+
}
|
| 553 |
+
}
|
| 554 |
+
|
| 555 |
+
// Firestore profile helpers
|
| 556 |
+
async function fetchUserProfile(uid) {
|
| 557 |
+
if (!db) return;
|
| 558 |
+
try {
|
| 559 |
+
const profileDocRef = doc(db, 'artifacts', appId, 'users', uid, 'user_data', 'profile');
|
| 560 |
+
const docSnap = await getDoc(profileDocRef);
|
| 561 |
+
if (docSnap && docSnap.exists()) {
|
| 562 |
+
userProfile = docSnap.data();
|
| 563 |
+
userProfileCache[uid] = userProfile;
|
| 564 |
+
console.log("Perfil de usuario cargado:", userProfile);
|
| 565 |
+
} else {
|
| 566 |
+
console.log("No se encontró perfil para el usuario:", uid);
|
| 567 |
+
userProfile = null;
|
| 568 |
+
}
|
| 569 |
+
} catch (error) {
|
| 570 |
+
console.error("Error al buscar perfil:", error);
|
| 571 |
+
userProfile = null;
|
| 572 |
+
}
|
| 573 |
+
}
|
| 574 |
+
|
| 575 |
+
async function saveUserProfile(uid, username) {
|
| 576 |
+
if (!db) return;
|
| 577 |
+
try {
|
| 578 |
+
const profileDocRef = doc(db, 'artifacts', appId, 'users', uid, 'user_data', 'profile');
|
| 579 |
+
await setDoc(profileDocRef, { username: username });
|
| 580 |
+
userProfile = { username: username };
|
| 581 |
+
userProfileCache[uid] = userProfile;
|
| 582 |
+
console.log("Perfil de usuario guardado:", userProfile);
|
| 583 |
+
} catch (error) {
|
| 584 |
+
console.error("Error al guardar perfil:", error);
|
| 585 |
+
}
|
| 586 |
+
}
|
| 587 |
+
|
| 588 |
+
async function getProfile(uid) {
|
| 589 |
+
if (userProfileCache[uid]) {
|
| 590 |
+
return userProfileCache[uid];
|
| 591 |
+
}
|
| 592 |
+
if (!db) return null;
|
| 593 |
+
try {
|
| 594 |
+
const profileDocRef = doc(db, 'artifacts', appId, 'users', uid, 'user_data', 'profile');
|
| 595 |
+
const docSnap = await getDoc(profileDocRef);
|
| 596 |
+
if (docSnap && docSnap.exists()) {
|
| 597 |
+
const profile = docSnap.data();
|
| 598 |
+
userProfileCache[uid] = profile;
|
| 599 |
+
return profile;
|
| 600 |
+
} else {
|
| 601 |
+
return null;
|
| 602 |
+
}
|
| 603 |
+
} catch (error) {
|
| 604 |
+
console.error("Error al buscar perfil (getProfile):", error);
|
| 605 |
+
return null;
|
| 606 |
+
}
|
| 607 |
+
}
|
| 608 |
+
|
| 609 |
+
function tryStartApp() {
|
| 610 |
+
if (!isAuthReady || !isFontReady) {
|
| 611 |
+
return;
|
| 612 |
+
}
|
| 613 |
+
console.log("Auth y Fuente listos. Iniciando app...");
|
| 614 |
+
const loginOverlay = document.getElementById('loginOverlay');
|
| 615 |
+
const loginForm = document.getElementById('loginForm');
|
| 616 |
+
const usernameForm = document.getElementById('usernameForm');
|
| 617 |
+
const usernameMessage = document.getElementById('usernameMessage');
|
| 618 |
+
|
| 619 |
+
if (userProfile) {
|
| 620 |
+
console.log(`Bienvenido de nuevo, ${userProfile.username}`);
|
| 621 |
+
loginOverlay.style.opacity = '0';
|
| 622 |
+
setTimeout(() => { loginOverlay.style.display = 'none'; }, 500);
|
| 623 |
+
initScene();
|
| 624 |
+
loadAllMaps();
|
| 625 |
+
} else {
|
| 626 |
+
console.log("Mostrando modal de login para nuevo usuario.");
|
| 627 |
+
usernameMessage.innerText = "¡Bienvenido! Elige un nombre de usuario público.";
|
| 628 |
+
usernameMessage.classList.remove('text-red-500');
|
| 629 |
+
loginForm.style.display = 'none';
|
| 630 |
+
usernameForm.style.display = 'block';
|
| 631 |
+
loginOverlay.style.display = 'flex';
|
| 632 |
+
loginOverlay.style.opacity = '1';
|
| 633 |
+
}
|
| 634 |
+
}
|
| 635 |
+
|
| 636 |
+
// ---------- Three.js scene setup and main app logic ----------
|
| 637 |
+
function initScene() {
|
| 638 |
+
scene = new THREE.Scene();
|
| 639 |
+
camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
|
| 640 |
+
camera.position.set(0, 0, 15);
|
| 641 |
+
|
| 642 |
+
renderer = new THREE.WebGLRenderer({ antialias: true });
|
| 643 |
+
renderer.setSize(window.innerWidth, window.innerHeight);
|
| 644 |
+
renderer.setPixelRatio(window.devicePixelRatio);
|
| 645 |
+
document.getElementById('container').appendChild(renderer.domElement);
|
| 646 |
+
|
| 647 |
+
tooltip = document.getElementById('tooltip');
|
| 648 |
+
|
| 649 |
+
controls = new OrbitControls(camera, renderer.domElement);
|
| 650 |
+
controls.enableDamping = true;
|
| 651 |
+
controls.dampingFactor = 0.05;
|
| 652 |
+
controls.target.set(0, 0, 0);
|
| 653 |
+
|
| 654 |
+
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
|
| 655 |
+
scene.add(ambientLight);
|
| 656 |
+
|
| 657 |
+
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
|
| 658 |
+
directionalLight.position.set(5, 10, 7.5);
|
| 659 |
+
scene.add(directionalLight);
|
| 660 |
+
|
| 661 |
+
raycaster = new THREE.Raycaster();
|
| 662 |
+
mouse = new THREE.Vector2();
|
| 663 |
+
|
| 664 |
+
hashtagGroup = new THREE.Group();
|
| 665 |
+
scene.add(hashtagGroup);
|
| 666 |
+
|
| 667 |
+
const visualizeButton = document.getElementById('visualizeButton');
|
| 668 |
+
const topicInput = document.getElementById('topicInput');
|
| 669 |
+
const level1Slider = document.getElementById('level1Slider');
|
| 670 |
+
const level2Slider = document.getElementById('level2Slider');
|
| 671 |
+
const level3Slider = document.getElementById('level3Slider');
|
| 672 |
+
|
| 673 |
+
if (isAnonymous) {
|
| 674 |
+
visualizeButton.disabled = true;
|
| 675 |
+
visualizeButton.innerHTML = `
|
| 676 |
+
<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">
|
| 677 |
+
<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" />
|
| 678 |
+
</svg>
|
| 679 |
+
<span>Solo Lectura</span>`;
|
| 680 |
+
visualizeButton.classList.add('bg-gray-500', 'hover:bg-gray-500', 'cursor-not-allowed');
|
| 681 |
+
visualizeButton.classList.remove('bg-green-600', 'hover:bg-green-700');
|
| 682 |
+
|
| 683 |
+
topicInput.disabled = true;
|
| 684 |
+
topicInput.placeholder = 'Inicia sesión para sembrar';
|
| 685 |
+
level1Slider.disabled = true;
|
| 686 |
+
level2Slider.disabled = true;
|
| 687 |
+
level3Slider.disabled = true;
|
| 688 |
+
} else {
|
| 689 |
+
visualizeButton.addEventListener('click', handleAnalysisAndVisualization);
|
| 690 |
+
}
|
| 691 |
+
|
| 692 |
+
document.getElementById('level1Slider').addEventListener('input', (e) => {
|
| 693 |
+
document.getElementById('level1Value').innerText = e.target.value;
|
| 694 |
+
});
|
| 695 |
+
document.getElementById('level2Slider').addEventListener('input', (e) => {
|
| 696 |
+
document.getElementById('level2Value').innerText = e.target.value;
|
| 697 |
+
});
|
| 698 |
+
document.getElementById('level3Slider').addEventListener('input', (e) => {
|
| 699 |
+
document.getElementById('level3Value').innerText = e.target.value;
|
| 700 |
+
});
|
| 701 |
+
|
| 702 |
+
window.addEventListener('resize', onWindowResize);
|
| 703 |
+
window.addEventListener('mousemove', onPointerMove);
|
| 704 |
+
|
| 705 |
+
const minimapCanvas = document.getElementById('minimap');
|
| 706 |
+
if (minimapCanvas) {
|
| 707 |
+
minimapCtx = minimapCanvas.getContext('2d');
|
| 708 |
+
minimapCanvas.addEventListener('click', onMinimapClick);
|
| 709 |
+
} else {
|
| 710 |
+
console.error("No se encontró el canvas del minimapa");
|
| 711 |
+
}
|
| 712 |
+
|
| 713 |
+
document.getElementById('zoomInButton').addEventListener('click', () => {
|
| 714 |
+
minimapScale *= 1.5;
|
| 715 |
+
drawMinimap();
|
| 716 |
+
});
|
| 717 |
+
document.getElementById('zoomOutButton').addEventListener('click', () => {
|
| 718 |
+
minimapScale /= 1.5;
|
| 719 |
+
drawMinimap();
|
| 720 |
+
});
|
| 721 |
+
|
| 722 |
+
animate();
|
| 723 |
+
}
|
| 724 |
+
|
| 725 |
+
function animate() {
|
| 726 |
+
requestAnimationFrame(animate);
|
| 727 |
+
controls.update();
|
| 728 |
+
updateRaycaster();
|
| 729 |
+
|
| 730 |
+
hashtagGroup.children.forEach(object => {
|
| 731 |
+
if (object.userData.isText) {
|
| 732 |
+
object.lookAt(camera.position);
|
| 733 |
+
const distance = object.position.distanceTo(camera.position);
|
| 734 |
+
const minScale = 0.5;
|
| 735 |
+
const maxScale = 4.0;
|
| 736 |
+
const scaleFactor = 10;
|
| 737 |
+
let scale = (1 / distance) * scaleFactor;
|
| 738 |
+
scale = Math.max(minScale, Math.min(maxScale, scale));
|
| 739 |
+
object.scale.set(scale, scale, scale);
|
| 740 |
+
}
|
| 741 |
+
});
|
| 742 |
+
|
| 743 |
+
hashtagGroup.children.forEach(object => {
|
| 744 |
+
if (object.userData.isPlaceholder && !object.userData.isLoaded) {
|
| 745 |
+
const distance = object.position.distanceTo(camera.position);
|
| 746 |
+
if (distance < LOAD_DISTANCE) {
|
| 747 |
+
object.userData.isLoaded = true;
|
| 748 |
+
object.visible = false;
|
| 749 |
+
loadFullGalaxy(object.userData.ownerId);
|
| 750 |
+
}
|
| 751 |
+
}
|
| 752 |
+
});
|
| 753 |
+
|
| 754 |
+
renderer.render(scene, camera);
|
| 755 |
+
}
|
| 756 |
+
|
| 757 |
+
// Fetch helper with backoff
|
| 758 |
+
async function fetchWithBackoff(url, options, retries = 3, delay = 1000) {
|
| 759 |
+
try {
|
| 760 |
+
return await fetch(url, options);
|
| 761 |
+
} catch (err) {
|
| 762 |
+
if (retries > 0) {
|
| 763 |
+
await new Promise(resolve => setTimeout(resolve, delay));
|
| 764 |
+
return fetchWithBackoff(url, options, retries - 1, delay * 2);
|
| 765 |
+
} else {
|
| 766 |
+
throw err;
|
| 767 |
+
}
|
| 768 |
+
}
|
| 769 |
+
}
|
| 770 |
+
|
| 771 |
+
// Gemini call routed through Netlify proxy with local fallback
|
| 772 |
+
async function callGemini(topic, mainCount, variantCount, subVariantCount) {
|
| 773 |
+
const modelId = 'gemini-2.5-flash-preview-09-2025';
|
| 774 |
+
const depth = 3;
|
| 775 |
+
|
| 776 |
+
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.`;
|
| 777 |
+
let userPrompt = `Tema: "${topic}".\n`;
|
| 778 |
+
|
| 779 |
+
let schema = {
|
| 780 |
+
type: "OBJECT",
|
| 781 |
+
properties: {
|
| 782 |
+
analisis: {
|
| 783 |
+
type: "STRING",
|
| 784 |
+
description: "Un análisis conciso del tema en 2-3 frases, sin acentos."
|
| 785 |
+
},
|
| 786 |
+
lista_palabras: {
|
| 787 |
+
type: "ARRAY",
|
| 788 |
+
description: `Una lista de ${mainCount} objetos.`,
|
| 789 |
+
items: {
|
| 790 |
+
type: "OBJECT",
|
| 791 |
+
properties: {
|
| 792 |
+
palabra_principal: { type: "STRING" }
|
| 793 |
+
},
|
| 794 |
+
required: ["palabra_principal"]
|
| 795 |
+
}
|
| 796 |
+
}
|
| 797 |
+
},
|
| 798 |
+
required: ["analisis", "lista_palabras"]
|
| 799 |
+
};
|
| 800 |
+
|
| 801 |
+
const n1Items = schema.properties.lista_palabras.items;
|
| 802 |
+
|
| 803 |
+
if (depth >= 2) {
|
| 804 |
+
userPrompt += `1. Genera una lista de ${mainCount} palabras clave principales (Nivel 1).\n`;
|
| 805 |
+
userPrompt += `2. Para CADA palabra de Nivel 1, genera ${variantCount} variantes (Nivel 2).\n`;
|
| 806 |
+
|
| 807 |
+
n1Items.properties.variantes = {
|
| 808 |
+
type: "ARRAY",
|
| 809 |
+
description: `Una lista de ${variantCount} objetos de variantes.`,
|
| 810 |
+
items: {
|
| 811 |
+
type: "OBJECT",
|
| 812 |
+
properties: {
|
| 813 |
+
palabra_variante: { type: "STRING" }
|
| 814 |
+
},
|
| 815 |
+
required: ["palabra_variante"]
|
| 816 |
+
}
|
| 817 |
+
};
|
| 818 |
+
n1Items.required.push("variantes");
|
| 819 |
+
}
|
| 820 |
+
|
| 821 |
+
if (depth == 3) {
|
| 822 |
+
userPrompt += `3. Para CADA variante de Nivel 2, genera ${subVariantCount} sub-variantes (Nivel 3).\n`;
|
| 823 |
+
userPrompt += `4. Asegurate que todo el texto no contenga acentos.`;
|
| 824 |
+
|
| 825 |
+
const n2Items = n1Items.properties.variantes.items;
|
| 826 |
+
n2Items.properties.sub_variantes = {
|
| 827 |
+
type: "ARRAY",
|
| 828 |
+
description: `Una lista de ${subVariantCount} sub-variantes.`,
|
| 829 |
+
items: { type: "STRING" }
|
| 830 |
+
};
|
| 831 |
+
n2Items.required.push("sub_variantes");
|
| 832 |
+
} else if (depth == 2) {
|
| 833 |
+
userPrompt += `3. Asegurate que todo el texto no contenga acentos.`;
|
| 834 |
+
} else {
|
| 835 |
+
userPrompt += `1. Genera una lista de ${mainCount} palabras clave principales (Nivel 1).\n`;
|
| 836 |
+
userPrompt += `2. Asegurate que todo el texto no contenga acentos.`;
|
| 837 |
+
}
|
| 838 |
+
|
| 839 |
+
const payload = {
|
| 840 |
+
contents: [{ parts: [{ text: userPrompt }] }],
|
| 841 |
+
systemInstruction: {
|
| 842 |
+
parts: [{ text: systemInstruction }]
|
| 843 |
+
},
|
| 844 |
+
generationConfig: {
|
| 845 |
+
responseMimeType: "application/json",
|
| 846 |
+
responseSchema: schema
|
| 847 |
+
}
|
| 848 |
+
};
|
| 849 |
+
|
| 850 |
+
console.log("Preparando solicitud a Gemini:", payload);
|
| 851 |
+
|
| 852 |
+
// 1) Intentar proxy de Netlify solo si NO estamos en Hugging Face Static
|
| 853 |
+
if (!isHFStatic) {
|
| 854 |
+
try {
|
| 855 |
+
const proxyResp = await fetchWithBackoff('/.netlify/functions/gemini-proxy', {
|
| 856 |
+
method: 'POST',
|
| 857 |
+
headers: { 'Content-Type': 'application/json' },
|
| 858 |
+
body: JSON.stringify({ model: modelId, payload })
|
| 859 |
+
});
|
| 860 |
+
if (proxyResp.ok) {
|
| 861 |
+
return await proxyResp.json();
|
| 862 |
+
} else {
|
| 863 |
+
const t = await proxyResp.text();
|
| 864 |
+
console.warn('Proxy no disponible o error:', proxyResp.status, t);
|
| 865 |
+
}
|
| 866 |
+
} catch (e) {
|
| 867 |
+
console.warn('Fallo al conectar con el proxy, usando fallback local...', e);
|
| 868 |
+
}
|
| 869 |
+
}
|
| 870 |
+
|
| 871 |
+
// 2) Fallback to direct call with local key (only for local/dev)
|
| 872 |
+
const localKey = getLocalGeminiKey();
|
| 873 |
+
if (!localKey) {
|
| 874 |
+
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.');
|
| 875 |
+
}
|
| 876 |
+
const apiUrlDirect = `https://generativelanguage.googleapis.com/v1beta/models/${modelId}:generateContent?key=${localKey}`;
|
| 877 |
+
const directResp = await fetchWithBackoff(apiUrlDirect, {
|
| 878 |
+
method: 'POST',
|
| 879 |
+
headers: { 'Content-Type': 'application/json' },
|
| 880 |
+
body: JSON.stringify(payload)
|
| 881 |
+
});
|
| 882 |
+
if (!directResp.ok) {
|
| 883 |
+
const errorBody = await directResp.text();
|
| 884 |
+
console.error('Error en la API de Gemini (fallback):', directResp.status, errorBody);
|
| 885 |
+
throw new Error(`Error en la API (fallback): ${directResp.statusText}`);
|
| 886 |
+
}
|
| 887 |
+
return await directResp.json();
|
| 888 |
+
}
|
| 889 |
+
|
| 890 |
+
// Main handler for analysis + visualization
|
| 891 |
+
async function handleAnalysisAndVisualization() {
|
| 892 |
+
if (!font) {
|
| 893 |
+
console.error("La fuente 3D no se ha cargado. Espera o refresca.");
|
| 894 |
+
return;
|
| 895 |
+
}
|
| 896 |
+
|
| 897 |
+
const topic = normalizeString(document.getElementById('topicInput').value);
|
| 898 |
+
const mainCount = document.getElementById('level1Slider').value;
|
| 899 |
+
const variantCount = document.getElementById('level2Slider').value;
|
| 900 |
+
const subVariantCount = document.getElementById('level3Slider').value;
|
| 901 |
+
|
| 902 |
+
const button = document.getElementById('visualizeButton');
|
| 903 |
+
const progressBarContainer = document.getElementById('progressBarContainer');
|
| 904 |
+
const progressBar = document.getElementById('progressBar');
|
| 905 |
+
|
| 906 |
+
if (!topic) {
|
| 907 |
+
console.warn("Por favor, introduce un tema.");
|
| 908 |
+
return;
|
| 909 |
+
}
|
| 910 |
+
|
| 911 |
+
button.disabled = true;
|
| 912 |
+
button.innerText = 'Analizando...';
|
| 913 |
+
|
| 914 |
+
progressBar.style.transition = 'none';
|
| 915 |
+
progressBar.style.width = '0%';
|
| 916 |
+
progressBarContainer.style.display = 'block';
|
| 917 |
+
void progressBar.offsetWidth;
|
| 918 |
+
progressBar.style.transition = 'width 20s ease-out';
|
| 919 |
+
progressBar.style.width = '95%';
|
| 920 |
+
|
| 921 |
+
let mapOrigin = new THREE.Vector3(0, 0, 0);
|
| 922 |
+
const existingUserMaps = userMaps[userId] || [];
|
| 923 |
+
const localMapIndex = existingUserMaps.length;
|
| 924 |
+
|
| 925 |
+
if (localMapIndex === 0) {
|
| 926 |
+
const galaxyIndex = Object.keys(userMaps).length;
|
| 927 |
+
const GALAXY_SEPARATION_STEP = 750;
|
| 928 |
+
const angle = galaxyIndex * 2.3998;
|
| 929 |
+
const radius = galaxyIndex * GALAXY_SEPARATION_STEP;
|
| 930 |
+
mapOrigin.x = radius * Math.cos(angle);
|
| 931 |
+
mapOrigin.z = radius * Math.sin(angle);
|
| 932 |
+
mapOrigin.y = 0;
|
| 933 |
+
} else {
|
| 934 |
+
const galaxyCentroid = new THREE.Vector3(0, 0, 0);
|
| 935 |
+
existingUserMaps.forEach(origin => galaxyCentroid.add(origin));
|
| 936 |
+
galaxyCentroid.divideScalar(localMapIndex);
|
| 937 |
+
const LOCAL_RADIUS_STEP = 25;
|
| 938 |
+
const angle = localMapIndex * 2.3998;
|
| 939 |
+
const radius = Math.sqrt(localMapIndex) * LOCAL_RADIUS_STEP;
|
| 940 |
+
mapOrigin.x = galaxyCentroid.x + radius * Math.cos(angle);
|
| 941 |
+
mapOrigin.z = galaxyCentroid.z + radius * Math.sin(angle);
|
| 942 |
+
mapOrigin.y = 0;
|
| 943 |
+
}
|
| 944 |
+
|
| 945 |
+
try {
|
| 946 |
+
console.log(`Llamando a Gemini con: ${topic}`);
|
| 947 |
+
const result = await callGemini(topic, mainCount, variantCount, subVariantCount);
|
| 948 |
+
console.log("Respuesta de Gemini recibida:", result);
|
| 949 |
+
|
| 950 |
+
const candidate = result.candidates?.[0];
|
| 951 |
+
if (candidate && candidate.content?.parts?.[0]?.text) {
|
| 952 |
+
const text = candidate.content.parts[0].text;
|
| 953 |
+
const parsedData = JSON.parse(text);
|
| 954 |
+
|
| 955 |
+
const rootTopic = topic;
|
| 956 |
+
visualizeRoot(rootTopic, mapOrigin);
|
| 957 |
+
visualizeHashtags(parsedData.lista_palabras, mapOrigin, 1, null);
|
| 958 |
+
|
| 959 |
+
if (db) {
|
| 960 |
+
await saveMapToFirestore(rootTopic, "3", mapOrigin, parsedData);
|
| 961 |
+
}
|
| 962 |
+
} else {
|
| 963 |
+
throw new Error("Respuesta de Gemini inválida o vacía.");
|
| 964 |
+
}
|
| 965 |
+
} catch (error) {
|
| 966 |
+
console.error("Error en handleAnalysisAndVisualization:", error);
|
| 967 |
+
} finally {
|
| 968 |
+
button.disabled = false;
|
| 969 |
+
button.innerText = 'Sembrar';
|
| 970 |
+
progressBar.style.transition = 'width 0.3s ease-in';
|
| 971 |
+
progressBar.style.width = '100%';
|
| 972 |
+
setTimeout(() => {
|
| 973 |
+
progressBarContainer.style.display = 'none';
|
| 974 |
+
progressBar.style.width = '0%';
|
| 975 |
+
}, 500);
|
| 976 |
+
}
|
| 977 |
+
}
|
| 978 |
+
|
| 979 |
+
// Scene helpers (clear, visualize root, visualize hashtags, etc.)
|
| 980 |
+
function clearScene() {
|
| 981 |
+
while (hashtagGroup.children.length > 0) {
|
| 982 |
+
const object = hashtagGroup.children[0];
|
| 983 |
+
if (object.geometry) object.geometry.dispose();
|
| 984 |
+
if (Array.isArray(object.material)) {
|
| 985 |
+
object.material.forEach(m => m.dispose());
|
| 986 |
+
} else if (object.material) {
|
| 987 |
+
object.material.dispose();
|
| 988 |
+
}
|
| 989 |
+
while (object.children.length > 0) {
|
| 990 |
+
const child = object.children[0];
|
| 991 |
+
if (child.geometry) child.geometry.dispose();
|
| 992 |
+
if (child.material) child.material.dispose();
|
| 993 |
+
object.remove(child);
|
| 994 |
+
}
|
| 995 |
+
hashtagGroup.remove(object);
|
| 996 |
+
}
|
| 997 |
+
console.log("Escena limpiada.");
|
| 998 |
+
}
|
| 999 |
+
|
| 1000 |
+
function visualizeRoot(topic, origin) {
|
| 1001 |
+
const { color: rootColor } = stringToHslColor(topic);
|
| 1002 |
+
const rootMaterial = new THREE.MeshStandardMaterial({
|
| 1003 |
+
color: new THREE.Color(rootColor),
|
| 1004 |
+
roughness: 0.5,
|
| 1005 |
+
metalness: 0.1
|
| 1006 |
+
});
|
| 1007 |
+
const rootTextMaterial = new THREE.MeshBasicMaterial({
|
| 1008 |
+
color: new THREE.Color(rootColor),
|
| 1009 |
+
transparent: true,
|
| 1010 |
+
opacity: 0.7
|
| 1011 |
+
});
|
| 1012 |
+
|
| 1013 |
+
const rootSphereRadius = 0.4;
|
| 1014 |
+
const rootGeometry = new THREE.SphereGeometry(rootSphereRadius, 16, 16);
|
| 1015 |
+
const rootSphere = new THREE.Mesh(rootGeometry, rootMaterial);
|
| 1016 |
+
rootSphere.position.copy(origin);
|
| 1017 |
+
rootSphere.userData.hashtag = topic;
|
| 1018 |
+
rootSphere.userData.level = 0;
|
| 1019 |
+
hashtagGroup.add(rootSphere);
|
| 1020 |
+
|
| 1021 |
+
const rootTextSize = 0.3;
|
| 1022 |
+
const rootTextGeometry = new TextGeometry(topic.toUpperCase(), {
|
| 1023 |
+
font: font,
|
| 1024 |
+
size: rootTextSize,
|
| 1025 |
+
height: 0.02,
|
| 1026 |
+
curveSegments: 4,
|
| 1027 |
+
bevelEnabled: false
|
| 1028 |
+
});
|
| 1029 |
+
rootTextGeometry.computeBoundingBox();
|
| 1030 |
+
|
| 1031 |
+
const rootTextMesh = new THREE.Mesh(rootTextGeometry, rootTextMaterial);
|
| 1032 |
+
rootTextMesh.position.copy(origin);
|
| 1033 |
+
rootTextMesh.position.y += rootSphereRadius + 0.1;
|
| 1034 |
+
rootTextMesh.position.x -= (rootTextGeometry.boundingBox.max.x - rootTextGeometry.boundingBox.min.x) / 2;
|
| 1035 |
+
rootTextMesh.userData.isText = true;
|
| 1036 |
+
hashtagGroup.add(rootTextMesh);
|
| 1037 |
+
}
|
| 1038 |
+
|
| 1039 |
+
async function saveMapToFirestore(topic, depth, origin, data) {
|
| 1040 |
+
if (!db) return;
|
| 1041 |
+
try {
|
| 1042 |
+
const mapCollection = collection(db, 'artifacts', appId, 'public', 'data', 'maps');
|
| 1043 |
+
const mapDocument = {
|
| 1044 |
+
topic: topic,
|
| 1045 |
+
depth: depth,
|
| 1046 |
+
origin: { x: origin.x, y: origin.y, z: origin.z },
|
| 1047 |
+
data: JSON.stringify(data),
|
| 1048 |
+
createdAt: new Date(),
|
| 1049 |
+
userId: userId
|
| 1050 |
+
};
|
| 1051 |
+
await addDoc(mapCollection, mapDocument);
|
| 1052 |
+
console.log("Mapa guardado en Firestore:", topic);
|
| 1053 |
+
} catch (error) {
|
| 1054 |
+
console.error("Error guardando mapa en Firestore:", error);
|
| 1055 |
+
}
|
| 1056 |
+
}
|
| 1057 |
+
|
| 1058 |
+
function loadAllMaps() {
|
| 1059 |
+
if (!db || !font) {
|
| 1060 |
+
console.warn("Firestore o la fuente no están listos. Esperando...");
|
| 1061 |
+
if (!font) {
|
| 1062 |
+
setTimeout(loadAllMaps, 500);
|
| 1063 |
+
}
|
| 1064 |
+
return;
|
| 1065 |
+
}
|
| 1066 |
+
|
| 1067 |
+
const mapCollection = collection(db, 'artifacts', appId, 'public', 'data', 'maps');
|
| 1068 |
+
const q = query(mapCollection);
|
| 1069 |
+
|
| 1070 |
+
onSnapshot(q, async (snapshot) => {
|
| 1071 |
+
console.log("Datos de Firestore recibidos, redibujando escena...");
|
| 1072 |
+
clearScene();
|
| 1073 |
+
mapCount = 0;
|
| 1074 |
+
userMaps = {};
|
| 1075 |
+
allMapsDataCache = {};
|
| 1076 |
+
|
| 1077 |
+
if (snapshot.empty) {
|
| 1078 |
+
console.log("No se encontraron mapas en Firestore.");
|
| 1079 |
+
drawMinimap();
|
| 1080 |
+
return;
|
| 1081 |
+
}
|
| 1082 |
+
|
| 1083 |
+
snapshot.docs.forEach((doc) => {
|
| 1084 |
+
mapCount++;
|
| 1085 |
+
const map = doc.data();
|
| 1086 |
+
if (!map.origin || !map.data || !map.topic || !map.userId) {
|
| 1087 |
+
console.warn("Documento de mapa incompleto, saltando:", doc.id);
|
| 1088 |
+
return;
|
| 1089 |
+
}
|
| 1090 |
+
const origin = new THREE.Vector3(map.origin.x, map.origin.y, map.origin.z);
|
| 1091 |
+
if (!userMaps[map.userId]) {
|
| 1092 |
+
userMaps[map.userId] = [];
|
| 1093 |
+
}
|
| 1094 |
+
userMaps[map.userId].push(origin);
|
| 1095 |
+
|
| 1096 |
+
let parsedData;
|
| 1097 |
+
try {
|
| 1098 |
+
parsedData = JSON.parse(map.data);
|
| 1099 |
+
} catch (e) {
|
| 1100 |
+
console.error("Error al parsear datos del mapa:", e, doc.id);
|
| 1101 |
+
return;
|
| 1102 |
+
}
|
| 1103 |
+
|
| 1104 |
+
if (!allMapsDataCache[map.userId]) {
|
| 1105 |
+
allMapsDataCache[map.userId] = [];
|
| 1106 |
+
}
|
| 1107 |
+
allMapsDataCache[map.userId].push({ topic: map.topic, origin: origin, data: parsedData });
|
| 1108 |
+
|
| 1109 |
+
if (map.userId === userId) {
|
| 1110 |
+
visualizeRoot(map.topic, origin);
|
| 1111 |
+
visualizeHashtags(parsedData.lista_palabras, origin, 1, null);
|
| 1112 |
+
}
|
| 1113 |
+
});
|
| 1114 |
+
|
| 1115 |
+
Object.keys(userMaps).forEach(uid => {
|
| 1116 |
+
if (uid !== userId) {
|
| 1117 |
+
const userMapOrigins = userMaps[uid];
|
| 1118 |
+
const centroid = new THREE.Vector3(0, 0, 0);
|
| 1119 |
+
userMapOrigins.forEach(origin => centroid.add(origin));
|
| 1120 |
+
centroid.divideScalar(userMapOrigins.length);
|
| 1121 |
+
visualizeUserPlaceholder(uid, centroid);
|
| 1122 |
+
}
|
| 1123 |
+
});
|
| 1124 |
+
|
| 1125 |
+
const userListElement = document.getElementById('userList');
|
| 1126 |
+
if (userListElement) {
|
| 1127 |
+
userListElement.innerHTML = '';
|
| 1128 |
+
const profilePromises = Object.keys(userMaps).map(uid => getProfile(uid));
|
| 1129 |
+
const profiles = await Promise.all(profilePromises);
|
| 1130 |
+
Object.keys(userMaps).forEach((uid, index) => {
|
| 1131 |
+
const profile = profiles[index];
|
| 1132 |
+
const username = profile ? profile.username : `Usuario ${uid.substring(0,4)}`;
|
| 1133 |
+
const userItem = document.createElement('div');
|
| 1134 |
+
userItem.className = 'p-2 mb-1 rounded-md hover:bg-gray-700 cursor-pointer transition-colors duration-200 text-sm';
|
| 1135 |
+
userItem.innerText = username;
|
| 1136 |
+
userItem.dataset.userid = uid;
|
| 1137 |
+
userItem.addEventListener('click', () => teleportToUser(uid));
|
| 1138 |
+
userListElement.appendChild(userItem);
|
| 1139 |
+
});
|
| 1140 |
+
}
|
| 1141 |
+
|
| 1142 |
+
drawMinimap();
|
| 1143 |
+
console.log(`Cargados ${mapCount} mapas.`);
|
| 1144 |
+
focusOnUserMaps();
|
| 1145 |
+
}, (error) => {
|
| 1146 |
+
console.error("Error al escuchar mapas de Firestore:", error);
|
| 1147 |
+
});
|
| 1148 |
+
}
|
| 1149 |
+
|
| 1150 |
+
async function visualizeUserPlaceholder(uid, centroid) {
|
| 1151 |
+
const profile = await getProfile(uid);
|
| 1152 |
+
const username = profile ? profile.username : 'Usuario';
|
| 1153 |
+
const placeholderMaterial = new THREE.MeshBasicMaterial({
|
| 1154 |
+
color: 0x00ffff,
|
| 1155 |
+
emissive: 0x00ffff,
|
| 1156 |
+
emissiveIntensity: 1,
|
| 1157 |
+
wireframe: true
|
| 1158 |
+
});
|
| 1159 |
+
const placeholderGeometry = new THREE.SphereGeometry(0.5, 8, 8);
|
| 1160 |
+
const placeholderSphere = new THREE.Mesh(placeholderGeometry, placeholderMaterial);
|
| 1161 |
+
placeholderSphere.position.copy(centroid);
|
| 1162 |
+
|
| 1163 |
+
placeholderSphere.userData = {
|
| 1164 |
+
isPlaceholder: true,
|
| 1165 |
+
ownerId: uid,
|
| 1166 |
+
isLoaded: false,
|
| 1167 |
+
hashtag: username
|
| 1168 |
+
};
|
| 1169 |
+
|
| 1170 |
+
const placeholderTextMaterial = new THREE.MeshBasicMaterial({ color: 0x00ffff, transparent: true, opacity: 0.7 });
|
| 1171 |
+
const textGeometry = new TextGeometry(username, {
|
| 1172 |
+
font: font,
|
| 1173 |
+
size: 0.3,
|
| 1174 |
+
height: 0.02,
|
| 1175 |
+
curveSegments: 4,
|
| 1176 |
+
bevelEnabled: false
|
| 1177 |
+
});
|
| 1178 |
+
textGeometry.computeBoundingBox();
|
| 1179 |
+
|
| 1180 |
+
const textMesh = new THREE.Mesh(textGeometry, placeholderTextMaterial);
|
| 1181 |
+
textMesh.position.y += 0.6;
|
| 1182 |
+
textMesh.position.x -= (textGeometry.boundingBox.max.x - textGeometry.boundingBox.min.x) / 2;
|
| 1183 |
+
textMesh.userData.isText = true;
|
| 1184 |
+
|
| 1185 |
+
placeholderSphere.add(textMesh);
|
| 1186 |
+
hashtagGroup.add(placeholderSphere);
|
| 1187 |
+
}
|
| 1188 |
+
|
| 1189 |
+
function loadFullGalaxy(ownerId) {
|
| 1190 |
+
console.log("Cargando galaxia para:", ownerId);
|
| 1191 |
+
const mapsToLoad = allMapsDataCache[ownerId];
|
| 1192 |
+
if (!mapsToLoad) {
|
| 1193 |
+
console.warn("No se encontraron datos en caché para:", ownerId);
|
| 1194 |
+
return;
|
| 1195 |
+
}
|
| 1196 |
+
mapsToLoad.forEach(map => {
|
| 1197 |
+
visualizeRoot(map.topic, map.origin);
|
| 1198 |
+
visualizeHashtags(map.data.lista_palabras, map.origin, 1, null);
|
| 1199 |
+
});
|
| 1200 |
+
}
|
| 1201 |
+
|
| 1202 |
+
function focusOnUserMaps() {
|
| 1203 |
+
if (!controls) return;
|
| 1204 |
+
if (!userId || !userMaps[userId] || userMaps[userId].length === 0) {
|
| 1205 |
+
console.log("Usuario sin mapas o no logueado, centrando en (0,0,0).");
|
| 1206 |
+
controls.target.set(0, 0, 0);
|
| 1207 |
+
camera.position.set(0, 0, 15);
|
| 1208 |
+
controls.update();
|
| 1209 |
+
return;
|
| 1210 |
+
}
|
| 1211 |
+
const userMapOrigins = userMaps[userId];
|
| 1212 |
+
const centroid = new THREE.Vector3(0, 0, 0);
|
| 1213 |
+
userMapOrigins.forEach(origin => centroid.add(origin));
|
| 1214 |
+
centroid.divideScalar(userMapOrigins.length);
|
| 1215 |
+
console.log(`Enfocando en la constelación del usuario en:`, centroid);
|
| 1216 |
+
controls.target.copy(centroid);
|
| 1217 |
+
const cameraOffset = new THREE.Vector3(0, 5, 20);
|
| 1218 |
+
camera.position.copy(centroid).add(cameraOffset);
|
| 1219 |
+
controls.update();
|
| 1220 |
+
}
|
| 1221 |
+
|
| 1222 |
+
function teleportToUser(targetUserId) {
|
| 1223 |
+
if (!controls || !userMaps[targetUserId] || userMaps[targetUserId].length === 0) {
|
| 1224 |
+
console.warn("No se puede teletransportar: Faltan controles o mapas para el usuario", targetUserId);
|
| 1225 |
+
return;
|
| 1226 |
+
}
|
| 1227 |
+
const userMapOrigins = userMaps[targetUserId];
|
| 1228 |
+
const centroid = new THREE.Vector3(0, 0, 0);
|
| 1229 |
+
userMapOrigins.forEach(origin => centroid.add(origin));
|
| 1230 |
+
centroid.divideScalar(userMapOrigins.length);
|
| 1231 |
+
console.log(`Teletransportando a la galaxia de ${targetUserId} en:`, centroid);
|
| 1232 |
+
controls.target.copy(centroid);
|
| 1233 |
+
const cameraOffset = new THREE.Vector3(0, 5, 20);
|
| 1234 |
+
camera.position.copy(centroid).add(cameraOffset);
|
| 1235 |
+
controls.update();
|
| 1236 |
+
}
|
| 1237 |
+
|
| 1238 |
+
function visualizeHashtags(dataList, origin, level, parentColor = null) {
|
| 1239 |
+
if (!dataList || dataList.length === 0) return;
|
| 1240 |
+
console.log(`Dibujando Nivel ${level} con ${dataList.length} items.`);
|
| 1241 |
+
for (const item of dataList) {
|
| 1242 |
+
let currentTag, variantsList;
|
| 1243 |
+
if (level === 1) {
|
| 1244 |
+
currentTag = normalizeString(item.palabra_principal);
|
| 1245 |
+
variantsList = item.variantes || [];
|
| 1246 |
+
} else if (level === 2) {
|
| 1247 |
+
currentTag = normalizeString(item.palabra_variante);
|
| 1248 |
+
variantsList = item.sub_variantes || [];
|
| 1249 |
+
} else {
|
| 1250 |
+
currentTag = normalizeString(item);
|
| 1251 |
+
variantsList = [];
|
| 1252 |
+
}
|
| 1253 |
+
if (!currentTag) continue;
|
| 1254 |
+
const { color, h } = stringToHslColor(currentTag);
|
| 1255 |
+
const nodeColor = (level === 1) ? color : parentColor;
|
| 1256 |
+
const nodeMaterial = new THREE.MeshStandardMaterial({
|
| 1257 |
+
color: new THREE.Color(nodeColor),
|
| 1258 |
+
roughness: 0.5,
|
| 1259 |
+
metalness: 0.1
|
| 1260 |
+
});
|
| 1261 |
+
const nodeTextMaterial = new THREE.MeshBasicMaterial({
|
| 1262 |
+
color: new THREE.Color(nodeColor),
|
| 1263 |
+
transparent: true,
|
| 1264 |
+
opacity: 0.7
|
| 1265 |
+
});
|
| 1266 |
+
|
| 1267 |
+
const theta = (h / 360) * Math.PI * 2;
|
| 1268 |
+
let phiHash = 0;
|
| 1269 |
+
for (let i = 0; i < currentTag.length; i++) {
|
| 1270 |
+
phiHash = (phiHash + currentTag.charCodeAt(i) * 13) % 180;
|
| 1271 |
+
}
|
| 1272 |
+
const phi = ((phiHash / 180) * 90 + 45) * (Math.PI / 180);
|
| 1273 |
+
|
| 1274 |
+
const baseRadius = 10 / (level * level);
|
| 1275 |
+
const clusterCenterX = baseRadius * Math.sin(phi) * Math.cos(theta);
|
| 1276 |
+
const clusterCenterY = baseRadius * Math.cos(phi);
|
| 1277 |
+
const clusterCenterZ = baseRadius * Math.sin(phi) * Math.sin(theta);
|
| 1278 |
+
const clusterCenter = new THREE.Vector3(clusterCenterX, clusterCenterY, clusterCenterZ);
|
| 1279 |
+
clusterCenter.add(origin);
|
| 1280 |
+
|
| 1281 |
+
const branchColor = new THREE.Color(nodeColor).multiplyScalar(0.4);
|
| 1282 |
+
const lineMaterial = new THREE.LineBasicMaterial({ color: branchColor });
|
| 1283 |
+
const linePoints = [origin, clusterCenter];
|
| 1284 |
+
const lineGeometry = new THREE.BufferGeometry().setFromPoints(linePoints);
|
| 1285 |
+
const line = new THREE.Line(lineGeometry, lineMaterial);
|
| 1286 |
+
hashtagGroup.add(line);
|
| 1287 |
+
|
| 1288 |
+
let sphereRadius;
|
| 1289 |
+
if (level === 1) sphereRadius = 0.2;
|
| 1290 |
+
else if (level === 2) sphereRadius = 0.1;
|
| 1291 |
+
else sphereRadius = 0.05;
|
| 1292 |
+
|
| 1293 |
+
const leafGeometry = new THREE.SphereGeometry(sphereRadius, 8, 8);
|
| 1294 |
+
const sphere = new THREE.Mesh(leafGeometry, nodeMaterial);
|
| 1295 |
+
sphere.position.copy(clusterCenter);
|
| 1296 |
+
sphere.userData.hashtag = currentTag;
|
| 1297 |
+
sphere.userData.count = 1;
|
| 1298 |
+
sphere.userData.level = level;
|
| 1299 |
+
hashtagGroup.add(sphere);
|
| 1300 |
+
|
| 1301 |
+
let baseTextSize;
|
| 1302 |
+
if (level === 1) baseTextSize = 0.24;
|
| 1303 |
+
else if (level === 2) baseTextSize = 0.12;
|
| 1304 |
+
else baseTextSize = 0.08;
|
| 1305 |
+
|
| 1306 |
+
const textGeometry = new TextGeometry(currentTag.toUpperCase(), {
|
| 1307 |
+
font: font,
|
| 1308 |
+
size: baseTextSize,
|
| 1309 |
+
height: 0.02 / level,
|
| 1310 |
+
curveSegments: 4,
|
| 1311 |
+
bevelEnabled: false
|
| 1312 |
+
});
|
| 1313 |
+
textGeometry.computeBoundingBox();
|
| 1314 |
+
const textMesh = new THREE.Mesh(textGeometry, nodeTextMaterial);
|
| 1315 |
+
const smallMargin = 0.05 / level;
|
| 1316 |
+
textMesh.position.copy(clusterCenter);
|
| 1317 |
+
textMesh.position.y += sphereRadius + smallMargin;
|
| 1318 |
+
textMesh.position.x -= (textGeometry.boundingBox.max.x - textGeometry.boundingBox.min.x) / 2;
|
| 1319 |
+
textMesh.userData.isText = true;
|
| 1320 |
+
hashtagGroup.add(textMesh);
|
| 1321 |
+
|
| 1322 |
+
visualizeHashtags(variantsList, clusterCenter, level + 1, nodeColor);
|
| 1323 |
+
}
|
| 1324 |
+
}
|
| 1325 |
+
|
| 1326 |
+
function drawMinimapDot(dot, color, size = MINIMAP_DOT_SIZE) {
|
| 1327 |
+
if (!minimapCtx) return;
|
| 1328 |
+
minimapCtx.beginPath();
|
| 1329 |
+
minimapCtx.arc(dot.x, dot.y, size, 0, Math.PI * 2);
|
| 1330 |
+
minimapCtx.fillStyle = color;
|
| 1331 |
+
minimapCtx.fill();
|
| 1332 |
+
minimapCtx.fillStyle = 'white';
|
| 1333 |
+
minimapCtx.font = '10px Orbitron';
|
| 1334 |
+
minimapCtx.fillText(dot.username, dot.x + size + 3, dot.y + 4);
|
| 1335 |
+
}
|
| 1336 |
+
|
| 1337 |
+
async function drawMinimap() {
|
| 1338 |
+
if (!minimapCtx || !userMaps) return;
|
| 1339 |
+
const canvas = minimapCtx.canvas;
|
| 1340 |
+
minimapCtx.fillStyle = '#1f2937';
|
| 1341 |
+
minimapCtx.fillRect(0, 0, canvas.width, canvas.height);
|
| 1342 |
+
minimapDotCoords = [];
|
| 1343 |
+
|
| 1344 |
+
let centerX = 0, centerZ = 0;
|
| 1345 |
+
if (userId && userMaps[userId] && userMaps[userId].length > 0) {
|
| 1346 |
+
const myCentroid = new THREE.Vector3(0, 0, 0);
|
| 1347 |
+
userMaps[userId].forEach(origin => myCentroid.add(origin));
|
| 1348 |
+
myCentroid.divideScalar(userMaps[userId].length);
|
| 1349 |
+
centerX = myCentroid.x;
|
| 1350 |
+
centerZ = myCentroid.z;
|
| 1351 |
+
}
|
| 1352 |
+
|
| 1353 |
+
const uids = Object.keys(userMaps);
|
| 1354 |
+
const profilePromises = uids.map(uid => getProfile(uid));
|
| 1355 |
+
const profiles = await Promise.all(profilePromises);
|
| 1356 |
+
let myDotData = null;
|
| 1357 |
+
|
| 1358 |
+
for (let i = 0; i < uids.length; i++) {
|
| 1359 |
+
const uid = uids[i];
|
| 1360 |
+
const profile = profiles[i];
|
| 1361 |
+
const username = profile ? profile.username : `Usuario ${uid.substring(0,4)}`;
|
| 1362 |
+
const userMapsList = userMaps[uid] || [];
|
| 1363 |
+
const numSatellites = userMapsList.length;
|
| 1364 |
+
const userCentroid = new THREE.Vector3(0,0,0);
|
| 1365 |
+
if (numSatellites > 0) {
|
| 1366 |
+
userMapsList.forEach(origin => userCentroid.add(origin));
|
| 1367 |
+
userCentroid.divideScalar(numSatellites);
|
| 1368 |
+
} else {
|
| 1369 |
+
console.warn(`Usuario ${uid} no tiene orígenes de mapa, usando (0,0,0)`);
|
| 1370 |
+
}
|
| 1371 |
+
const relX = (userCentroid.x - centerX) * minimapScale;
|
| 1372 |
+
const relZ = (userCentroid.z - centerZ) * minimapScale;
|
| 1373 |
+
const canvasX = canvas.width / 2 + relX;
|
| 1374 |
+
const canvasY = canvas.height / 2 + relZ;
|
| 1375 |
+
const dotData = { x: canvasX, y: canvasY, uid: uid, username: username };
|
| 1376 |
+
minimapDotCoords.push(dotData);
|
| 1377 |
+
const isMe = (uid === userId);
|
| 1378 |
+
const mainColor = isMe ? '#fde047' : '#06b6d4';
|
| 1379 |
+
const baseSize = MINIMAP_DOT_SIZE;
|
| 1380 |
+
const sizeBonus = (numSatellites > 1) ? Math.log(numSatellites) * 1.5 : 0;
|
| 1381 |
+
let mainSize = baseSize + sizeBonus;
|
| 1382 |
+
if (isMe) mainSize += 1;
|
| 1383 |
+
|
| 1384 |
+
if (numSatellites > 0) {
|
| 1385 |
+
const satelliteRadius = mainSize + 3;
|
| 1386 |
+
const satelliteSize = 1;
|
| 1387 |
+
for (let j = 0; j < numSatellites; j++) {
|
| 1388 |
+
const angle = (j / numSatellites) * Math.PI * 2;
|
| 1389 |
+
const satX = dotData.x + Math.cos(angle) * satelliteRadius;
|
| 1390 |
+
const satY = dotData.y + Math.sin(angle) * satelliteRadius;
|
| 1391 |
+
minimapCtx.beginPath();
|
| 1392 |
+
minimapCtx.arc(satX, satY, satelliteSize, 0, Math.PI * 2);
|
| 1393 |
+
minimapCtx.fillStyle = mainColor;
|
| 1394 |
+
minimapCtx.globalAlpha = 0.6;
|
| 1395 |
+
minimapCtx.fill();
|
| 1396 |
+
minimapCtx.globalAlpha = 1.0;
|
| 1397 |
+
}
|
| 1398 |
+
}
|
| 1399 |
+
|
| 1400 |
+
if (!isMe) drawMinimapDot(dotData, mainColor, mainSize);
|
| 1401 |
+
else myDotData = { dot: dotData, color: mainColor, size: mainSize };
|
| 1402 |
+
}
|
| 1403 |
+
|
| 1404 |
+
if (myDotData) drawMinimapDot(myDotData.dot, myDotData.color, myDotData.size);
|
| 1405 |
+
}
|
| 1406 |
+
|
| 1407 |
+
function onMinimapClick(event) {
|
| 1408 |
+
if (!minimapCtx) return;
|
| 1409 |
+
const canvas = minimapCtx.canvas;
|
| 1410 |
+
const rect = canvas.getBoundingClientRect();
|
| 1411 |
+
const x = event.clientX - rect.left;
|
| 1412 |
+
const y = event.clientY - rect.top;
|
| 1413 |
+
let clickedUser = null;
|
| 1414 |
+
let minDistance = 10;
|
| 1415 |
+
for (let i = minimapDotCoords.length - 1; i >= 0; i--) {
|
| 1416 |
+
const dot = minimapDotCoords[i];
|
| 1417 |
+
const dx = x - dot.x;
|
| 1418 |
+
const dy = y - dot.y;
|
| 1419 |
+
const distance = Math.sqrt(dx * dx + dy * dy);
|
| 1420 |
+
if (distance < minDistance) {
|
| 1421 |
+
minDistance = distance;
|
| 1422 |
+
clickedUser = dot.uid;
|
| 1423 |
+
}
|
| 1424 |
+
}
|
| 1425 |
+
if (clickedUser) {
|
| 1426 |
+
console.log("Clic en minimapa sobre usuario:", clickedUser);
|
| 1427 |
+
teleportToUser(clickedUser);
|
| 1428 |
+
}
|
| 1429 |
+
}
|
| 1430 |
+
|
| 1431 |
+
function onWindowResize() {
|
| 1432 |
+
camera.aspect = window.innerWidth / window.innerHeight;
|
| 1433 |
+
camera.updateProjectionMatrix();
|
| 1434 |
+
renderer.setSize(window.innerWidth, window.innerHeight);
|
| 1435 |
+
}
|
| 1436 |
+
|
| 1437 |
+
function onPointerMove(event) {
|
| 1438 |
+
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
|
| 1439 |
+
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
|
| 1440 |
+
tooltip.style.left = `${event.clientX + 15}px`;
|
| 1441 |
+
tooltip.style.top = `${event.clientY}px`;
|
| 1442 |
+
}
|
| 1443 |
+
|
| 1444 |
+
function updateRaycaster() {
|
| 1445 |
+
raycaster.setFromCamera(mouse, camera);
|
| 1446 |
+
const objectsToIntersect = hashtagGroup.children.filter(o => o.isMesh);
|
| 1447 |
+
const intersects = raycaster.intersectObjects(objectsToIntersect, false);
|
| 1448 |
+
|
| 1449 |
+
if (intersects.length > 0) {
|
| 1450 |
+
let targetObject = intersects[0].object;
|
| 1451 |
+
if (targetObject.userData.isText && targetObject.parent) targetObject = targetObject.parent;
|
| 1452 |
+
if (intersected.object !== targetObject) {
|
| 1453 |
+
intersected.object = targetObject;
|
| 1454 |
+
const data = intersected.object.userData;
|
| 1455 |
+
tooltip.style.display = 'block';
|
| 1456 |
+
let tooltipText = `<strong>${data.hashtag}</strong>`;
|
| 1457 |
+
if (data.level !== undefined) tooltipText += `<br>Nivel: ${data.level}`;
|
| 1458 |
+
else if (data.isPlaceholder) tooltipText = `<strong>Galaxia de ${data.hashtag}</strong>`;
|
| 1459 |
+
tooltip.innerHTML = tooltipText;
|
| 1460 |
+
}
|
| 1461 |
+
} else {
|
| 1462 |
+
if (intersected.object) tooltip.style.display = 'none';
|
| 1463 |
+
intersected.object = null;
|
| 1464 |
+
}
|
| 1465 |
+
}
|
| 1466 |
+
|
| 1467 |
+
function stringToHslColor(str) {
|
| 1468 |
+
let hash = 0;
|
| 1469 |
+
for (let i = 0; i < str.length; i++) hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
| 1470 |
+
const h = Math.abs(hash % 360);
|
| 1471 |
+
return { color: `hsl(${h}, 80%, 60%)`, h: h };
|
| 1472 |
+
}
|
| 1473 |
+
</script>
|
| 1474 |
+
</body>
|
| 1475 |
+
</html>
|
netlify.toml
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[build]
|
| 2 |
+
publish = "."
|
| 3 |
+
command = ""
|
| 4 |
+
functions = "netlify/functions"
|
| 5 |
+
|
| 6 |
+
[[redirects]]
|
| 7 |
+
from = "/*"
|
| 8 |
+
to = "/index.html"
|
| 9 |
+
status = 200
|
styles.css
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
body {
|
| 2 |
+
font-family 'Orbitron', sans-serif;
|
| 3 |
+
background-color #111827;
|
| 4 |
+
color white;
|
| 5 |
+
}
|
| 6 |
+
|
| 7 |
+
button {
|
| 8 |
+
transition all 0.2s ease-in-out;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
buttonhover {
|
| 12 |
+
transform scale(1.03);
|
| 13 |
+
}
|