feat: pipeline de imágenes + demos de visualización 3D
- BACK_BACK/IMAGENES/: nueva pipeline para análisis de imágenes con VLM - image_analyzer.py: imagen → keywords via ollama - image_comparator.py: TF-IDF similitud keywords vs corpus - mongo_helper.py: CRUD colecciones imagenes y comparaciones - pipeline_pruebas.py: script end-to-end con CLI - requirements_imagenes.txt - BACK_BACK/FLUJOS_APP_PRUEBAS.js: servidor Express ligero (port 3001) sin MongoDB para testear los demos de visualización - VISUALIZACION/public/demos/: tres demos de 3d-force-graph - demo_text_nodes.html: nodos como texto (three-spritetext) - demo_img_nodes.html: nodos como imágenes (THREE.Sprite) - demo_mixed_nodes.html: cards HTML en 3D (CSS2DRenderer) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
013fe673f3
commit
b992e25f8f
9 changed files with 1797 additions and 0 deletions
97
BACK_BACK/FLUJOS_APP_PRUEBAS.js
Normal file
97
BACK_BACK/FLUJOS_APP_PRUEBAS.js
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
// FLUJOS_APP_PRUEBAS.js
|
||||||
|
// Servidor ligero para probar los demos de visualización SIN necesitar MongoDB.
|
||||||
|
// No modifica ni interfiere con FLUJOS_APP.js.
|
||||||
|
// Arrancarlo: node FLUJOS_APP_PRUEBAS.js
|
||||||
|
// URL: http://localhost:3001/demos/demo_text_nodes.html
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const path = require('path');
|
||||||
|
const helmet = require('helmet');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const PORT = 3001;
|
||||||
|
|
||||||
|
// CSP ampliada para permitir esm.sh (demos usan ES modules desde CDN)
|
||||||
|
app.use(
|
||||||
|
helmet.contentSecurityPolicy({
|
||||||
|
directives: {
|
||||||
|
defaultSrc: ["'self'"],
|
||||||
|
scriptSrc: [
|
||||||
|
"'self'",
|
||||||
|
"'unsafe-inline'",
|
||||||
|
"'unsafe-eval'",
|
||||||
|
'https://unpkg.com',
|
||||||
|
'https://cdnjs.cloudflare.com',
|
||||||
|
'https://fonts.googleapis.com',
|
||||||
|
'https://esm.sh',
|
||||||
|
],
|
||||||
|
scriptSrcElem: [
|
||||||
|
"'self'",
|
||||||
|
"'unsafe-inline'",
|
||||||
|
'https://unpkg.com',
|
||||||
|
'https://cdnjs.cloudflare.com',
|
||||||
|
'https://esm.sh',
|
||||||
|
],
|
||||||
|
styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'],
|
||||||
|
fontSrc: ["'self'", 'https://fonts.gstatic.com', 'https://fonts.googleapis.com'],
|
||||||
|
imgSrc: ["'self'", 'data:', 'blob:'],
|
||||||
|
connectSrc: ["'self'", 'https://esm.sh', 'ws://localhost:3001'],
|
||||||
|
workerSrc: ["'self'", 'blob:'],
|
||||||
|
scriptSrcAttr: ["'unsafe-inline'"],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Ruta raíz → index de demos (debe ir ANTES del middleware estático)
|
||||||
|
app.get('/', (req, res) => {
|
||||||
|
res.send(`
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>FLUJOS — Demos</title>
|
||||||
|
<style>
|
||||||
|
body { background:#000; color:#39ff14; font-family:'Courier New',monospace; padding:40px; }
|
||||||
|
h1 { font-size:1.4em; letter-spacing:4px; margin-bottom:8px; text-shadow:0 0 10px #39ff14; }
|
||||||
|
p { font-size:0.75em; color:#555; margin-bottom:30px; }
|
||||||
|
ul { list-style:none; padding:0; }
|
||||||
|
li { margin-bottom:12px; }
|
||||||
|
a { color:#39ff14; text-decoration:none; font-size:0.9em; letter-spacing:1px;
|
||||||
|
border:1px solid #222; padding:8px 16px; display:inline-block;
|
||||||
|
transition:border-color 0.2s, box-shadow 0.2s; }
|
||||||
|
a:hover { border-color:#39ff14; box-shadow:0 0 8px #39ff14; }
|
||||||
|
.tag { font-size:0.65em; color:#555; margin-left:10px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>FLUJOS · DEMOS</h1>
|
||||||
|
<p>Servidor de pruebas — sin MongoDB · puerto 3001</p>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<a href="/demos/demo_text_nodes.html">demo_text_nodes</a>
|
||||||
|
<span class="tag">three-spritetext · texto como nodo</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/demos/demo_img_nodes.html">demo_img_nodes</a>
|
||||||
|
<span class="tag">THREE.Sprite · imágenes como nodo</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/demos/demo_mixed_nodes.html">demo_mixed_nodes</a>
|
||||||
|
<span class="tag">CSS2DRenderer · cards HTML en 3D</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Archivos estáticos (después de la ruta raíz)
|
||||||
|
app.use(express.static(path.join(__dirname, '../VISUALIZACION/public')));
|
||||||
|
|
||||||
|
app.listen(PORT, '0.0.0.0', () => {
|
||||||
|
console.log(`\n FLUJOS PRUEBAS corriendo en http://localhost:${PORT}`);
|
||||||
|
console.log(` Demos disponibles:`);
|
||||||
|
console.log(` http://localhost:${PORT}/demos/demo_text_nodes.html`);
|
||||||
|
console.log(` http://localhost:${PORT}/demos/demo_img_nodes.html`);
|
||||||
|
console.log(` http://localhost:${PORT}/demos/demo_mixed_nodes.html\n`);
|
||||||
|
});
|
||||||
197
BACK_BACK/IMAGENES/image_analyzer.py
Normal file
197
BACK_BACK/IMAGENES/image_analyzer.py
Normal file
|
|
@ -0,0 +1,197 @@
|
||||||
|
"""
|
||||||
|
image_analyzer.py
|
||||||
|
-----------------
|
||||||
|
Analiza imágenes usando un VLM via ollama y extrae:
|
||||||
|
- tema, subtema
|
||||||
|
- keywords (lista de palabras clave)
|
||||||
|
- descripción
|
||||||
|
- entidades (personas, organizaciones, lugares)
|
||||||
|
|
||||||
|
Modelos recomendados en ollama para visión:
|
||||||
|
- llava:13b (bueno, ligero)
|
||||||
|
- qwen2-vl:7b (muy bueno para keywords)
|
||||||
|
- minicpm-v:8b (rápido y preciso)
|
||||||
|
- llava-llama3:8b (balance velocidad/calidad)
|
||||||
|
|
||||||
|
Para usar Qwen3.5 GGUF (texto), primero genera caption con un VLM
|
||||||
|
y luego pasa el texto a Qwen para enriquecer keywords — ver pipeline_pruebas.py
|
||||||
|
|
||||||
|
Uso:
|
||||||
|
analyzer = ImageAnalyzer(model="qwen2-vl:7b")
|
||||||
|
result = analyzer.analyze("foto.jpg")
|
||||||
|
results = analyzer.analyze_folder("./mis_imagenes/")
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
# ── Configuración ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://localhost:11434")
|
||||||
|
DEFAULT_MODEL = os.getenv("VISION_MODEL", "qwen2-vl:7b")
|
||||||
|
|
||||||
|
SUPPORTED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"}
|
||||||
|
|
||||||
|
KEYWORD_PROMPT = """Analiza esta imagen en detalle.
|
||||||
|
Devuelve ÚNICAMENTE un objeto JSON válido con esta estructura exacta, sin texto adicional:
|
||||||
|
|
||||||
|
{
|
||||||
|
"tema": "tema principal de la imagen (1-3 palabras en español)",
|
||||||
|
"subtema": "subtema específico (1-4 palabras en español)",
|
||||||
|
"keywords": ["palabra1", "palabra2", "palabra3"],
|
||||||
|
"descripcion": "descripción breve y objetiva de lo que muestra la imagen (1-2 frases)",
|
||||||
|
"entidades": ["nombre_propio1", "organizacion1", "lugar1"],
|
||||||
|
"idioma_detectado": "es/en/fr/..."
|
||||||
|
}
|
||||||
|
|
||||||
|
Requisitos:
|
||||||
|
- keywords: entre 8 y 15 palabras clave relevantes, en minúsculas
|
||||||
|
- entidades: solo si son claramente visibles/identificables, puede estar vacío []
|
||||||
|
- todo el contenido en español salvo entidades propias
|
||||||
|
- SOLO el JSON, sin markdown ni explicaciones"""
|
||||||
|
|
||||||
|
|
||||||
|
# ── Clase principal ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class ImageAnalyzer:
|
||||||
|
def __init__(self, model: str = DEFAULT_MODEL, ollama_url: str = OLLAMA_URL):
|
||||||
|
self.model = model
|
||||||
|
self.ollama_url = ollama_url.rstrip("/")
|
||||||
|
|
||||||
|
# ── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def encode_image(image_path: str) -> str:
|
||||||
|
with open(image_path, "rb") as f:
|
||||||
|
return base64.b64encode(f.read()).decode("utf-8")
|
||||||
|
|
||||||
|
def _check_ollama(self) -> bool:
|
||||||
|
try:
|
||||||
|
r = requests.get(f"{self.ollama_url}/api/tags", timeout=5)
|
||||||
|
return r.status_code == 200
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _parse_json_response(self, raw: str) -> dict:
|
||||||
|
"""Extrae JSON del response aunque venga con texto alrededor."""
|
||||||
|
raw = raw.strip()
|
||||||
|
# Buscar bloque JSON entre llaves
|
||||||
|
match = re.search(r'\{[\s\S]*\}', raw)
|
||||||
|
if match:
|
||||||
|
return json.loads(match.group())
|
||||||
|
raise ValueError(f"No se encontró JSON válido en el response:\n{raw[:300]}")
|
||||||
|
|
||||||
|
# ── Análisis de una imagen ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def analyze(self, image_path: str, extra_context: str = "") -> dict:
|
||||||
|
"""
|
||||||
|
Analiza una imagen y devuelve dict con keywords y metadata.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
image_path: ruta a la imagen
|
||||||
|
extra_context: contexto adicional para el prompt (ej: "esta imagen es de un periódico sobre guerras")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict con: archivo, tema, subtema, texto, keywords, entidades,
|
||||||
|
source_type, fecha, image_path
|
||||||
|
"""
|
||||||
|
if not os.path.exists(image_path):
|
||||||
|
raise FileNotFoundError(f"Imagen no encontrada: {image_path}")
|
||||||
|
|
||||||
|
prompt = KEYWORD_PROMPT
|
||||||
|
if extra_context:
|
||||||
|
prompt = f"Contexto adicional: {extra_context}\n\n" + prompt
|
||||||
|
|
||||||
|
img_b64 = self.encode_image(image_path)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"model": self.model,
|
||||||
|
"prompt": prompt,
|
||||||
|
"images": [img_b64],
|
||||||
|
"stream": False,
|
||||||
|
"format": "json",
|
||||||
|
"options": {
|
||||||
|
"temperature": 0.1, # baja temperatura = más determinista
|
||||||
|
"num_predict": 512,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
print(f" → Enviando a ollama ({self.model}): {Path(image_path).name}")
|
||||||
|
response = requests.post(
|
||||||
|
f"{self.ollama_url}/api/generate",
|
||||||
|
json=payload,
|
||||||
|
timeout=180
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
raw_response = response.json().get("response", "")
|
||||||
|
parsed = self._parse_json_response(raw_response)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"archivo": Path(image_path).name,
|
||||||
|
"image_path": str(Path(image_path).resolve()),
|
||||||
|
"tema": parsed.get("tema", "sin_clasificar").lower(),
|
||||||
|
"subtema": parsed.get("subtema", "").lower(),
|
||||||
|
"texto": parsed.get("descripcion", ""),
|
||||||
|
"keywords": [k.lower().strip() for k in parsed.get("keywords", [])],
|
||||||
|
"entidades": parsed.get("entidades", []),
|
||||||
|
"idioma": parsed.get("idioma_detectado", "es"),
|
||||||
|
"source_type": "imagen",
|
||||||
|
"fecha": datetime.now().strftime("%Y-%m-%d"),
|
||||||
|
"modelo_usado": self.model,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Análisis de una carpeta ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def analyze_folder(self, folder_path: str, extra_context: str = "") -> list[dict]:
|
||||||
|
"""
|
||||||
|
Analiza todas las imágenes de una carpeta.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Lista de resultados, incluye errores como dicts con campo 'error'
|
||||||
|
"""
|
||||||
|
folder = Path(folder_path)
|
||||||
|
if not folder.exists():
|
||||||
|
raise FileNotFoundError(f"Carpeta no encontrada: {folder_path}")
|
||||||
|
|
||||||
|
images = [p for p in folder.iterdir() if p.suffix.lower() in SUPPORTED_EXTENSIONS]
|
||||||
|
print(f"\n[ImageAnalyzer] {len(images)} imágenes encontradas en {folder_path}")
|
||||||
|
|
||||||
|
if not self._check_ollama():
|
||||||
|
print(f" ⚠ ollama no responde en {self.ollama_url}")
|
||||||
|
print(" Instala ollama: https://ollama.ai")
|
||||||
|
print(f" Luego: ollama pull {self.model}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for i, img_path in enumerate(images, 1):
|
||||||
|
print(f" [{i}/{len(images)}] {img_path.name}")
|
||||||
|
try:
|
||||||
|
result = self.analyze(str(img_path), extra_context)
|
||||||
|
results.append(result)
|
||||||
|
print(f" tema={result['tema']} | keywords={result['keywords'][:4]}...")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ERROR: {e}")
|
||||||
|
results.append({
|
||||||
|
"archivo": img_path.name,
|
||||||
|
"error": str(e),
|
||||||
|
"source_type": "imagen",
|
||||||
|
"fecha": datetime.now().strftime("%Y-%m-%d"),
|
||||||
|
})
|
||||||
|
|
||||||
|
print(f"\n[ImageAnalyzer] Completado: {len([r for r in results if 'error' not in r])}/{len(images)} OK\n")
|
||||||
|
return results
|
||||||
|
|
||||||
|
# ── Guardar resultados a JSON ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def save_json(results: list[dict], output_path: str):
|
||||||
|
with open(output_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(results, f, ensure_ascii=False, indent=2)
|
||||||
|
print(f"[ImageAnalyzer] Guardado: {output_path} ({len(results)} registros)")
|
||||||
158
BACK_BACK/IMAGENES/image_comparator.py
Normal file
158
BACK_BACK/IMAGENES/image_comparator.py
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
"""
|
||||||
|
image_comparator.py
|
||||||
|
-------------------
|
||||||
|
Compara keywords de imágenes con documentos de texto (noticias, wikipedia, torrents)
|
||||||
|
usando similitud TF-IDF coseno.
|
||||||
|
|
||||||
|
Produce documentos para la colección 'comparaciones' de MongoDB,
|
||||||
|
con la misma estructura que los comparaciones texto-texto ya existentes:
|
||||||
|
{ noticia1, noticia2, porcentaje_similitud }
|
||||||
|
|
||||||
|
Ampliado con campos opcionales: source1_type, source2_type (para saber qué se comparó).
|
||||||
|
|
||||||
|
Uso:
|
||||||
|
comp = ImageComparator()
|
||||||
|
resultados = comp.compare_image_vs_collection(imagen_doc, lista_docs_texto)
|
||||||
|
top = comp.top_n(resultados, n=10)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sklearn.feature_extraction.text import TfidfVectorizer
|
||||||
|
from sklearn.metrics.pairwise import cosine_similarity
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
|
# ── Clase principal ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class ImageComparator:
|
||||||
|
|
||||||
|
def __init__(self, threshold: float = 5.0):
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
threshold: porcentaje mínimo de similitud para incluir en resultados (0-100)
|
||||||
|
"""
|
||||||
|
self.threshold = threshold
|
||||||
|
self.vectorizer = TfidfVectorizer(
|
||||||
|
analyzer="word",
|
||||||
|
ngram_range=(1, 2),
|
||||||
|
min_df=1,
|
||||||
|
strip_accents="unicode",
|
||||||
|
lowercase=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Conversión de documentos a texto ──────────────────────────────────────
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def doc_to_text(doc: dict) -> str:
|
||||||
|
"""
|
||||||
|
Concatena los campos relevantes de un documento en un string para TF-IDF.
|
||||||
|
Compatible con estructura de noticias/wikipedia/torrents/imagenes.
|
||||||
|
"""
|
||||||
|
parts = []
|
||||||
|
# keywords de imágenes (lista) — los más informativos, se repiten para darles peso
|
||||||
|
if doc.get("keywords"):
|
||||||
|
kws = doc["keywords"] if isinstance(doc["keywords"], list) else []
|
||||||
|
parts.extend(kws * 3) # peso extra a keywords
|
||||||
|
# campos de texto estándar
|
||||||
|
for field in ("tema", "subtema", "texto"):
|
||||||
|
val = doc.get(field)
|
||||||
|
if val and isinstance(val, str):
|
||||||
|
parts.append(val)
|
||||||
|
# entidades
|
||||||
|
if doc.get("entidades"):
|
||||||
|
parts.extend(doc["entidades"])
|
||||||
|
return " ".join(parts)
|
||||||
|
|
||||||
|
# ── Comparación imagen vs lista de documentos ─────────────────────────────
|
||||||
|
|
||||||
|
def compare_image_vs_collection(
|
||||||
|
self,
|
||||||
|
image_doc: dict,
|
||||||
|
text_docs: list[dict],
|
||||||
|
) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Compara una imagen contra una lista de documentos de texto.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Lista de dicts ordenados por porcentaje_similitud desc, filtrados por threshold.
|
||||||
|
"""
|
||||||
|
if not text_docs:
|
||||||
|
return []
|
||||||
|
|
||||||
|
all_docs = [image_doc] + text_docs
|
||||||
|
texts = [self.doc_to_text(d) for d in all_docs]
|
||||||
|
|
||||||
|
try:
|
||||||
|
matrix = self.vectorizer.fit_transform(texts)
|
||||||
|
except ValueError:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Similitud de imagen (índice 0) contra todos los demás
|
||||||
|
sims = cosine_similarity(matrix[0:1], matrix[1:]).flatten()
|
||||||
|
|
||||||
|
comparaciones = []
|
||||||
|
for doc, sim in zip(text_docs, sims):
|
||||||
|
pct = round(float(sim) * 100, 2)
|
||||||
|
if pct < self.threshold:
|
||||||
|
continue
|
||||||
|
|
||||||
|
comparaciones.append({
|
||||||
|
# Campos compatibles con colección 'comparaciones' existente
|
||||||
|
"noticia1": image_doc.get("archivo", "imagen"),
|
||||||
|
"noticia2": doc.get("archivo", str(doc.get("_id", ""))),
|
||||||
|
"porcentaje_similitud": pct,
|
||||||
|
# Campos extendidos (opcionales — no rompen queries existentes)
|
||||||
|
"source1_type": "imagen",
|
||||||
|
"source2_type": doc.get("source_type", "texto"),
|
||||||
|
"tema_imagen": image_doc.get("tema", ""),
|
||||||
|
"tema_doc": doc.get("tema", ""),
|
||||||
|
})
|
||||||
|
|
||||||
|
comparaciones.sort(key=lambda x: x["porcentaje_similitud"], reverse=True)
|
||||||
|
return comparaciones
|
||||||
|
|
||||||
|
# ── Comparación muchas imágenes vs colección ───────────────────────────────
|
||||||
|
|
||||||
|
def compare_batch(
|
||||||
|
self,
|
||||||
|
image_docs: list[dict],
|
||||||
|
text_docs: list[dict],
|
||||||
|
) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Compara múltiples imágenes contra una colección de documentos.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Todos los pares con similitud >= threshold, sin duplicados.
|
||||||
|
"""
|
||||||
|
all_comparaciones = []
|
||||||
|
seen = set()
|
||||||
|
|
||||||
|
for img_doc in image_docs:
|
||||||
|
results = self.compare_image_vs_collection(img_doc, text_docs)
|
||||||
|
for r in results:
|
||||||
|
key = (r["noticia1"], r["noticia2"])
|
||||||
|
if key not in seen:
|
||||||
|
seen.add(key)
|
||||||
|
all_comparaciones.append(r)
|
||||||
|
|
||||||
|
all_comparaciones.sort(key=lambda x: x["porcentaje_similitud"], reverse=True)
|
||||||
|
return all_comparaciones
|
||||||
|
|
||||||
|
# ── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def top_n(comparaciones: list[dict], n: int = 20) -> list[dict]:
|
||||||
|
return sorted(comparaciones, key=lambda x: x["porcentaje_similitud"], reverse=True)[:n]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def stats(comparaciones: list[dict]) -> dict:
|
||||||
|
if not comparaciones:
|
||||||
|
return {"total": 0}
|
||||||
|
sims = [c["porcentaje_similitud"] for c in comparaciones]
|
||||||
|
return {
|
||||||
|
"total": len(sims),
|
||||||
|
"media": round(np.mean(sims), 2),
|
||||||
|
"max": round(max(sims), 2),
|
||||||
|
"min": round(min(sims), 2),
|
||||||
|
"sobre_50": sum(1 for s in sims if s >= 50),
|
||||||
|
"sobre_70": sum(1 for s in sims if s >= 70),
|
||||||
|
}
|
||||||
172
BACK_BACK/IMAGENES/mongo_helper.py
Normal file
172
BACK_BACK/IMAGENES/mongo_helper.py
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
"""
|
||||||
|
mongo_helper.py
|
||||||
|
---------------
|
||||||
|
Operaciones MongoDB para la colección 'imagenes' y extensión de 'comparaciones'.
|
||||||
|
Compatible con la estructura existente de FLUJOS_DATOS.
|
||||||
|
|
||||||
|
Uso:
|
||||||
|
mongo = MongoHelper()
|
||||||
|
mongo.upsert_imagenes(lista_docs)
|
||||||
|
mongo.insert_comparaciones(lista_comparaciones)
|
||||||
|
docs = mongo.get_collection_sample("noticias", limit=100)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pymongo import MongoClient, UpdateOne
|
||||||
|
from pymongo.errors import ConnectionFailure
|
||||||
|
|
||||||
|
MONGO_URL = os.getenv("MONGO_URL", "mongodb://localhost:27017")
|
||||||
|
DB_NAME = os.getenv("DB_NAME", "FLUJOS_DATOS")
|
||||||
|
|
||||||
|
|
||||||
|
class MongoHelper:
|
||||||
|
def __init__(self, mongo_url: str = MONGO_URL, db_name: str = DB_NAME):
|
||||||
|
self.mongo_url = mongo_url
|
||||||
|
self.db_name = db_name
|
||||||
|
self._client = None
|
||||||
|
self._db = None
|
||||||
|
|
||||||
|
# ── Conexión ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def connect(self):
|
||||||
|
if self._client is None:
|
||||||
|
self._client = MongoClient(self.mongo_url, serverSelectionTimeoutMS=5000)
|
||||||
|
self._client.admin.command("ping")
|
||||||
|
self._db = self._client[self.db_name]
|
||||||
|
print(f"[MongoDB] Conectado a {self.mongo_url} / {self.db_name}")
|
||||||
|
return self._db
|
||||||
|
|
||||||
|
def disconnect(self):
|
||||||
|
if self._client:
|
||||||
|
self._client.close()
|
||||||
|
self._client = None
|
||||||
|
self._db = None
|
||||||
|
|
||||||
|
def is_available(self) -> bool:
|
||||||
|
try:
|
||||||
|
self.connect()
|
||||||
|
return True
|
||||||
|
except ConnectionFailure:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# ── Colección IMAGENES ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def upsert_imagenes(self, docs: list[dict]) -> dict:
|
||||||
|
"""
|
||||||
|
Inserta o actualiza documentos en la colección 'imagenes'.
|
||||||
|
Usa 'archivo' como clave única (upsert por nombre de archivo).
|
||||||
|
|
||||||
|
Returns: {'inserted': N, 'updated': N}
|
||||||
|
"""
|
||||||
|
db = self.connect()
|
||||||
|
collection = db["imagenes"]
|
||||||
|
collection.create_index("archivo", unique=True)
|
||||||
|
|
||||||
|
ops = [
|
||||||
|
UpdateOne(
|
||||||
|
{"archivo": doc["archivo"]},
|
||||||
|
{"$set": doc},
|
||||||
|
upsert=True
|
||||||
|
)
|
||||||
|
for doc in docs if "error" not in doc
|
||||||
|
]
|
||||||
|
|
||||||
|
if not ops:
|
||||||
|
return {"inserted": 0, "updated": 0}
|
||||||
|
|
||||||
|
result = collection.bulk_write(ops)
|
||||||
|
stats = {
|
||||||
|
"inserted": result.upserted_count,
|
||||||
|
"updated": result.modified_count,
|
||||||
|
}
|
||||||
|
print(f"[MongoDB] imagenes → {stats}")
|
||||||
|
return stats
|
||||||
|
|
||||||
|
def get_imagenes(self, tema: str = None, limit: int = 500) -> list[dict]:
|
||||||
|
"""Recupera documentos de la colección 'imagenes'."""
|
||||||
|
db = self.connect()
|
||||||
|
query = {"tema": {"$regex": tema, "$options": "i"}} if tema else {}
|
||||||
|
return list(db["imagenes"].find(query, {"_id": 0}).limit(limit))
|
||||||
|
|
||||||
|
# ── Colección COMPARACIONES ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def insert_comparaciones(self, comparaciones: list[dict], replace_existing: bool = False) -> int:
|
||||||
|
"""
|
||||||
|
Inserta comparaciones imagen-texto en la colección 'comparaciones'.
|
||||||
|
Evita duplicados por (noticia1, noticia2).
|
||||||
|
|
||||||
|
Returns: número de documentos insertados
|
||||||
|
"""
|
||||||
|
db = self.connect()
|
||||||
|
collection = db["comparaciones"]
|
||||||
|
|
||||||
|
ops = []
|
||||||
|
for comp in comparaciones:
|
||||||
|
filter_q = {"noticia1": comp["noticia1"], "noticia2": comp["noticia2"]}
|
||||||
|
update_q = {"$set": comp} if replace_existing else {"$setOnInsert": comp}
|
||||||
|
ops.append(UpdateOne(filter_q, update_q, upsert=True))
|
||||||
|
|
||||||
|
if not ops:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
result = collection.bulk_write(ops)
|
||||||
|
inserted = result.upserted_count
|
||||||
|
print(f"[MongoDB] comparaciones → {inserted} nuevas, {result.modified_count} actualizadas")
|
||||||
|
return inserted
|
||||||
|
|
||||||
|
# ── Leer colecciones existentes (para comparar) ────────────────────────────
|
||||||
|
|
||||||
|
def get_collection_sample(
|
||||||
|
self,
|
||||||
|
collection_name: str,
|
||||||
|
tema: str = None,
|
||||||
|
limit: int = 200,
|
||||||
|
fields: list[str] = None,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Lee una muestra de documentos de una colección existente.
|
||||||
|
Compatible con noticias, wikipedia, torrents.
|
||||||
|
"""
|
||||||
|
db = self.connect()
|
||||||
|
query = {}
|
||||||
|
if tema:
|
||||||
|
query["$or"] = [
|
||||||
|
{"tema": {"$regex": tema, "$options": "i"}},
|
||||||
|
{"subtema": {"$regex": tema, "$options": "i"}},
|
||||||
|
{"texto": {"$regex": tema, "$options": "i"}},
|
||||||
|
]
|
||||||
|
|
||||||
|
projection = {"_id": 0}
|
||||||
|
if fields:
|
||||||
|
for f in fields:
|
||||||
|
projection[f] = 1
|
||||||
|
|
||||||
|
docs = list(db[collection_name].find(query, projection).limit(limit))
|
||||||
|
for doc in docs:
|
||||||
|
if "source_type" not in doc:
|
||||||
|
doc["source_type"] = collection_name
|
||||||
|
return docs
|
||||||
|
|
||||||
|
def get_all_text_docs(self, tema: str = None, limit_per_collection: int = 200) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Recupera documentos de noticias + wikipedia + torrents combinados.
|
||||||
|
Útil para comparar imágenes contra todo el corpus.
|
||||||
|
"""
|
||||||
|
all_docs = []
|
||||||
|
for col in ("noticias", "wikipedia", "torrents"):
|
||||||
|
try:
|
||||||
|
docs = self.get_collection_sample(col, tema=tema, limit=limit_per_collection)
|
||||||
|
all_docs.extend(docs)
|
||||||
|
print(f"[MongoDB] {col}: {len(docs)} docs cargados")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[MongoDB] WARNING: no se pudo leer '{col}': {e}")
|
||||||
|
return all_docs
|
||||||
|
|
||||||
|
# ── Info de la BD ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def collection_stats(self) -> dict:
|
||||||
|
db = self.connect()
|
||||||
|
stats = {}
|
||||||
|
for col_name in db.list_collection_names():
|
||||||
|
stats[col_name] = db[col_name].count_documents({})
|
||||||
|
return stats
|
||||||
268
BACK_BACK/IMAGENES/pipeline_pruebas.py
Normal file
268
BACK_BACK/IMAGENES/pipeline_pruebas.py
Normal file
|
|
@ -0,0 +1,268 @@
|
||||||
|
"""
|
||||||
|
pipeline_pruebas.py
|
||||||
|
-------------------
|
||||||
|
Pipeline de prueba end-to-end para análisis de imágenes.
|
||||||
|
|
||||||
|
Flujo:
|
||||||
|
1. Carga imágenes desde una carpeta (por defecto: las imágenes del proyecto)
|
||||||
|
2. Analiza cada imagen con el VLM via ollama → keywords + metadata
|
||||||
|
3. Guarda resultados en JSON local (output/)
|
||||||
|
4. Compara keywords de imágenes con documentos de texto (noticias/wiki/torrents)
|
||||||
|
- Si MongoDB disponible: lee corpus real
|
||||||
|
- Si no: usa corpus de prueba hardcodeado
|
||||||
|
5. Guarda comparaciones en JSON local
|
||||||
|
6. Opcional: sube todo a MongoDB
|
||||||
|
|
||||||
|
Ejecutar:
|
||||||
|
python pipeline_pruebas.py
|
||||||
|
python pipeline_pruebas.py --carpeta /ruta/a/imagenes --modelo qwen2-vl:7b
|
||||||
|
python pipeline_pruebas.py --solo-json # sin MongoDB
|
||||||
|
python pipeline_pruebas.py --tema "clima" # filtra corpus por tema
|
||||||
|
|
||||||
|
Requisitos previos:
|
||||||
|
pip install -r requirements_imagenes.txt
|
||||||
|
ollama pull qwen2-vl:7b (o el modelo que prefieras)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Añadir el directorio padre al path para importar desde BACK_BACK
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent))
|
||||||
|
|
||||||
|
from image_analyzer import ImageAnalyzer
|
||||||
|
from image_comparator import ImageComparator
|
||||||
|
from mongo_helper import MongoHelper
|
||||||
|
|
||||||
|
# ── Configuración por defecto ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
DEFAULT_IMAGES_FOLDER = str(
|
||||||
|
Path(__file__).parent.parent.parent / "VISUALIZACION" / "public" / "images"
|
||||||
|
)
|
||||||
|
DEFAULT_MODEL = os.getenv("VISION_MODEL", "qwen2-vl:7b")
|
||||||
|
OUTPUT_DIR = Path(__file__).parent / "output"
|
||||||
|
|
||||||
|
# ── Corpus de prueba (cuando no hay MongoDB) ───────────────────────────────────
|
||||||
|
|
||||||
|
CORPUS_PRUEBA = [
|
||||||
|
{
|
||||||
|
"archivo": "noticia_clima_001.txt",
|
||||||
|
"tema": "cambio climático",
|
||||||
|
"subtema": "emisiones co2",
|
||||||
|
"texto": "Las emisiones de dióxido de carbono alcanzan niveles récord. Los países industrializados deben reducir su huella de carbono para frenar el calentamiento global. La crisis climática afecta a millones de personas.",
|
||||||
|
"source_type": "noticias",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"archivo": "wiki_energia_renovable.txt",
|
||||||
|
"tema": "energía renovable",
|
||||||
|
"subtema": "energía solar",
|
||||||
|
"texto": "La energía solar fotovoltaica ha experimentado un crecimiento exponencial. Los paneles solares reducen la dependencia de combustibles fósiles y disminuyen las emisiones de gases de efecto invernadero.",
|
||||||
|
"source_type": "wikipedia",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"archivo": "noticia_tecnologia_001.txt",
|
||||||
|
"tema": "tecnología",
|
||||||
|
"subtema": "inteligencia artificial",
|
||||||
|
"texto": "Los modelos de lenguaje de gran tamaño están transformando la industria tecnológica. Empresas como Google, Meta y OpenAI compiten por el liderazgo en inteligencia artificial generativa.",
|
||||||
|
"source_type": "noticias",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"archivo": "wiki_desinformacion.txt",
|
||||||
|
"tema": "desinformación",
|
||||||
|
"subtema": "redes sociales",
|
||||||
|
"texto": "La desinformación en redes sociales representa una amenaza para la democracia. Las fake news se propagan más rápido que las noticias verificadas. Los algoritmos de plataformas como Twitter y Facebook amplifican contenido polarizante.",
|
||||||
|
"source_type": "wikipedia",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"archivo": "noticia_geopolitica_001.txt",
|
||||||
|
"tema": "geopolítica",
|
||||||
|
"subtema": "conflictos internacionales",
|
||||||
|
"texto": "Las tensiones geopolíticas entre potencias mundiales aumentan la incertidumbre global. Los conflictos armados desplazan millones de personas y generan crisis humanitarias sin precedentes.",
|
||||||
|
"source_type": "noticias",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"archivo": "wiki_privacidad_datos.txt",
|
||||||
|
"tema": "privacidad",
|
||||||
|
"subtema": "datos personales",
|
||||||
|
"texto": "La privacidad de los datos personales es un derecho fundamental en la era digital. El RGPD europeo establece normas estrictas sobre el tratamiento de datos. La vigilancia masiva por parte de gobiernos y corporaciones amenaza las libertades individuales.",
|
||||||
|
"source_type": "wikipedia",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"archivo": "torrent_documental_medioambiente.txt",
|
||||||
|
"tema": "medioambiente",
|
||||||
|
"subtema": "biodiversidad",
|
||||||
|
"texto": "Documental sobre la pérdida de biodiversidad y el impacto de la actividad humana en los ecosistemas. La deforestación, la contaminación y el cambio climático amenazan la supervivencia de miles de especies.",
|
||||||
|
"source_type": "torrents",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"archivo": "noticia_corporaciones_001.txt",
|
||||||
|
"tema": "corporaciones",
|
||||||
|
"subtema": "paraísos fiscales",
|
||||||
|
"texto": "Las grandes corporaciones utilizan paraísos fiscales para eludir el pago de impuestos. El lobbying corporativo influye decisivamente en las políticas gubernamentales. La concentración de poder económico en pocas empresas amenaza la competencia.",
|
||||||
|
"source_type": "noticias",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Pipeline principal ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def run_pipeline(
|
||||||
|
images_folder: str,
|
||||||
|
model: str,
|
||||||
|
tema_filtro: str,
|
||||||
|
solo_json: bool,
|
||||||
|
threshold: float,
|
||||||
|
contexto: str,
|
||||||
|
):
|
||||||
|
OUTPUT_DIR.mkdir(exist_ok=True)
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print(" FLUJOS — Pipeline de Imágenes (PRUEBAS)")
|
||||||
|
print("="*60)
|
||||||
|
print(f" Carpeta: {images_folder}")
|
||||||
|
print(f" Modelo: {model}")
|
||||||
|
print(f" Umbral: {threshold}%")
|
||||||
|
print(f" Solo JSON: {solo_json}")
|
||||||
|
print("="*60 + "\n")
|
||||||
|
|
||||||
|
# ── PASO 1: Analizar imágenes ──────────────────────────────────────────────
|
||||||
|
print("[ PASO 1 ] Análisis de imágenes con VLM")
|
||||||
|
print("-"*40)
|
||||||
|
|
||||||
|
analyzer = ImageAnalyzer(model=model)
|
||||||
|
image_docs = analyzer.analyze_folder(images_folder, extra_context=contexto)
|
||||||
|
|
||||||
|
if not image_docs:
|
||||||
|
print(" ⚠ No se analizaron imágenes. Comprueba que ollama está corriendo.")
|
||||||
|
print(f" ollama serve → ollama pull {model}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Guardar resultados de análisis
|
||||||
|
json_imagenes = OUTPUT_DIR / f"imagenes_{timestamp}.json"
|
||||||
|
ImageAnalyzer.save_json(image_docs, str(json_imagenes))
|
||||||
|
|
||||||
|
# ── PASO 2: Cargar corpus de texto para comparar ───────────────────────────
|
||||||
|
print("\n[ PASO 2 ] Cargando corpus de texto")
|
||||||
|
print("-"*40)
|
||||||
|
|
||||||
|
mongo = MongoHelper()
|
||||||
|
text_docs = []
|
||||||
|
|
||||||
|
if not solo_json and mongo.is_available():
|
||||||
|
print(" MongoDB disponible — cargando corpus real")
|
||||||
|
text_docs = mongo.get_all_text_docs(tema=tema_filtro, limit_per_collection=300)
|
||||||
|
else:
|
||||||
|
if not solo_json:
|
||||||
|
print(" ⚠ MongoDB no disponible — usando corpus de prueba hardcodeado")
|
||||||
|
else:
|
||||||
|
print(" Modo solo-json — usando corpus de prueba hardcodeado")
|
||||||
|
text_docs = CORPUS_PRUEBA
|
||||||
|
|
||||||
|
print(f" Total documentos de texto: {len(text_docs)}")
|
||||||
|
|
||||||
|
# ── PASO 3: Comparar imágenes vs corpus ───────────────────────────────────
|
||||||
|
print("\n[ PASO 3 ] Comparando keywords de imágenes vs corpus")
|
||||||
|
print("-"*40)
|
||||||
|
|
||||||
|
comparador = ImageComparator(threshold=threshold)
|
||||||
|
valid_image_docs = [d for d in image_docs if "error" not in d]
|
||||||
|
comparaciones = comparador.compare_batch(valid_image_docs, text_docs)
|
||||||
|
|
||||||
|
stats = comparador.stats(comparaciones)
|
||||||
|
print(f" Comparaciones generadas: {stats.get('total', 0)}")
|
||||||
|
print(f" Similitud media: {stats.get('media', 0)}%")
|
||||||
|
print(f" Similitud máxima: {stats.get('max', 0)}%")
|
||||||
|
print(f" Con > 50%: {stats.get('sobre_50', 0)}")
|
||||||
|
print(f" Con > 70%: {stats.get('sobre_70', 0)}")
|
||||||
|
|
||||||
|
# Guardar comparaciones
|
||||||
|
json_comparaciones = OUTPUT_DIR / f"comparaciones_{timestamp}.json"
|
||||||
|
with open(json_comparaciones, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(comparaciones, f, ensure_ascii=False, indent=2)
|
||||||
|
print(f"\n Guardado: {json_comparaciones}")
|
||||||
|
|
||||||
|
# Mostrar top 10
|
||||||
|
if comparaciones:
|
||||||
|
print("\n Top 10 similitudes:")
|
||||||
|
for c in comparador.top_n(comparaciones, 10):
|
||||||
|
print(f" {c['porcentaje_similitud']:5.1f}% {c['noticia1']} ↔ {c['noticia2']}")
|
||||||
|
|
||||||
|
# ── PASO 4: Subir a MongoDB (opcional) ────────────────────────────────────
|
||||||
|
if not solo_json and mongo.is_available():
|
||||||
|
print("\n[ PASO 4 ] Subiendo a MongoDB")
|
||||||
|
print("-"*40)
|
||||||
|
|
||||||
|
mongo.upsert_imagenes(valid_image_docs)
|
||||||
|
mongo.insert_comparaciones(comparaciones)
|
||||||
|
mongo.disconnect()
|
||||||
|
|
||||||
|
print("\n ✓ Datos guardados en MongoDB")
|
||||||
|
print(f" Colección 'imagenes': {len(valid_image_docs)} documentos")
|
||||||
|
print(f" Colección 'comparaciones': {len(comparaciones)} documentos")
|
||||||
|
else:
|
||||||
|
print("\n[ PASO 4 ] Skipped (solo-json o MongoDB no disponible)")
|
||||||
|
|
||||||
|
# ── Resumen final ──────────────────────────────────────────────────────────
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print(" COMPLETADO")
|
||||||
|
print(f" Imágenes analizadas: {len(valid_image_docs)}")
|
||||||
|
print(f" Comparaciones: {len(comparaciones)}")
|
||||||
|
print(f" Output JSON:")
|
||||||
|
print(f" {json_imagenes}")
|
||||||
|
print(f" {json_comparaciones}")
|
||||||
|
print("="*60 + "\n")
|
||||||
|
|
||||||
|
|
||||||
|
# ── CLI ────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Pipeline de prueba: imágenes → keywords → comparaciones"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--carpeta",
|
||||||
|
default=DEFAULT_IMAGES_FOLDER,
|
||||||
|
help=f"Carpeta con imágenes (default: {DEFAULT_IMAGES_FOLDER})"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--modelo",
|
||||||
|
default=DEFAULT_MODEL,
|
||||||
|
help=f"Modelo ollama a usar (default: {DEFAULT_MODEL})"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--tema",
|
||||||
|
default=None,
|
||||||
|
help="Filtrar corpus MongoDB por tema (ej: 'clima', 'tecnología')"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--umbral",
|
||||||
|
type=float,
|
||||||
|
default=5.0,
|
||||||
|
help="Porcentaje mínimo de similitud para guardar comparación (default: 5.0)"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--contexto",
|
||||||
|
default="",
|
||||||
|
help="Contexto adicional para el prompt de análisis (ej: 'imágenes de noticias políticas')"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--solo-json",
|
||||||
|
action="store_true",
|
||||||
|
help="No conectar a MongoDB, solo guardar JSONs locales"
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
run_pipeline(
|
||||||
|
images_folder=args.carpeta,
|
||||||
|
model=args.modelo,
|
||||||
|
tema_filtro=args.tema,
|
||||||
|
solo_json=args.solo_json,
|
||||||
|
threshold=args.umbral,
|
||||||
|
contexto=args.contexto,
|
||||||
|
)
|
||||||
16
BACK_BACK/IMAGENES/requirements_imagenes.txt
Normal file
16
BACK_BACK/IMAGENES/requirements_imagenes.txt
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
# Dependencias para la pipeline de imágenes FLUJOS
|
||||||
|
# Instalar con: pip install -r requirements_imagenes.txt
|
||||||
|
|
||||||
|
# MongoDB
|
||||||
|
pymongo>=4.6
|
||||||
|
|
||||||
|
# ML / comparación
|
||||||
|
scikit-learn>=1.3
|
||||||
|
numpy>=1.24
|
||||||
|
|
||||||
|
# HTTP (para llamadas a ollama)
|
||||||
|
requests>=2.31
|
||||||
|
|
||||||
|
# Utilidades
|
||||||
|
python-dotenv>=1.0
|
||||||
|
Pillow>=10.0 # lectura/validación de imágenes
|
||||||
301
VISUALIZACION/public/demos/demo_img_nodes.html
Normal file
301
VISUALIZACION/public/demos/demo_img_nodes.html
Normal file
|
|
@ -0,0 +1,301 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>FLUJOS — Demo: Image Nodes</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;700&display=swap" rel="stylesheet">
|
||||||
|
|
||||||
|
<script type="importmap">
|
||||||
|
{
|
||||||
|
"imports": {
|
||||||
|
"three": "https://esm.sh/three@0.168",
|
||||||
|
"three/": "https://esm.sh/three@0.168/",
|
||||||
|
"3d-force-graph": "https://esm.sh/3d-force-graph@1.73?external=three"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; font-family: 'Fira Code', monospace; }
|
||||||
|
body { background: #000; color: #39ff14; overflow: hidden; }
|
||||||
|
|
||||||
|
#graph { position: fixed; top: 0; left: 0; width: 100%; height: 100%; }
|
||||||
|
|
||||||
|
#header {
|
||||||
|
position: fixed; top: 0; left: 0; right: 0; z-index: 10;
|
||||||
|
padding: 10px 20px;
|
||||||
|
background: linear-gradient(180deg, rgba(0,0,0,0.9) 0%, transparent 100%);
|
||||||
|
display: flex; align-items: center; gap: 16px;
|
||||||
|
}
|
||||||
|
#header h1 { font-size: 1.1em; color: #39ff14; letter-spacing: 3px; text-shadow: 0 0 10px #39ff14; }
|
||||||
|
#header .tag {
|
||||||
|
font-size: 0.65em; padding: 3px 8px;
|
||||||
|
border: 1px solid #ff4500; color: #ff4500;
|
||||||
|
opacity: 0.8; letter-spacing: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#info-panel {
|
||||||
|
position: fixed; bottom: 30px; right: 20px; z-index: 10;
|
||||||
|
width: 300px;
|
||||||
|
background: rgba(0,0,0,0.9);
|
||||||
|
border: 1px solid #333;
|
||||||
|
display: none;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
#info-panel .panel-img {
|
||||||
|
width: 100%; height: 120px;
|
||||||
|
object-fit: cover; display: block;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
#info-panel .panel-img-placeholder {
|
||||||
|
width: 100%; height: 120px;
|
||||||
|
background: #111;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 2em; color: #333;
|
||||||
|
}
|
||||||
|
#info-panel .panel-body { padding: 12px 16px; }
|
||||||
|
#info-panel h3 { font-size: 0.85em; color: #fff; margin-bottom: 6px; }
|
||||||
|
#info-panel .group-badge {
|
||||||
|
display: inline-block; padding: 2px 8px;
|
||||||
|
font-size: 0.65em; letter-spacing: 2px; margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
#info-panel p { font-size: 0.72em; color: #888; line-height: 1.5; }
|
||||||
|
#info-panel .close {
|
||||||
|
position: absolute; top: 8px; right: 10px; z-index: 1;
|
||||||
|
cursor: pointer; color: #fff; font-size: 0.8em;
|
||||||
|
background: rgba(0,0,0,0.6); border: none; font-family: inherit;
|
||||||
|
padding: 2px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#technique-label {
|
||||||
|
position: fixed; top: 50px; right: 20px; z-index: 10;
|
||||||
|
font-size: 0.6em; color: #333; letter-spacing: 1px;
|
||||||
|
text-align: right; line-height: 1.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
#legend {
|
||||||
|
position: fixed; bottom: 30px; left: 20px; z-index: 10;
|
||||||
|
background: rgba(0,0,0,0.8); border: 1px solid #222;
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
#legend h4 { font-size: 0.6em; color: #444; margin-bottom: 8px; letter-spacing: 2px; }
|
||||||
|
.legend-row { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
|
||||||
|
.legend-img { width: 20px; height: 20px; object-fit: cover; opacity: 0.7; }
|
||||||
|
.legend-sphere { width: 10px; height: 10px; border-radius: 50%; }
|
||||||
|
.legend-label { font-size: 0.62em; color: #666; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="graph"></div>
|
||||||
|
|
||||||
|
<div id="header">
|
||||||
|
<h1>FLUJOS</h1>
|
||||||
|
<span class="tag">IMAGE NODES</span>
|
||||||
|
<span class="tag">THREE.Sprite + TextureLoader</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="info-panel">
|
||||||
|
<button class="close" onclick="document.getElementById('info-panel').style.display='none'">✕</button>
|
||||||
|
<img id="panel-img" class="panel-img" src="" alt="" style="display:none">
|
||||||
|
<div id="panel-placeholder" class="panel-img-placeholder">◈</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<h3 id="node-title"></h3>
|
||||||
|
<span class="group-badge" id="node-group"></span>
|
||||||
|
<p id="node-content"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="legend">
|
||||||
|
<h4>NODOS CON IMAGEN</h4>
|
||||||
|
<div class="legend-row">
|
||||||
|
<img class="legend-img" src="/images/flujos_logo.png" alt="">
|
||||||
|
<span class="legend-label">FLUJOS (core)</span>
|
||||||
|
</div>
|
||||||
|
<div class="legend-row">
|
||||||
|
<img class="legend-img" src="/images/flujos3.jpg" alt="">
|
||||||
|
<span class="legend-label">Cambio Climático</span>
|
||||||
|
</div>
|
||||||
|
<div class="legend-row">
|
||||||
|
<img class="legend-img" src="/images/journalist_fondo.jpg" alt="">
|
||||||
|
<span class="legend-label">Libertad de Prensa</span>
|
||||||
|
</div>
|
||||||
|
<h4 style="margin-top:8px">NODOS ESFERA</h4>
|
||||||
|
<div class="legend-row">
|
||||||
|
<div class="legend-sphere" style="background:#ff69b4"></div>
|
||||||
|
<span class="legend-label">security</span>
|
||||||
|
</div>
|
||||||
|
<div class="legend-row">
|
||||||
|
<div class="legend-sphere" style="background:#ffdc00"></div>
|
||||||
|
<span class="legend-label">corporate</span>
|
||||||
|
</div>
|
||||||
|
<div class="legend-row">
|
||||||
|
<div class="legend-sphere" style="background:#4488ff"></div>
|
||||||
|
<span class="legend-label">politics</span>
|
||||||
|
</div>
|
||||||
|
<div class="legend-row">
|
||||||
|
<div class="legend-sphere" style="background:#cc44ff"></div>
|
||||||
|
<span class="legend-label">data</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="technique-label">
|
||||||
|
nodeThreeObject(node => Sprite)<br>
|
||||||
|
new THREE.TextureLoader().load(img)<br>
|
||||||
|
new THREE.SpriteMaterial({ map })<br>
|
||||||
|
— THREE.js Sprite billboard —
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="module">
|
||||||
|
import ForceGraph3D from '3d-force-graph';
|
||||||
|
import * as THREE from 'three';
|
||||||
|
|
||||||
|
// Nodes with `img` get rendered as image sprites.
|
||||||
|
// Nodes without `img` fall back to a colored sphere (default).
|
||||||
|
const GROUP_COLORS = {
|
||||||
|
core: '#39ff14',
|
||||||
|
climate: '#ff4500',
|
||||||
|
security: '#ff69b4',
|
||||||
|
journalism: '#00fff2',
|
||||||
|
corporate: '#ffdc00',
|
||||||
|
politics: '#4488ff',
|
||||||
|
data: '#cc44ff'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Images are served from the project's /images/ folder
|
||||||
|
const MOCK_DATA = {
|
||||||
|
nodes: [
|
||||||
|
{ id: 'FLUJOS', group: 'core', img: '/images/flujos_logo.png', size: 22, content: 'Sistema de visualización de flujos de información global' },
|
||||||
|
{ id: 'Cambio Climático', group: 'climate', img: '/images/flujos3.jpg', size: 18, content: 'Crisis climática y sus efectos sociopolíticos' },
|
||||||
|
{ id: 'Seguridad Intl.', group: 'security', img: '/images/flujos4.jpg', size: 16, content: 'Geopolítica y conflictos armados globales' },
|
||||||
|
{ id: 'Libertad de Prensa', group: 'journalism', img: '/images/journalist_fondo.jpg', size: 16, content: 'Estado global de la libertad periodística' },
|
||||||
|
{ id: 'Eco-Corporativo', group: 'corporate', img: '/images/flujos7.jpg', size: 16, content: 'Poder corporativo e influencia política' },
|
||||||
|
{ id: 'Populismo', group: 'politics', img: '/images/flujos8.jpg', size: 16, content: 'Auge de movimientos populistas globales' },
|
||||||
|
{ id: 'Wikipedia', group: 'data', img: '/images/flujos_logo3.png', size: 14, content: 'Enciclopedia libre como fuente de datos' },
|
||||||
|
// Leaf nodes — rendered as default colored spheres
|
||||||
|
{ id: 'Emisiones CO₂', group: 'climate', content: 'Emisiones industriales de CO₂' },
|
||||||
|
{ id: 'Energía Renovable', group: 'climate', content: 'Transición a fuentes sostenibles' },
|
||||||
|
{ id: 'Pérdida Biodiversidad',group: 'climate', content: 'Extinción de especies y ecosistemas' },
|
||||||
|
{ id: 'Ciberseguridad', group: 'security', content: 'Ataques cibernéticos estatales' },
|
||||||
|
{ id: 'Vigilancia Masiva', group: 'security', content: 'Programas de espionaje gubernamental' },
|
||||||
|
{ id: 'Privacidad Datos', group: 'security', content: 'Derechos digitales y datos personales' },
|
||||||
|
{ id: 'Desinformación', group: 'journalism', content: 'Fake news y manipulación informativa' },
|
||||||
|
{ id: 'Whistleblowers', group: 'journalism', content: 'Snowden, Assange, Panama Papers...' },
|
||||||
|
{ id: 'Big Tech', group: 'corporate', content: 'Monopolios: Google, Meta, Amazon...' },
|
||||||
|
{ id: 'Paraísos Fiscales', group: 'corporate', content: 'Evasión fiscal corporativa offshore' },
|
||||||
|
{ id: 'Lobbying', group: 'corporate', content: 'Grupos de presión legislativa' },
|
||||||
|
{ id: 'Elecciones', group: 'politics', content: 'Procesos electorales e interferencia' },
|
||||||
|
{ id: 'Migración', group: 'politics', content: 'Crisis migratoria y fronteras' },
|
||||||
|
{ id: 'Extremismo', group: 'politics', content: 'Radicalización y movimientos extremistas' },
|
||||||
|
{ id: 'Redes Sociales', group: 'data', content: 'Plataformas sociales como vectores' },
|
||||||
|
{ id: 'IA & Algoritmos', group: 'data', content: 'IA, sesgos y control algorítmico' },
|
||||||
|
{ id: 'Torrents & P2P', group: 'data', content: 'Distribución descentralizada de info' },
|
||||||
|
],
|
||||||
|
links: [
|
||||||
|
{ source: 'FLUJOS', target: 'Cambio Climático', value: 90 },
|
||||||
|
{ source: 'FLUJOS', target: 'Seguridad Intl.', value: 85 },
|
||||||
|
{ source: 'FLUJOS', target: 'Libertad de Prensa', value: 88 },
|
||||||
|
{ source: 'FLUJOS', target: 'Eco-Corporativo', value: 87 },
|
||||||
|
{ source: 'FLUJOS', target: 'Populismo', value: 82 },
|
||||||
|
{ source: 'FLUJOS', target: 'Wikipedia', value: 95 },
|
||||||
|
{ source: 'FLUJOS', target: 'Torrents & P2P', value: 88 },
|
||||||
|
{ source: 'Cambio Climático', target: 'Emisiones CO₂', value: 92 },
|
||||||
|
{ source: 'Cambio Climático', target: 'Energía Renovable', value: 88 },
|
||||||
|
{ source: 'Cambio Climático', target: 'Pérdida Biodiversidad',value: 85 },
|
||||||
|
{ source: 'Emisiones CO₂', target: 'Eco-Corporativo', value: 78 },
|
||||||
|
{ source: 'Energía Renovable', target: 'Big Tech', value: 65 },
|
||||||
|
{ source: 'Seguridad Intl.', target: 'Ciberseguridad', value: 88 },
|
||||||
|
{ source: 'Seguridad Intl.', target: 'Vigilancia Masiva', value: 85 },
|
||||||
|
{ source: 'Seguridad Intl.', target: 'Migración', value: 75 },
|
||||||
|
{ source: 'Ciberseguridad', target: 'Privacidad Datos', value: 90 },
|
||||||
|
{ source: 'Vigilancia Masiva', target: 'Privacidad Datos', value: 92 },
|
||||||
|
{ source: 'Vigilancia Masiva', target: 'Big Tech', value: 75 },
|
||||||
|
{ source: 'Vigilancia Masiva', target: 'IA & Algoritmos', value: 82 },
|
||||||
|
{ source: 'Libertad de Prensa',target: 'Desinformación', value: 88 },
|
||||||
|
{ source: 'Libertad de Prensa',target: 'Whistleblowers', value: 85 },
|
||||||
|
{ source: 'Desinformación', target: 'Redes Sociales', value: 90 },
|
||||||
|
{ source: 'Desinformación', target: 'Elecciones', value: 85 },
|
||||||
|
{ source: 'Desinformación', target: 'Extremismo', value: 80 },
|
||||||
|
{ source: 'Eco-Corporativo', target: 'Big Tech', value: 85 },
|
||||||
|
{ source: 'Eco-Corporativo', target: 'Paraísos Fiscales', value: 88 },
|
||||||
|
{ source: 'Eco-Corporativo', target: 'Lobbying', value: 90 },
|
||||||
|
{ source: 'Big Tech', target: 'IA & Algoritmos', value: 88 },
|
||||||
|
{ source: 'Big Tech', target: 'Redes Sociales', value: 92 },
|
||||||
|
{ source: 'Lobbying', target: 'Elecciones', value: 78 },
|
||||||
|
{ source: 'Populismo', target: 'Elecciones', value: 88 },
|
||||||
|
{ source: 'Populismo', target: 'Migración', value: 85 },
|
||||||
|
{ source: 'Populismo', target: 'Extremismo', value: 82 },
|
||||||
|
{ source: 'Extremismo', target: 'Redes Sociales', value: 78 },
|
||||||
|
{ source: 'IA & Algoritmos', target: 'Redes Sociales', value: 85 },
|
||||||
|
{ source: 'Wikipedia', target: 'Desinformación', value: 72 },
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const textureCache = {};
|
||||||
|
const loader = new THREE.TextureLoader();
|
||||||
|
|
||||||
|
function getTexture(url) {
|
||||||
|
if (!textureCache[url]) {
|
||||||
|
const tex = loader.load(url);
|
||||||
|
tex.colorSpace = THREE.SRGBColorSpace;
|
||||||
|
textureCache[url] = tex;
|
||||||
|
}
|
||||||
|
return textureCache[url];
|
||||||
|
}
|
||||||
|
|
||||||
|
const elem = document.getElementById('graph');
|
||||||
|
|
||||||
|
const Graph = ForceGraph3D()(elem)
|
||||||
|
.backgroundColor('#000000')
|
||||||
|
.nodeLabel(node => `<span style="font-family:Fira Code,monospace;color:${GROUP_COLORS[node.group]};font-size:12px">${node.id}</span>`)
|
||||||
|
.nodeColor(node => GROUP_COLORS[node.group] || '#ffffff')
|
||||||
|
.nodeVal(node => node.size ? node.size / 3 : 1.2)
|
||||||
|
.linkColor(() => 'rgba(57,255,20,0.25)')
|
||||||
|
.linkWidth(link => (link.value || 50) / 55)
|
||||||
|
.nodeThreeObject(node => {
|
||||||
|
if (!node.img) return null; // use default sphere for leaf nodes
|
||||||
|
|
||||||
|
const texture = getTexture(node.img);
|
||||||
|
const material = new THREE.SpriteMaterial({ map: texture, transparent: true });
|
||||||
|
const sprite = new THREE.Sprite(material);
|
||||||
|
const s = node.size || 12;
|
||||||
|
sprite.scale.set(s, s);
|
||||||
|
return sprite;
|
||||||
|
})
|
||||||
|
.onNodeClick(node => {
|
||||||
|
const panel = document.getElementById('info-panel');
|
||||||
|
document.getElementById('node-title').textContent = node.id;
|
||||||
|
|
||||||
|
const badge = document.getElementById('node-group');
|
||||||
|
badge.textContent = node.group.toUpperCase();
|
||||||
|
badge.style.background = GROUP_COLORS[node.group] + '22';
|
||||||
|
badge.style.border = `1px solid ${GROUP_COLORS[node.group]}`;
|
||||||
|
badge.style.color = GROUP_COLORS[node.group];
|
||||||
|
|
||||||
|
document.getElementById('node-content').textContent = node.content || '';
|
||||||
|
|
||||||
|
const imgEl = document.getElementById('panel-img');
|
||||||
|
const placeholder = document.getElementById('panel-placeholder');
|
||||||
|
if (node.img) {
|
||||||
|
imgEl.src = node.img;
|
||||||
|
imgEl.style.display = 'block';
|
||||||
|
placeholder.style.display = 'none';
|
||||||
|
} else {
|
||||||
|
imgEl.style.display = 'none';
|
||||||
|
placeholder.style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
panel.style.display = 'block';
|
||||||
|
})
|
||||||
|
.onNodeHover(node => { elem.style.cursor = node ? 'pointer' : null; })
|
||||||
|
.graphData(MOCK_DATA);
|
||||||
|
|
||||||
|
Graph.d3Force('charge').strength(-200);
|
||||||
|
setTimeout(() => Graph.zoomToFit(600, 80), 1500);
|
||||||
|
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
Graph.width(elem.clientWidth).height(elem.clientHeight);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
346
VISUALIZACION/public/demos/demo_mixed_nodes.html
Normal file
346
VISUALIZACION/public/demos/demo_mixed_nodes.html
Normal file
|
|
@ -0,0 +1,346 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>FLUJOS — Demo: Mixed Nodes (HTML Cards)</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;700&display=swap" rel="stylesheet">
|
||||||
|
|
||||||
|
<script type="importmap">
|
||||||
|
{
|
||||||
|
"imports": {
|
||||||
|
"three": "https://esm.sh/three@0.168",
|
||||||
|
"three/": "https://esm.sh/three@0.168/",
|
||||||
|
"three-spritetext": "https://esm.sh/three-spritetext@1.9?external=three",
|
||||||
|
"3d-force-graph": "https://esm.sh/3d-force-graph@1.73?external=three"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; font-family: 'Fira Code', monospace; }
|
||||||
|
body { background: #000; color: #39ff14; overflow: hidden; }
|
||||||
|
#graph { position: fixed; top: 0; left: 0; width: 100%; height: 100%; }
|
||||||
|
|
||||||
|
#header {
|
||||||
|
position: fixed; top: 0; left: 0; right: 0; z-index: 10;
|
||||||
|
padding: 10px 20px;
|
||||||
|
background: linear-gradient(180deg, rgba(0,0,0,0.9) 0%, transparent 100%);
|
||||||
|
display: flex; align-items: center; gap: 16px;
|
||||||
|
}
|
||||||
|
#header h1 { font-size: 1.1em; color: #39ff14; letter-spacing: 3px; text-shadow: 0 0 10px #39ff14; }
|
||||||
|
#header .tag {
|
||||||
|
font-size: 0.65em; padding: 3px 8px;
|
||||||
|
border: 1px solid #cc44ff; color: #cc44ff;
|
||||||
|
opacity: 0.8; letter-spacing: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CSS2D node cards — these are real DOM elements positioned by THREE.js */
|
||||||
|
.node-card {
|
||||||
|
background: rgba(0,0,0,0.85);
|
||||||
|
border: 1px solid var(--color, #39ff14);
|
||||||
|
padding: 6px 10px;
|
||||||
|
max-width: 160px;
|
||||||
|
cursor: pointer;
|
||||||
|
pointer-events: auto;
|
||||||
|
transition: border-color 0.2s, box-shadow 0.2s;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.node-card:hover {
|
||||||
|
box-shadow: 0 0 12px var(--color, #39ff14);
|
||||||
|
}
|
||||||
|
.node-card .card-title {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
line-height: 1.3;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
}
|
||||||
|
.node-card .card-badge {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 8px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
background: var(--color-bg, rgba(57,255,20,0.1));
|
||||||
|
color: var(--color, #39ff14);
|
||||||
|
border: 1px solid var(--color, #39ff14);
|
||||||
|
margin-bottom: 3px;
|
||||||
|
}
|
||||||
|
.node-card .card-source {
|
||||||
|
font-size: 8px;
|
||||||
|
color: #555;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Small leaf node labels */
|
||||||
|
.node-label-small {
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--color, #666);
|
||||||
|
background: rgba(0,0,0,0.7);
|
||||||
|
padding: 2px 5px;
|
||||||
|
pointer-events: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#technique-label {
|
||||||
|
position: fixed; top: 50px; right: 20px; z-index: 10;
|
||||||
|
font-size: 0.6em; color: #333; letter-spacing: 1px;
|
||||||
|
text-align: right; line-height: 1.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
#controls {
|
||||||
|
position: fixed; bottom: 30px; left: 20px; z-index: 10;
|
||||||
|
background: rgba(0,0,0,0.8); border: 1px solid #222;
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
#controls h4 { font-size: 0.6em; color: #444; margin-bottom: 8px; letter-spacing: 2px; }
|
||||||
|
.ctrl-row { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
|
||||||
|
.ctrl-row label { font-size: 0.65em; color: #666; }
|
||||||
|
.ctrl-row input[type=range] { width: 100px; accent-color: #39ff14; }
|
||||||
|
.ctrl-row span { font-size: 0.6em; color: #444; width: 30px; }
|
||||||
|
|
||||||
|
#selected-info {
|
||||||
|
position: fixed; bottom: 30px; right: 20px; z-index: 10;
|
||||||
|
width: 260px;
|
||||||
|
background: rgba(0,0,0,0.9);
|
||||||
|
border: 1px solid #222;
|
||||||
|
padding: 14px;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
#selected-info h3 { font-size: 0.8em; color: #fff; margin-bottom: 6px; }
|
||||||
|
#selected-info .badge { font-size: 0.65em; padding: 2px 8px; margin-bottom: 8px; display: inline-block; }
|
||||||
|
#selected-info p { font-size: 0.7em; color: #888; line-height: 1.5; margin-bottom: 6px; }
|
||||||
|
#selected-info .meta { font-size: 0.65em; color: #555; }
|
||||||
|
#selected-info .close {
|
||||||
|
position: absolute; top: 8px; right: 10px;
|
||||||
|
cursor: pointer; color: #555; font-size: 0.8em;
|
||||||
|
background: none; border: none; font-family: inherit;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="graph"></div>
|
||||||
|
|
||||||
|
<div id="header">
|
||||||
|
<h1>FLUJOS</h1>
|
||||||
|
<span class="tag">HTML NODES</span>
|
||||||
|
<span class="tag">CSS2DRenderer</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="technique-label">
|
||||||
|
CSS2DRenderer + CSS2DObject<br>
|
||||||
|
nodeThreeObject(node => CSS2DObject(div))<br>
|
||||||
|
nodeThreeObjectExtend(true)<br>
|
||||||
|
— Real DOM elements in 3D space —
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="controls">
|
||||||
|
<h4>VISUALIZACIÓN</h4>
|
||||||
|
<div class="ctrl-row">
|
||||||
|
<label>Umbral similitud</label>
|
||||||
|
<input type="range" id="umbral" min="0" max="95" value="0" step="5">
|
||||||
|
<span id="umbral-val">0%</span>
|
||||||
|
</div>
|
||||||
|
<div class="ctrl-row">
|
||||||
|
<label>Carga D3</label>
|
||||||
|
<input type="range" id="charge" min="-400" max="-50" value="-200" step="10">
|
||||||
|
<span id="charge-val">-200</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="selected-info">
|
||||||
|
<button class="close" onclick="document.getElementById('selected-info').style.display='none'">✕</button>
|
||||||
|
<h3 id="sel-title"></h3>
|
||||||
|
<span class="badge" id="sel-badge"></span>
|
||||||
|
<p id="sel-content"></p>
|
||||||
|
<div class="meta" id="sel-meta"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="module">
|
||||||
|
import ForceGraph3D from '3d-force-graph';
|
||||||
|
import { CSS2DRenderer, CSS2DObject } from 'https://esm.sh/three@0.168/examples/jsm/renderers/CSS2DRenderer.js';
|
||||||
|
|
||||||
|
const GROUP_COLORS = {
|
||||||
|
core: '#39ff14',
|
||||||
|
climate: '#ff4500',
|
||||||
|
security: '#ff69b4',
|
||||||
|
journalism: '#00fff2',
|
||||||
|
corporate: '#ffdc00',
|
||||||
|
politics: '#4488ff',
|
||||||
|
data: '#cc44ff'
|
||||||
|
};
|
||||||
|
|
||||||
|
// `card: true` = rendered as HTML card (CSS2DObject)
|
||||||
|
// `card: false` = rendered as colored sphere with small label
|
||||||
|
const MOCK_DATA = {
|
||||||
|
nodes: [
|
||||||
|
// Hub nodes — full HTML cards
|
||||||
|
{ id: 'FLUJOS', group: 'core', card: true, source: 'SISTEMA', connections: 7, content: 'Plataforma de análisis y visualización de flujos informativos globales' },
|
||||||
|
{ id: 'Cambio Climático', group: 'climate', card: true, source: 'Wikipedia', connections: 5, content: 'Crisis climática: emisiones, biodiversidad, energía' },
|
||||||
|
{ id: 'Seguridad Intl.', group: 'security', card: true, source: 'Noticias', connections: 4, content: 'Geopolítica, conflictos armados y espionaje global' },
|
||||||
|
{ id: 'Libertad de Prensa', group: 'journalism', card: true, source: 'Wikipedia', connections: 4, content: 'Periodismo, desinformación y denunciantes' },
|
||||||
|
{ id: 'Eco-Corporativo', group: 'corporate', card: true, source: 'Noticias', connections: 4, content: 'Poder corporativo, lobbying y paraísos fiscales' },
|
||||||
|
{ id: 'Populismo', group: 'politics', card: true, source: 'Noticias', connections: 3, content: 'Movimientos populistas, elecciones y extremismo' },
|
||||||
|
{ id: 'Wikipedia', group: 'data', card: true, source: 'Wikipedia', connections: 3, content: 'Fuente de datos abiertos y verificación factual' },
|
||||||
|
{ id: 'Torrents & P2P', group: 'data', card: true, source: 'Torrents', connections: 2, content: 'Distribución descentralizada de información' },
|
||||||
|
// Leaf nodes — small labels on spheres
|
||||||
|
{ id: 'Emisiones CO₂', group: 'climate', card: false, source: 'Wikipedia' },
|
||||||
|
{ id: 'Energía Renovable', group: 'climate', card: false, source: 'Wikipedia' },
|
||||||
|
{ id: 'Pérdida Biodiversidad',group: 'climate', card: false, source: 'Wikipedia' },
|
||||||
|
{ id: 'Ciberseguridad', group: 'security', card: false, source: 'Noticias' },
|
||||||
|
{ id: 'Vigilancia Masiva', group: 'security', card: false, source: 'Noticias' },
|
||||||
|
{ id: 'Privacidad Datos', group: 'security', card: false, source: 'Wikipedia' },
|
||||||
|
{ id: 'Desinformación', group: 'journalism', card: false, source: 'Noticias' },
|
||||||
|
{ id: 'Whistleblowers', group: 'journalism', card: false, source: 'Wikipedia' },
|
||||||
|
{ id: 'Big Tech', group: 'corporate', card: false, source: 'Noticias' },
|
||||||
|
{ id: 'Paraísos Fiscales', group: 'corporate', card: false, source: 'Wikipedia' },
|
||||||
|
{ id: 'Lobbying', group: 'corporate', card: false, source: 'Noticias' },
|
||||||
|
{ id: 'Elecciones', group: 'politics', card: false, source: 'Noticias' },
|
||||||
|
{ id: 'Migración', group: 'politics', card: false, source: 'Noticias' },
|
||||||
|
{ id: 'Extremismo', group: 'politics', card: false, source: 'Noticias' },
|
||||||
|
{ id: 'Redes Sociales', group: 'data', card: false, source: 'Wikipedia' },
|
||||||
|
{ id: 'IA & Algoritmos', group: 'data', card: false, source: 'Noticias' },
|
||||||
|
],
|
||||||
|
links: [
|
||||||
|
{ source: 'FLUJOS', target: 'Cambio Climático', value: 90 },
|
||||||
|
{ source: 'FLUJOS', target: 'Seguridad Intl.', value: 85 },
|
||||||
|
{ source: 'FLUJOS', target: 'Libertad de Prensa', value: 88 },
|
||||||
|
{ source: 'FLUJOS', target: 'Eco-Corporativo', value: 87 },
|
||||||
|
{ source: 'FLUJOS', target: 'Populismo', value: 82 },
|
||||||
|
{ source: 'FLUJOS', target: 'Wikipedia', value: 95 },
|
||||||
|
{ source: 'FLUJOS', target: 'Torrents & P2P', value: 88 },
|
||||||
|
{ source: 'Cambio Climático', target: 'Emisiones CO₂', value: 92 },
|
||||||
|
{ source: 'Cambio Climático', target: 'Energía Renovable', value: 88 },
|
||||||
|
{ source: 'Cambio Climático', target: 'Pérdida Biodiversidad',value: 85 },
|
||||||
|
{ source: 'Emisiones CO₂', target: 'Eco-Corporativo', value: 78 },
|
||||||
|
{ source: 'Seguridad Intl.', target: 'Ciberseguridad', value: 88 },
|
||||||
|
{ source: 'Seguridad Intl.', target: 'Vigilancia Masiva', value: 85 },
|
||||||
|
{ source: 'Seguridad Intl.', target: 'Migración', value: 75 },
|
||||||
|
{ source: 'Ciberseguridad', target: 'Privacidad Datos', value: 90 },
|
||||||
|
{ source: 'Vigilancia Masiva', target: 'Privacidad Datos', value: 92 },
|
||||||
|
{ source: 'Vigilancia Masiva', target: 'IA & Algoritmos', value: 82 },
|
||||||
|
{ source: 'Libertad de Prensa',target: 'Desinformación', value: 88 },
|
||||||
|
{ source: 'Libertad de Prensa',target: 'Whistleblowers', value: 85 },
|
||||||
|
{ source: 'Desinformación', target: 'Redes Sociales', value: 90 },
|
||||||
|
{ source: 'Desinformación', target: 'Elecciones', value: 85 },
|
||||||
|
{ source: 'Eco-Corporativo', target: 'Big Tech', value: 85 },
|
||||||
|
{ source: 'Eco-Corporativo', target: 'Paraísos Fiscales', value: 88 },
|
||||||
|
{ source: 'Eco-Corporativo', target: 'Lobbying', value: 90 },
|
||||||
|
{ source: 'Big Tech', target: 'IA & Algoritmos', value: 88 },
|
||||||
|
{ source: 'Big Tech', target: 'Redes Sociales', value: 92 },
|
||||||
|
{ source: 'Lobbying', target: 'Elecciones', value: 78 },
|
||||||
|
{ source: 'Populismo', target: 'Elecciones', value: 88 },
|
||||||
|
{ source: 'Populismo', target: 'Migración', value: 85 },
|
||||||
|
{ source: 'Populismo', target: 'Extremismo', value: 82 },
|
||||||
|
{ source: 'Extremismo', target: 'Redes Sociales', value: 78 },
|
||||||
|
{ source: 'IA & Algoritmos', target: 'Redes Sociales', value: 85 },
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const elem = document.getElementById('graph');
|
||||||
|
const css2dRenderer = new CSS2DRenderer();
|
||||||
|
css2dRenderer.setSize(window.innerWidth, window.innerHeight);
|
||||||
|
css2dRenderer.domElement.style.position = 'absolute';
|
||||||
|
css2dRenderer.domElement.style.top = '0';
|
||||||
|
css2dRenderer.domElement.style.left = '0';
|
||||||
|
css2dRenderer.domElement.style.pointerEvents = 'none';
|
||||||
|
elem.appendChild(css2dRenderer.domElement);
|
||||||
|
|
||||||
|
function showNodeInfo(node) {
|
||||||
|
const panel = document.getElementById('selected-info');
|
||||||
|
document.getElementById('sel-title').textContent = node.id;
|
||||||
|
const badge = document.getElementById('sel-badge');
|
||||||
|
badge.textContent = node.group.toUpperCase();
|
||||||
|
badge.style.background = GROUP_COLORS[node.group] + '22';
|
||||||
|
badge.style.border = `1px solid ${GROUP_COLORS[node.group]}`;
|
||||||
|
badge.style.color = GROUP_COLORS[node.group];
|
||||||
|
document.getElementById('sel-content').textContent = node.content || node.id;
|
||||||
|
document.getElementById('sel-meta').textContent =
|
||||||
|
`FUENTE: ${node.source || '—'}` + (node.connections ? ` | CONEXIONES: ${node.connections}` : '');
|
||||||
|
panel.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
const Graph = ForceGraph3D({ extraRenderers: [css2dRenderer] })(elem)
|
||||||
|
.backgroundColor('#000000')
|
||||||
|
.nodeLabel(() => '') // disable default tooltip — we use CSS2D labels
|
||||||
|
.nodeColor(node => GROUP_COLORS[node.group] || '#ffffff')
|
||||||
|
.nodeVal(node => node.card ? 3 : 1)
|
||||||
|
.nodeOpacity(node => node.card ? 0 : 0.85) // hide sphere for card nodes
|
||||||
|
.linkColor(() => 'rgba(57,255,20,0.2)')
|
||||||
|
.linkWidth(link => (link.value || 50) / 60)
|
||||||
|
.nodeThreeObject(node => {
|
||||||
|
const color = GROUP_COLORS[node.group] || '#ffffff';
|
||||||
|
|
||||||
|
if (node.card) {
|
||||||
|
// Full HTML card as CSS2D object
|
||||||
|
const wrapper = document.createElement('div');
|
||||||
|
wrapper.className = 'node-card';
|
||||||
|
wrapper.style.setProperty('--color', color);
|
||||||
|
wrapper.style.setProperty('--color-bg', color + '15');
|
||||||
|
wrapper.style.borderColor = color;
|
||||||
|
|
||||||
|
const title = document.createElement('div');
|
||||||
|
title.className = 'card-title';
|
||||||
|
title.textContent = node.id;
|
||||||
|
|
||||||
|
const badge = document.createElement('div');
|
||||||
|
badge.className = 'card-badge';
|
||||||
|
badge.textContent = node.group.toUpperCase();
|
||||||
|
|
||||||
|
const source = document.createElement('div');
|
||||||
|
source.className = 'card-source';
|
||||||
|
source.textContent = `↗ ${node.source || '—'}${node.connections ? ' · ' + node.connections + ' conn.' : ''}`;
|
||||||
|
|
||||||
|
wrapper.appendChild(title);
|
||||||
|
wrapper.appendChild(badge);
|
||||||
|
wrapper.appendChild(source);
|
||||||
|
|
||||||
|
wrapper.addEventListener('click', () => showNodeInfo(node));
|
||||||
|
|
||||||
|
return new CSS2DObject(wrapper);
|
||||||
|
} else {
|
||||||
|
// Small text label for leaf nodes
|
||||||
|
const label = document.createElement('div');
|
||||||
|
label.className = 'node-label-small';
|
||||||
|
label.textContent = node.id;
|
||||||
|
label.style.setProperty('--color', color);
|
||||||
|
return new CSS2DObject(label);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.nodeThreeObjectExtend(true)
|
||||||
|
.onNodeClick(node => showNodeInfo(node))
|
||||||
|
.onNodeHover(node => { elem.style.cursor = node ? 'pointer' : null; })
|
||||||
|
.graphData(MOCK_DATA);
|
||||||
|
|
||||||
|
Graph.d3Force('charge').strength(-200);
|
||||||
|
setTimeout(() => Graph.zoomToFit(800, 100), 1500);
|
||||||
|
|
||||||
|
// Controls
|
||||||
|
const umbralSlider = document.getElementById('umbral');
|
||||||
|
const umbralVal = document.getElementById('umbral-val');
|
||||||
|
umbralSlider.addEventListener('input', () => {
|
||||||
|
const v = parseInt(umbralSlider.value);
|
||||||
|
umbralVal.textContent = v + '%';
|
||||||
|
const allLinks = MOCK_DATA.links;
|
||||||
|
const filtered = v > 0 ? allLinks.filter(l => (l.value || 0) >= v) : allLinks;
|
||||||
|
Graph.graphData({ nodes: MOCK_DATA.nodes, links: filtered });
|
||||||
|
});
|
||||||
|
|
||||||
|
const chargeSlider = document.getElementById('charge');
|
||||||
|
const chargeVal = document.getElementById('charge-val');
|
||||||
|
chargeSlider.addEventListener('input', () => {
|
||||||
|
const v = parseInt(chargeSlider.value);
|
||||||
|
chargeVal.textContent = v;
|
||||||
|
Graph.d3Force('charge').strength(v);
|
||||||
|
Graph.d3ReheatSimulation();
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
Graph.width(elem.clientWidth).height(elem.clientHeight);
|
||||||
|
css2dRenderer.setSize(window.innerWidth, window.innerHeight);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
242
VISUALIZACION/public/demos/demo_text_nodes.html
Normal file
242
VISUALIZACION/public/demos/demo_text_nodes.html
Normal file
|
|
@ -0,0 +1,242 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>FLUJOS — Demo: Text Nodes</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;700&display=swap" rel="stylesheet">
|
||||||
|
|
||||||
|
<script type="importmap">
|
||||||
|
{
|
||||||
|
"imports": {
|
||||||
|
"three": "https://esm.sh/three@0.168",
|
||||||
|
"three/": "https://esm.sh/three@0.168/",
|
||||||
|
"three-spritetext": "https://esm.sh/three-spritetext@1.9?external=three",
|
||||||
|
"3d-force-graph": "https://esm.sh/3d-force-graph@1.73?external=three"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; font-family: 'Fira Code', monospace; }
|
||||||
|
body { background: #000; color: #39ff14; overflow: hidden; }
|
||||||
|
|
||||||
|
#graph { position: fixed; top: 0; left: 0; width: 100%; height: 100%; }
|
||||||
|
|
||||||
|
#header {
|
||||||
|
position: fixed; top: 0; left: 0; right: 0; z-index: 10;
|
||||||
|
padding: 10px 20px;
|
||||||
|
background: linear-gradient(180deg, rgba(0,0,0,0.9) 0%, transparent 100%);
|
||||||
|
display: flex; align-items: center; gap: 16px;
|
||||||
|
}
|
||||||
|
#header h1 { font-size: 1.1em; color: #39ff14; letter-spacing: 3px; text-shadow: 0 0 10px #39ff14; }
|
||||||
|
#header .tag {
|
||||||
|
font-size: 0.65em; padding: 3px 8px;
|
||||||
|
border: 1px solid #39ff14; color: #39ff14;
|
||||||
|
opacity: 0.7; letter-spacing: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#info-panel {
|
||||||
|
position: fixed; bottom: 30px; right: 20px; z-index: 10;
|
||||||
|
width: 280px;
|
||||||
|
background: rgba(0,0,0,0.85);
|
||||||
|
border: 1px solid #39ff14;
|
||||||
|
padding: 16px;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
#info-panel h3 { font-size: 0.9em; color: #39ff14; margin-bottom: 8px; }
|
||||||
|
#info-panel .group-badge {
|
||||||
|
display: inline-block; padding: 2px 8px;
|
||||||
|
font-size: 0.7em; letter-spacing: 2px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
#info-panel p { font-size: 0.75em; color: #aaa; line-height: 1.5; }
|
||||||
|
#info-panel .close {
|
||||||
|
position: absolute; top: 8px; right: 10px;
|
||||||
|
cursor: pointer; color: #39ff14; font-size: 0.8em;
|
||||||
|
background: none; border: none; font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
#legend {
|
||||||
|
position: fixed; bottom: 30px; left: 20px; z-index: 10;
|
||||||
|
background: rgba(0,0,0,0.8); border: 1px solid #333;
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
#legend h4 { font-size: 0.65em; color: #555; margin-bottom: 8px; letter-spacing: 2px; }
|
||||||
|
.legend-item { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
|
||||||
|
.legend-dot { width: 8px; height: 8px; border-radius: 50%; }
|
||||||
|
.legend-label { font-size: 0.65em; color: #888; }
|
||||||
|
|
||||||
|
#technique-label {
|
||||||
|
position: fixed; top: 50px; right: 20px; z-index: 10;
|
||||||
|
font-size: 0.6em; color: #333; letter-spacing: 1px;
|
||||||
|
text-align: right; line-height: 1.8;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="graph"></div>
|
||||||
|
|
||||||
|
<div id="header">
|
||||||
|
<h1>FLUJOS</h1>
|
||||||
|
<span class="tag">TEXT NODES</span>
|
||||||
|
<span class="tag">three-spritetext</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="info-panel">
|
||||||
|
<button class="close" onclick="document.getElementById('info-panel').style.display='none'">✕</button>
|
||||||
|
<h3 id="node-title"></h3>
|
||||||
|
<span class="group-badge" id="node-group"></span>
|
||||||
|
<p id="node-content"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="legend">
|
||||||
|
<h4>GRUPOS</h4>
|
||||||
|
<div class="legend-item"><div class="legend-dot" style="background:#39ff14"></div><span class="legend-label">core</span></div>
|
||||||
|
<div class="legend-item"><div class="legend-dot" style="background:#ff4500"></div><span class="legend-label">climate</span></div>
|
||||||
|
<div class="legend-item"><div class="legend-dot" style="background:#ff69b4"></div><span class="legend-label">security</span></div>
|
||||||
|
<div class="legend-item"><div class="legend-dot" style="background:#00fff2"></div><span class="legend-label">journalism</span></div>
|
||||||
|
<div class="legend-item"><div class="legend-dot" style="background:#ffdc00"></div><span class="legend-label">corporate</span></div>
|
||||||
|
<div class="legend-item"><div class="legend-dot" style="background:#4488ff"></div><span class="legend-label">politics</span></div>
|
||||||
|
<div class="legend-item"><div class="legend-dot" style="background:#cc44ff"></div><span class="legend-label">data</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="technique-label">
|
||||||
|
nodeThreeObject(node => SpriteText)<br>
|
||||||
|
nodeThreeObjectExtend(true)<br>
|
||||||
|
— three-spritetext —
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="module">
|
||||||
|
import ForceGraph3D from '3d-force-graph';
|
||||||
|
import SpriteText from 'three-spritetext';
|
||||||
|
|
||||||
|
const GROUP_COLORS = {
|
||||||
|
core: '#39ff14',
|
||||||
|
climate: '#ff4500',
|
||||||
|
security: '#ff69b4',
|
||||||
|
journalism: '#00fff2',
|
||||||
|
corporate: '#ffdc00',
|
||||||
|
politics: '#4488ff',
|
||||||
|
data: '#cc44ff'
|
||||||
|
};
|
||||||
|
|
||||||
|
const MOCK_DATA = {
|
||||||
|
nodes: [
|
||||||
|
{ id: 'FLUJOS', group: 'core', content: 'Sistema de visualización de flujos de información global' },
|
||||||
|
{ id: 'Cambio Climático', group: 'climate', content: 'Crisis climática y sus efectos sociopolíticos a escala global' },
|
||||||
|
{ id: 'Emisiones CO₂', group: 'climate', content: 'Emisiones de dióxido de carbono por sector industrial' },
|
||||||
|
{ id: 'Energía Renovable', group: 'climate', content: 'Transición energética hacia fuentes sostenibles' },
|
||||||
|
{ id: 'Pérdida Biodiversidad',group: 'climate', content: 'Extinción masiva de especies y colapso de ecosistemas' },
|
||||||
|
{ id: 'Seguridad Intl.', group: 'security', content: 'Geopolítica, conflictos armados y alianzas militares' },
|
||||||
|
{ id: 'Ciberseguridad', group: 'security', content: 'Ataques cibernéticos estatales y crimen organizado digital' },
|
||||||
|
{ id: 'Vigilancia Masiva', group: 'security', content: 'Programas de espionaje gubernamental (NSA, GCHQ...)' },
|
||||||
|
{ id: 'Privacidad Datos', group: 'security', content: 'Derechos digitales y protección de datos personales' },
|
||||||
|
{ id: 'Libertad de Prensa', group: 'journalism', content: 'Estado global de la libertad periodística' },
|
||||||
|
{ id: 'Desinformación', group: 'journalism', content: 'Fake news, propaganda y manipulación informativa' },
|
||||||
|
{ id: 'Whistleblowers', group: 'journalism', content: 'Filtraciones: Snowden, Assange, Panama Papers...' },
|
||||||
|
{ id: 'Periodismo de Datos', group: 'journalism', content: 'Investigación basada en datos abiertos y scraping' },
|
||||||
|
{ id: 'Eco-Corporativo', group: 'corporate', content: 'Poder corporativo y su influencia en política' },
|
||||||
|
{ id: 'Big Tech', group: 'corporate', content: 'Monopolios: Google, Meta, Amazon, Apple, Microsoft' },
|
||||||
|
{ id: 'Paraísos Fiscales', group: 'corporate', content: 'Evasión fiscal corporativa offshore' },
|
||||||
|
{ id: 'Lobbying', group: 'corporate', content: 'Grupos de presión e influencia legislativa' },
|
||||||
|
{ id: 'Populismo', group: 'politics', content: 'Auge de movimientos populistas globales' },
|
||||||
|
{ id: 'Elecciones', group: 'politics', content: 'Procesos electorales e interferencia exterior' },
|
||||||
|
{ id: 'Migración', group: 'politics', content: 'Crisis migratoria y políticas de fronteras' },
|
||||||
|
{ id: 'Extremismo', group: 'politics', content: 'Radicalización online y movimientos extremistas' },
|
||||||
|
{ id: 'Wikipedia', group: 'data', content: 'Enciclopedia libre como fuente de datos estructurados' },
|
||||||
|
{ id: 'Redes Sociales', group: 'data', content: 'Plataformas sociales como vectores de información' },
|
||||||
|
{ id: 'IA & Algoritmos', group: 'data', content: 'Inteligencia artificial, sesgos y control algorítmico' },
|
||||||
|
{ id: 'Torrents & P2P', group: 'data', content: 'Redes de distribución descentralizada de información' },
|
||||||
|
],
|
||||||
|
links: [
|
||||||
|
{ source: 'FLUJOS', target: 'Cambio Climático', value: 90 },
|
||||||
|
{ source: 'FLUJOS', target: 'Seguridad Intl.', value: 85 },
|
||||||
|
{ source: 'FLUJOS', target: 'Libertad de Prensa', value: 88 },
|
||||||
|
{ source: 'FLUJOS', target: 'Eco-Corporativo', value: 87 },
|
||||||
|
{ source: 'FLUJOS', target: 'Populismo', value: 82 },
|
||||||
|
{ source: 'FLUJOS', target: 'Wikipedia', value: 95 },
|
||||||
|
{ source: 'FLUJOS', target: 'Torrents & P2P', value: 88 },
|
||||||
|
{ source: 'Cambio Climático', target: 'Emisiones CO₂', value: 92 },
|
||||||
|
{ source: 'Cambio Climático', target: 'Energía Renovable', value: 88 },
|
||||||
|
{ source: 'Cambio Climático', target: 'Pérdida Biodiversidad',value: 85 },
|
||||||
|
{ source: 'Emisiones CO₂', target: 'Eco-Corporativo', value: 78 },
|
||||||
|
{ source: 'Energía Renovable', target: 'Big Tech', value: 65 },
|
||||||
|
{ source: 'Seguridad Intl.', target: 'Ciberseguridad', value: 88 },
|
||||||
|
{ source: 'Seguridad Intl.', target: 'Vigilancia Masiva', value: 85 },
|
||||||
|
{ source: 'Seguridad Intl.', target: 'Migración', value: 75 },
|
||||||
|
{ source: 'Ciberseguridad', target: 'Privacidad Datos', value: 90 },
|
||||||
|
{ source: 'Vigilancia Masiva', target: 'Privacidad Datos', value: 92 },
|
||||||
|
{ source: 'Vigilancia Masiva', target: 'Big Tech', value: 75 },
|
||||||
|
{ source: 'Vigilancia Masiva', target: 'IA & Algoritmos', value: 82 },
|
||||||
|
{ source: 'Libertad de Prensa',target: 'Desinformación', value: 88 },
|
||||||
|
{ source: 'Libertad de Prensa',target: 'Whistleblowers', value: 85 },
|
||||||
|
{ source: 'Libertad de Prensa',target: 'Periodismo de Datos', value: 82 },
|
||||||
|
{ source: 'Desinformación', target: 'Redes Sociales', value: 90 },
|
||||||
|
{ source: 'Desinformación', target: 'Elecciones', value: 85 },
|
||||||
|
{ source: 'Desinformación', target: 'Extremismo', value: 80 },
|
||||||
|
{ source: 'Whistleblowers', target: 'Ciberseguridad', value: 72 },
|
||||||
|
{ source: 'Periodismo de Datos',target:'Wikipedia', value: 80 },
|
||||||
|
{ source: 'Eco-Corporativo', target: 'Big Tech', value: 85 },
|
||||||
|
{ source: 'Eco-Corporativo', target: 'Paraísos Fiscales', value: 88 },
|
||||||
|
{ source: 'Eco-Corporativo', target: 'Lobbying', value: 90 },
|
||||||
|
{ source: 'Big Tech', target: 'IA & Algoritmos', value: 88 },
|
||||||
|
{ source: 'Big Tech', target: 'Privacidad Datos', value: 85 },
|
||||||
|
{ source: 'Big Tech', target: 'Redes Sociales', value: 92 },
|
||||||
|
{ source: 'Lobbying', target: 'Elecciones', value: 78 },
|
||||||
|
{ source: 'Populismo', target: 'Elecciones', value: 88 },
|
||||||
|
{ source: 'Populismo', target: 'Migración', value: 85 },
|
||||||
|
{ source: 'Populismo', target: 'Extremismo', value: 82 },
|
||||||
|
{ source: 'Extremismo', target: 'Redes Sociales', value: 78 },
|
||||||
|
{ source: 'IA & Algoritmos', target: 'Redes Sociales', value: 85 },
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const elem = document.getElementById('graph');
|
||||||
|
|
||||||
|
const Graph = ForceGraph3D()(elem)
|
||||||
|
.backgroundColor('#000000')
|
||||||
|
.nodeLabel(node => `<span style="font-family:Fira Code,monospace;color:${GROUP_COLORS[node.group]}">${node.id}</span>`)
|
||||||
|
.nodeColor(node => GROUP_COLORS[node.group] || '#ffffff')
|
||||||
|
.nodeVal(node => node.group === 'core' ? 4 : 1.5)
|
||||||
|
.linkColor(link => {
|
||||||
|
const val = link.value || 50;
|
||||||
|
const alpha = Math.round((val / 100) * 200).toString(16).padStart(2, '0');
|
||||||
|
return `#39ff14${alpha}`;
|
||||||
|
})
|
||||||
|
.linkOpacity(0.4)
|
||||||
|
.linkWidth(link => (link.value || 50) / 60)
|
||||||
|
.nodeThreeObject(node => {
|
||||||
|
const sprite = new SpriteText(node.id);
|
||||||
|
sprite.material.depthWrite = false;
|
||||||
|
sprite.color = GROUP_COLORS[node.group] || '#ffffff';
|
||||||
|
sprite.textHeight = node.group === 'core' ? 5 : 3.5;
|
||||||
|
sprite.backgroundColor = 'rgba(0,0,0,0.4)';
|
||||||
|
sprite.padding = 2;
|
||||||
|
return sprite;
|
||||||
|
})
|
||||||
|
.nodeThreeObjectExtend(true)
|
||||||
|
.onNodeClick(node => {
|
||||||
|
const panel = document.getElementById('info-panel');
|
||||||
|
document.getElementById('node-title').textContent = node.id;
|
||||||
|
const badge = document.getElementById('node-group');
|
||||||
|
badge.textContent = node.group.toUpperCase();
|
||||||
|
badge.style.background = GROUP_COLORS[node.group] + '22';
|
||||||
|
badge.style.border = `1px solid ${GROUP_COLORS[node.group]}`;
|
||||||
|
badge.style.color = GROUP_COLORS[node.group];
|
||||||
|
document.getElementById('node-content').textContent = node.content || '';
|
||||||
|
panel.style.display = 'block';
|
||||||
|
})
|
||||||
|
.onNodeHover(node => { elem.style.cursor = node ? 'pointer' : null; })
|
||||||
|
.graphData(MOCK_DATA);
|
||||||
|
|
||||||
|
Graph.d3Force('charge').strength(-180);
|
||||||
|
|
||||||
|
setTimeout(() => Graph.zoomToFit(600, 80), 1200);
|
||||||
|
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
Graph.width(elem.clientWidth).height(elem.clientHeight);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue