Incluye: backend Node.js/Express, visualización 3D (Three.js/3d-force-graph), scrapers Wikipedia/noticias/imágenes, analizador Qwen3-VL, pipeline maestro con systemd timer, fixes de seguridad (NoSQL injection, XSS, ReDoS, port binding) y documentación técnica completa en docs/. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
231 lines
7.5 KiB
Markdown
231 lines
7.5 KiB
Markdown
# Visualización JavaScript — Contexto técnico FLUJOS
|
|
**Fecha:** 2026-04-21
|
|
**Directorio:** `FLUJOS/VISUALIZACION/public/`
|
|
**Backend:** `FLUJOS/BACK_BACK/FLUJOS_APP.js`
|
|
|
|
---
|
|
|
|
## Arquitectura general
|
|
|
|
```
|
|
theflows.net (nginx)
|
|
├── / → archivos estáticos desde VISUALIZACION/public/
|
|
└── /api/ → proxy a Node.js :3000
|
|
└── GET /api/data?tema=...&nodos=...&complejidad=...
|
|
└── MongoDB FLUJOS_DATOS
|
|
```
|
|
|
|
El frontend es **vanilla JS sin framework**, todo en ficheros `.js` planos. Cada vista (`glob-war`, `int-sec`, `climate`, etc.) tiene su propio script de grafo 3D.
|
|
|
|
---
|
|
|
|
## Dependencias externas (CDN)
|
|
|
|
```html
|
|
<!-- D3.js v6 — simulación de fuerzas -->
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/6.7.0/d3.min.js"></script>
|
|
|
|
<!-- Three.js r168 — renderer WebGL, Sprite, TextureLoader -->
|
|
<script src="https://unpkg.com/three@0.168/build/three.min.js"></script>
|
|
|
|
<!-- 3d-force-graph — wrapper de Three.js para grafos de fuerza 3D -->
|
|
<script src="https://unpkg.com/3d-force-graph"></script>
|
|
```
|
|
|
|
Three.js debe cargarse ANTES de 3d-force-graph o `THREE` no estará disponible globalmente cuando el grafo intente usarlo para los Sprite.
|
|
|
|
---
|
|
|
|
## API — Endpoint único
|
|
|
|
```
|
|
GET /api/data
|
|
```
|
|
|
|
Parámetros de query:
|
|
|
|
| Param | Tipo | Descripción |
|
|
|---|---|---|
|
|
| `tema` | string (obligatorio) | Tema principal de la consulta |
|
|
| `subtematica` | string | Filtro por subtema |
|
|
| `palabraClave` | string | Búsqueda `$regex` en campo `texto` |
|
|
| `fechaInicio` | `YYYY-MM-DD` | Rango inicio |
|
|
| `fechaFin` | `YYYY-MM-DD` | Rango fin |
|
|
| `nodos` | int (máx. 500) | Límite de nodos de texto |
|
|
| `complejidad` | float | Similitud mínima (%) para los enlaces |
|
|
|
|
**Respuesta:**
|
|
```json
|
|
{
|
|
"nodes": [
|
|
{ "id": "archivo.txt", "group": "subtema", "tema": "...", "content": "...", "fecha": "...", "type": "texto" },
|
|
{ "id": "imagen.jpg", "group": "subtema", "tema": "...", "content": "...", "fecha": "...", "type": "imagen", "image_url": "/wiki-images/tema/img.jpg", "label": "subtema" }
|
|
],
|
|
"links": [
|
|
{ "source": "archivo_A.txt", "target": "archivo_B.txt", "value": 23.45 }
|
|
]
|
|
}
|
|
```
|
|
|
|
El backend limita imágenes a `floor(nodosLimit / 3)` para no saturar el grafo.
|
|
Prefiere `imagenes` (analizadas con Qwen) sobre `imagenes_wiki` (fallback scrapeado).
|
|
|
|
---
|
|
|
|
## Estructura de un script de grafo (patrón base)
|
|
|
|
Todos los scripts (`output_glob_war.js`, `output_int_sec.js`, etc.) siguen el mismo patrón:
|
|
|
|
```javascript
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
const elem = document.getElementById('contenedorContainer');
|
|
|
|
// 1. Inicializar grafo
|
|
const graph = ForceGraph3D()(elem)
|
|
.backgroundColor('#000000')
|
|
.nodeLabel(node => node.type === 'imagen' ? (node.label || node.id) : node.id)
|
|
.nodeAutoColorBy('group')
|
|
.nodeVal(node => node.type === 'imagen' ? 0 : 2) // 0 = sin esfera para imágenes
|
|
.linkColor(() => 'rgba(0,255,80,0.4)')
|
|
.onNodeClick(node => showNodeContent(node.content))
|
|
.onNodeHover(node => { elem.style.cursor = node ? 'pointer' : null; })
|
|
.nodeThreeObject(node => { /* Sprite para imágenes */ })
|
|
.nodeThreeObjectExtend(false)
|
|
.forceEngine('d3')
|
|
.d3Force('charge', d3.forceManyBody().strength(-10))
|
|
.d3Force('link', d3.forceLink().distance(30).strength(1));
|
|
|
|
// 2. Fetch de datos
|
|
async function getData(paramsObj = {}) { ... }
|
|
|
|
// 3. Renderizar
|
|
async function fetchAndRender() { ... }
|
|
|
|
// 4. Evento del formulario
|
|
document.getElementById('paramForm').addEventListener('submit', e => {
|
|
e.preventDefault();
|
|
fetchAndRender();
|
|
});
|
|
|
|
// 5. Carga inicial
|
|
fetchAndRender();
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## Nodos de imagen — THREE.Sprite
|
|
|
|
Los nodos de tipo `imagen` usan un Sprite de Three.js en lugar de la esfera por defecto:
|
|
|
|
```javascript
|
|
const textureCache = {};
|
|
|
|
function getTexture(url) {
|
|
if (!textureCache[url]) {
|
|
textureCache[url] = new THREE.TextureLoader().load(url);
|
|
}
|
|
return textureCache[url];
|
|
}
|
|
|
|
// En nodeThreeObject:
|
|
.nodeThreeObject(node => {
|
|
if (node.type !== 'imagen' || !node.image_url) return null;
|
|
|
|
const texture = getTexture(node.image_url);
|
|
texture.colorSpace = THREE.SRGBColorSpace; // corrección de color
|
|
|
|
const material = new THREE.SpriteMaterial({ map: texture, depthWrite: false });
|
|
const sprite = new THREE.Sprite(material);
|
|
sprite.scale.set(14, 9, 1); // proporción 14:9 ≈ 16:9
|
|
return sprite;
|
|
})
|
|
.nodeThreeObjectExtend(false) // false = reemplaza la esfera, no la extiende
|
|
```
|
|
|
|
`getTexture` cachea las texturas para no recargar la misma imagen cada vez que el grafo se actualiza.
|
|
|
|
`depthWrite: false` evita artefactos de z-fighting entre sprites que se superponen.
|
|
|
|
---
|
|
|
|
## Archivos por vista
|
|
|
|
| Vista | HTML | Script |
|
|
|---|---|---|
|
|
| Index / Home | `index.html` | ninguno (estático) |
|
|
| Glob-War | `glob-war.html` | `output_glob_war_pruebas.js` |
|
|
| Int-Sec | `int-sec.html` | `output_int_sec.js` |
|
|
| Climate | `climate.html` | `climate.js` / `output_climate_pruebas.js` |
|
|
| Eco-Corp | `eco-corp.html` | `output_eco_corp_pruebas.js` |
|
|
| Popl-Up | `popl-up.html` | `output_popl_up_pruebas.js` / `output_popl_up.js` |
|
|
|
|
Los ficheros `*_pruebas.js` son la versión en desarrollo activo (son los referenciados en los HTML). Los sin `_pruebas` son versiones anteriores o de referencia.
|
|
|
|
---
|
|
|
|
## Formulario de parámetros (sidebar)
|
|
|
|
```html
|
|
<form id="paramForm">
|
|
<input type="date" id="fecha_inicio">
|
|
<input type="date" id="fecha_fin">
|
|
<input type="number" id="nodos" value="100">
|
|
<input type="range" id="complejidad" min="1" max="40" value="20">
|
|
<input type="text" id="param1"> <!-- palabraClave -->
|
|
<input type="color" id="color1"> <!-- sin uso en backend aún -->
|
|
<input type="text" id="param2"> <!-- subtematica -->
|
|
<input type="color" id="color2"> <!-- sin uso en backend aún -->
|
|
<input type="submit" value="Aplicar">
|
|
</form>
|
|
```
|
|
|
|
`color1` y `color2` están en el HTML pero el backend los ignora. Están pendientes de implementar para colorear nodos por grupo.
|
|
|
|
---
|
|
|
|
## Filtrado client-side de enlaces
|
|
|
|
Además del filtro de `complejidad` en el backend (`porcentaje_similitud >= X`), el cliente aplica un segundo filtro eliminando enlaces cuyos nodos no existan en la respuesta:
|
|
|
|
```javascript
|
|
const nodeIds = new Set(data.nodes.map(n => n.id));
|
|
data.links = data.links.filter(l => nodeIds.has(l.source) && nodeIds.has(l.target));
|
|
```
|
|
|
|
Esto previene errores de 3d-force-graph cuando llegan enlaces a nodos que no se devolvieron por el límite.
|
|
|
|
---
|
|
|
|
## Backend Node.js — FLUJOS_APP.js
|
|
|
|
**Archivo:** `FLUJOS/BACK_BACK/FLUJOS_APP.js`
|
|
**Puerto:** 3000 (configurado en `.env`)
|
|
**Proceso:** gestionado manualmente (falta configurar como systemd service)
|
|
|
|
Rutas:
|
|
```
|
|
GET / → glob-war.html (redirige a index.html)
|
|
GET /api/data → endpoint de datos
|
|
GET /wiki-images/* → estático desde IMAGENES/output/wiki_images/
|
|
GET /* → archivos estáticos desde VISUALIZACION/public/
|
|
```
|
|
|
|
La CSP actual (Helmet) permite `unsafe-inline` en scripts — pendiente de corregir (ver SEGURIDAD.md).
|
|
|
|
---
|
|
|
|
## Imágenes wiki — ruta física a URL
|
|
|
|
```
|
|
Disco: /var/www/theflows.net/flujos/FLUJOS_DATOS/IMAGENES/output/wiki_images/{tema_slug}/{archivo}
|
|
Express: app.use('/wiki-images', express.static('.../wiki_images'))
|
|
URL: https://theflows.net/wiki-images/{tema_slug}/{archivo}
|
|
```
|
|
|
|
El backend construye `image_url` en el API:
|
|
```javascript
|
|
const WIKI_IMAGES_BASE = '/var/www/theflows.net/flujos/FLUJOS_DATOS/IMAGENES/output/wiki_images/';
|
|
const relativePath = result.image_path.replace(WIKI_IMAGES_BASE, '');
|
|
image_url = `/wiki-images/${relativePath}`;
|
|
```
|