Add image pipeline and demo files from pruebas branch
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
1f7bba58e6
commit
932e8e80db
14 changed files with 2663 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`);
|
||||
});
|
||||
3
BACK_BACK/IMAGENES/.gitignore
vendored
Normal file
3
BACK_BACK/IMAGENES/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
__pycache__/
|
||||
output/
|
||||
*.pyc
|
||||
30
BACK_BACK/IMAGENES/debug_wiki.py
Normal file
30
BACK_BACK/IMAGENES/debug_wiki.py
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
"""Script de debug para ver qué devuelve la API de Wikipedia/Wikimedia."""
|
||||
import requests
|
||||
from wikipedia_image_scraper import (
|
||||
search_articles, get_article_images, get_image_info, should_skip, SKIP_PATTERNS
|
||||
)
|
||||
|
||||
# 1. Buscar artículos
|
||||
print("=== ARTÍCULOS ===")
|
||||
articles = search_articles("cambio climático", lang="es", limit=2)
|
||||
for a in articles:
|
||||
print(f" {a['title']}")
|
||||
|
||||
# 2. Imágenes del primer artículo
|
||||
print("\n=== IMÁGENES DEL ARTÍCULO ===")
|
||||
img_titles = get_article_images(articles[0]["title"], lang="es", limit=10)
|
||||
for t in img_titles:
|
||||
print(f" {t}")
|
||||
|
||||
# 3. Info de las primeras 5 imágenes
|
||||
print("\n=== INFO DE CADA IMAGEN ===")
|
||||
for title in img_titles[:5]:
|
||||
print(f"\n Título: {title}")
|
||||
info = get_image_info(title)
|
||||
if info is None:
|
||||
print(" → get_image_info devolvió None")
|
||||
continue
|
||||
print(f" url: {info.get('url', 'N/A')[:80]}")
|
||||
print(f" size: {info.get('width')}x{info.get('height')} {info.get('size_bytes')}B")
|
||||
skip, motivo = should_skip(title, info)
|
||||
print(f" skip: {skip} ({motivo})")
|
||||
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
|
||||
446
BACK_BACK/IMAGENES/wikipedia_image_scraper.py
Normal file
446
BACK_BACK/IMAGENES/wikipedia_image_scraper.py
Normal file
|
|
@ -0,0 +1,446 @@
|
|||
"""
|
||||
wikipedia_image_scraper.py
|
||||
--------------------------
|
||||
Descarga imágenes de artículos de Wikipedia por tema usando la Wikimedia API.
|
||||
Las guarda en una carpeta local y registra los metadatos en JSON / MongoDB.
|
||||
|
||||
Flujo:
|
||||
1. Busca artículos en Wikipedia por tema/keyword
|
||||
2. Para cada artículo extrae las imágenes (Wikimedia API)
|
||||
3. Filtra imágenes no relevantes (iconos, banderas, logos pequeños...)
|
||||
4. Descarga las imágenes a la carpeta de destino
|
||||
5. Guarda metadatos: título del artículo, tema, url, descripción, fecha
|
||||
6. Opcional: guarda metadatos en MongoDB colección 'imagenes_wiki'
|
||||
|
||||
Uso:
|
||||
python wikipedia_image_scraper.py --tema "cambio climático" --max 30
|
||||
python wikipedia_image_scraper.py --tema "geopolítica" --lang es --max 50
|
||||
python wikipedia_image_scraper.py --temas temas.txt --max 20
|
||||
python wikipedia_image_scraper.py --tema "climate change" --lang en --max 40
|
||||
|
||||
Requisitos:
|
||||
pip install requests Pillow pymongo python-dotenv
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote, urlparse
|
||||
|
||||
import requests
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
|
||||
# ── Configuración ──────────────────────────────────────────────────────────────
|
||||
|
||||
WIKI_API_ES = "https://es.wikipedia.org/w/api.php"
|
||||
WIKI_API_EN = "https://en.wikipedia.org/w/api.php"
|
||||
WIKIMEDIA_API = "https://commons.wikimedia.org/w/api.php"
|
||||
|
||||
OUTPUT_BASE = Path(__file__).parent / "output" / "wiki_images"
|
||||
|
||||
# Tamaño mínimo para considerar una imagen relevante (pixels)
|
||||
MIN_WIDTH = 200
|
||||
MIN_HEIGHT = 200
|
||||
MIN_BYTES = 20_000 # 20KB mínimo
|
||||
|
||||
# Extensiones válidas
|
||||
VALID_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp"}
|
||||
|
||||
# Prefijos/sufijos de archivos a ignorar (iconos, banderas, etc.)
|
||||
SKIP_PATTERNS = [
|
||||
"flag_", "Flag_", "icon", "Icon", "logo", "Logo",
|
||||
"symbol", "Symbol", "coat_of_arms", "Coat_of_arms",
|
||||
"commons-logo", "wiki", "Wiki", "question_mark",
|
||||
"edit-", "nuvola", "Nuvola", "pictogram", "Pictogram",
|
||||
"OOjs", "Ambox", "Portal-", "Disambig",
|
||||
]
|
||||
|
||||
HEADERS = {
|
||||
"User-Agent": "FLUJOS-Project/1.0 (https://gitea.laenre.net/hacklab/FLUJOS; educational research)"
|
||||
}
|
||||
|
||||
|
||||
# ── Funciones de búsqueda Wikipedia ───────────────────────────────────────────
|
||||
|
||||
def search_articles(tema: str, lang: str = "es", limit: int = 10) -> list[dict]:
|
||||
"""Busca artículos en Wikipedia por tema. Devuelve lista de {title, pageid}."""
|
||||
api_url = WIKI_API_EN if lang == "en" else WIKI_API_ES
|
||||
|
||||
params = {
|
||||
"action": "query",
|
||||
"list": "search",
|
||||
"srsearch": tema,
|
||||
"srlimit": limit,
|
||||
"format": "json",
|
||||
"srinfo": "totalhits",
|
||||
"srprop": "snippet|titlesnippet",
|
||||
}
|
||||
|
||||
resp = requests.get(api_url, params=params, headers=HEADERS, timeout=15)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
articles = []
|
||||
for item in data.get("query", {}).get("search", []):
|
||||
articles.append({
|
||||
"title": item["title"],
|
||||
"pageid": item["pageid"],
|
||||
"snippet": item.get("snippet", "").replace("<span class=\"searchmatch\">", "").replace("</span>", ""),
|
||||
})
|
||||
|
||||
return articles
|
||||
|
||||
|
||||
def get_article_images(title: str, lang: str = "es", limit: int = 20) -> list[str]:
|
||||
"""Obtiene lista de nombres de archivo de imágenes de un artículo Wikipedia."""
|
||||
api_url = WIKI_API_EN if lang == "en" else WIKI_API_ES
|
||||
|
||||
params = {
|
||||
"action": "query",
|
||||
"titles": title,
|
||||
"prop": "images",
|
||||
"imlimit": limit,
|
||||
"format": "json",
|
||||
}
|
||||
|
||||
resp = requests.get(api_url, params=params, headers=HEADERS, timeout=15)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
pages = data.get("query", {}).get("pages", {})
|
||||
image_titles = []
|
||||
for page in pages.values():
|
||||
for img in page.get("images", []):
|
||||
image_titles.append(img["title"])
|
||||
|
||||
return image_titles
|
||||
|
||||
|
||||
def get_image_info(file_title: str) -> dict | None:
|
||||
"""
|
||||
Obtiene info de una imagen via Wikimedia API:
|
||||
url directa de descarga, dimensiones, descripción, autor, licencia.
|
||||
"""
|
||||
# Normalizar namespace: Wikipedia ES usa "Archivo:", Commons usa "File:"
|
||||
for prefix in ("Archivo:", "Fichero:", "Image:", "Imagen:"):
|
||||
if file_title.startswith(prefix):
|
||||
file_title = "File:" + file_title[len(prefix):]
|
||||
break
|
||||
|
||||
params = {
|
||||
"action": "query",
|
||||
"titles": file_title,
|
||||
"prop": "imageinfo",
|
||||
"iiprop": "url|size|extmetadata",
|
||||
"iiurlwidth": 1200,
|
||||
"format": "json",
|
||||
}
|
||||
|
||||
resp = requests.get(WIKIMEDIA_API, params=params, headers=HEADERS, timeout=15)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
pages = data.get("query", {}).get("pages", {})
|
||||
for page in pages.values():
|
||||
infos = page.get("imageinfo", [])
|
||||
if not infos:
|
||||
return None
|
||||
info = infos[0]
|
||||
|
||||
ext_meta = info.get("extmetadata", {})
|
||||
return {
|
||||
"url": info.get("thumburl") or info.get("url"),
|
||||
"url_original": info.get("url"),
|
||||
"width": info.get("width", 0),
|
||||
"height": info.get("height", 0),
|
||||
"size_bytes": info.get("size", 0),
|
||||
"descripcion": ext_meta.get("ImageDescription", {}).get("value", ""),
|
||||
"autor": ext_meta.get("Artist", {}).get("value", ""),
|
||||
"licencia": ext_meta.get("LicenseShortName", {}).get("value", ""),
|
||||
"fecha_orig": ext_meta.get("DateTimeOriginal", {}).get("value", ""),
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# ── Filtros ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def should_skip(file_title: str, img_info: dict) -> tuple[bool, str]:
|
||||
"""Devuelve (skip, motivo) — True si la imagen debe descartarse."""
|
||||
filename = Path(file_title).name
|
||||
|
||||
# Extensión válida
|
||||
ext = Path(filename).suffix.lower()
|
||||
if ext not in VALID_EXTENSIONS:
|
||||
return True, f"extensión no válida: {ext}"
|
||||
|
||||
# Patrones a ignorar
|
||||
for pattern in SKIP_PATTERNS:
|
||||
if pattern in filename:
|
||||
return True, f"patrón ignorado: {pattern}"
|
||||
|
||||
# Tamaño mínimo
|
||||
if img_info.get("width", 0) < MIN_WIDTH or img_info.get("height", 0) < MIN_HEIGHT:
|
||||
return True, f"demasiado pequeña: {img_info.get('width')}x{img_info.get('height')}"
|
||||
|
||||
if img_info.get("size_bytes", 0) < MIN_BYTES:
|
||||
return True, f"archivo demasiado pequeño: {img_info.get('size_bytes')} bytes"
|
||||
|
||||
return False, ""
|
||||
|
||||
|
||||
# ── Descarga ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def download_image(url: str, dest_path: Path) -> bool:
|
||||
"""Descarga una imagen a dest_path. Devuelve True si éxito."""
|
||||
try:
|
||||
resp = requests.get(url, headers=HEADERS, timeout=30, stream=True)
|
||||
resp.raise_for_status()
|
||||
|
||||
with open(dest_path, "wb") as f:
|
||||
for chunk in resp.iter_content(chunk_size=8192):
|
||||
f.write(chunk)
|
||||
|
||||
# Verificar que es imagen válida con Pillow
|
||||
with Image.open(dest_path) as img:
|
||||
img.verify()
|
||||
|
||||
return True
|
||||
|
||||
except (UnidentifiedImageError, Exception) as e:
|
||||
if dest_path.exists():
|
||||
dest_path.unlink()
|
||||
return False
|
||||
|
||||
|
||||
# ── Pipeline principal ─────────────────────────────────────────────────────────
|
||||
|
||||
class WikipediaImageScraper:
|
||||
|
||||
def __init__(self, output_dir: Path = OUTPUT_BASE, lang: str = "es"):
|
||||
self.output_dir = output_dir
|
||||
self.lang = lang
|
||||
self.output_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.session = requests.Session()
|
||||
|
||||
def scrape_tema(self, tema: str, max_images: int = 30, max_articles: int = 10) -> list[dict]:
|
||||
"""
|
||||
Descarga imágenes de Wikipedia sobre un tema.
|
||||
|
||||
Args:
|
||||
tema: tema a buscar (ej: "cambio climático")
|
||||
max_images: máximo de imágenes a descargar
|
||||
max_articles: máximo de artículos a explorar
|
||||
|
||||
Returns:
|
||||
Lista de metadatos de imágenes descargadas
|
||||
"""
|
||||
# Carpeta por tema
|
||||
tema_slug = tema.lower().replace(" ", "_").replace("/", "-")[:40]
|
||||
tema_dir = self.output_dir / tema_slug
|
||||
tema_dir.mkdir(exist_ok=True)
|
||||
|
||||
print(f"\n[WikiScraper] Tema: '{tema}' | max_images={max_images} | lang={self.lang}")
|
||||
print("-" * 50)
|
||||
|
||||
# 1. Buscar artículos
|
||||
articles = search_articles(tema, lang=self.lang, limit=max_articles)
|
||||
print(f" Artículos encontrados: {len(articles)}")
|
||||
for a in articles[:5]:
|
||||
print(f" · {a['title']}")
|
||||
if len(articles) > 5:
|
||||
print(f" ... y {len(articles)-5} más")
|
||||
|
||||
downloaded = []
|
||||
total_downloaded = 0
|
||||
|
||||
for article in articles:
|
||||
if total_downloaded >= max_images:
|
||||
break
|
||||
|
||||
print(f"\n → {article['title']}")
|
||||
|
||||
# 2. Obtener imágenes del artículo
|
||||
try:
|
||||
img_titles = get_article_images(article["title"], lang=self.lang, limit=25)
|
||||
except Exception as e:
|
||||
print(f" ERROR obteniendo imágenes: {e}")
|
||||
continue
|
||||
|
||||
print(f" {len(img_titles)} imágenes en el artículo")
|
||||
|
||||
for img_title in img_titles:
|
||||
if total_downloaded >= max_images:
|
||||
break
|
||||
|
||||
# 3. Obtener info de la imagen
|
||||
try:
|
||||
img_info = get_image_info(img_title)
|
||||
time.sleep(0.2) # respetar rate limit Wikimedia
|
||||
except Exception as e:
|
||||
continue
|
||||
|
||||
if not img_info or not img_info.get("url"):
|
||||
continue
|
||||
|
||||
# 4. Filtrar
|
||||
skip, motivo = should_skip(img_title, img_info)
|
||||
if skip:
|
||||
continue
|
||||
|
||||
# 5. Nombre de archivo local
|
||||
original_name = Path(urlparse(img_info["url"]).path).name
|
||||
ext = Path(original_name).suffix.lower() or ".jpg"
|
||||
safe_name = f"{tema_slug}_{total_downloaded:03d}{ext}"
|
||||
dest_path = tema_dir / safe_name
|
||||
|
||||
# Saltar si ya existe
|
||||
if dest_path.exists():
|
||||
print(f" ↳ ya existe: {safe_name}")
|
||||
total_downloaded += 1
|
||||
continue
|
||||
|
||||
# 6. Descargar
|
||||
print(f" ↓ {safe_name} ({img_info['width']}x{img_info['height']} {img_info['size_bytes']//1024}KB)")
|
||||
success = download_image(img_info["url"], dest_path)
|
||||
|
||||
if success:
|
||||
meta = {
|
||||
"archivo": safe_name,
|
||||
"image_path": str(dest_path.resolve()),
|
||||
"tema": tema.lower(),
|
||||
"subtema": article["title"].lower(),
|
||||
"texto": article.get("snippet", ""),
|
||||
"descripcion_wiki": img_info.get("descripcion", ""),
|
||||
"autor": img_info.get("autor", ""),
|
||||
"licencia": img_info.get("licencia", ""),
|
||||
"url_original": img_info.get("url_original", ""),
|
||||
"width": img_info["width"],
|
||||
"height": img_info["height"],
|
||||
"size_bytes": img_info["size_bytes"],
|
||||
"source_type": "wikipedia_imagen",
|
||||
"lang": self.lang,
|
||||
"fecha": datetime.now().strftime("%Y-%m-%d"),
|
||||
"articulo_wiki": article["title"],
|
||||
"keywords": [], # se rellenan con image_analyzer.py
|
||||
}
|
||||
downloaded.append(meta)
|
||||
total_downloaded += 1
|
||||
else:
|
||||
print(f" ✗ fallo descarga")
|
||||
|
||||
print(f"\n[WikiScraper] Descargadas: {total_downloaded} imágenes en {tema_dir}")
|
||||
return downloaded
|
||||
|
||||
def scrape_multitema(self, temas: list[str], max_per_tema: int = 20) -> list[dict]:
|
||||
"""Descarga imágenes para múltiples temas."""
|
||||
all_results = []
|
||||
for tema in temas:
|
||||
results = self.scrape_tema(tema, max_images=max_per_tema)
|
||||
all_results.extend(results)
|
||||
time.sleep(1) # pausa entre temas
|
||||
return all_results
|
||||
|
||||
def save_metadata(self, metadata: list[dict], json_path: Path = None) -> Path:
|
||||
"""Guarda metadatos en JSON."""
|
||||
if json_path is None:
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
json_path = self.output_dir / f"metadata_{ts}.json"
|
||||
|
||||
with open(json_path, "w", encoding="utf-8") as f:
|
||||
json.dump(metadata, f, ensure_ascii=False, indent=2)
|
||||
|
||||
print(f"[WikiScraper] Metadatos guardados: {json_path}")
|
||||
return json_path
|
||||
|
||||
def save_to_mongo(self, metadata: list[dict]) -> dict:
|
||||
"""Guarda metadatos en MongoDB colección 'imagenes_wiki'."""
|
||||
from mongo_helper import MongoHelper
|
||||
mongo = MongoHelper()
|
||||
|
||||
if not mongo.is_available():
|
||||
print("[WikiScraper] MongoDB no disponible — solo JSON local")
|
||||
return {"inserted": 0, "updated": 0}
|
||||
|
||||
# Usar colección imagenes_wiki para no mezclar con imagenes analizadas
|
||||
db = mongo.connect()
|
||||
from pymongo import UpdateOne
|
||||
col = db["imagenes_wiki"]
|
||||
col.create_index("archivo", unique=True)
|
||||
|
||||
ops = [
|
||||
UpdateOne({"archivo": doc["archivo"]}, {"$set": doc}, upsert=True)
|
||||
for doc in metadata
|
||||
]
|
||||
if ops:
|
||||
result = col.bulk_write(ops)
|
||||
stats = {"inserted": result.upserted_count, "updated": result.modified_count}
|
||||
else:
|
||||
stats = {"inserted": 0, "updated": 0}
|
||||
|
||||
print(f"[WikiScraper] MongoDB imagenes_wiki → {stats}")
|
||||
mongo.disconnect()
|
||||
return stats
|
||||
|
||||
|
||||
# ── CLI ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Temas de FLUJOS por defecto
|
||||
TEMAS_FLUJOS = [
|
||||
"cambio climático",
|
||||
"geopolítica conflictos",
|
||||
"seguridad internacional espionaje",
|
||||
"libertad de prensa periodismo",
|
||||
"corporaciones poder económico",
|
||||
"populismo extremismo",
|
||||
"desinformación redes sociales",
|
||||
"privacidad vigilancia masiva",
|
||||
"biodiversidad medioambiente",
|
||||
"inteligencia artificial algoritmos",
|
||||
]
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Descarga imágenes de Wikipedia por tema")
|
||||
|
||||
group = parser.add_mutually_exclusive_group(required=True)
|
||||
group.add_argument("--tema", help="Tema único a buscar (ej: 'cambio climático')")
|
||||
group.add_argument("--temas", help="Fichero .txt con un tema por línea")
|
||||
group.add_argument("--flujos", action="store_true", help="Usar los temas de FLUJOS por defecto")
|
||||
|
||||
parser.add_argument("--max", type=int, default=20, help="Máximo imágenes por tema (default: 20)")
|
||||
parser.add_argument("--lang", default="es", help="Idioma Wikipedia: es | en (default: es)")
|
||||
parser.add_argument("--output", default=str(OUTPUT_BASE), help="Carpeta de destino")
|
||||
parser.add_argument("--mongo", action="store_true", help="Guardar metadatos en MongoDB")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
scraper = WikipediaImageScraper(output_dir=Path(args.output), lang=args.lang)
|
||||
|
||||
# Determinar lista de temas
|
||||
if args.flujos:
|
||||
temas = TEMAS_FLUJOS
|
||||
elif args.temas:
|
||||
with open(args.temas, encoding="utf-8") as f:
|
||||
temas = [l.strip() for l in f if l.strip()]
|
||||
else:
|
||||
temas = [args.tema]
|
||||
|
||||
# Ejecutar
|
||||
if len(temas) == 1:
|
||||
metadata = scraper.scrape_tema(temas[0], max_images=args.max)
|
||||
else:
|
||||
metadata = scraper.scrape_multitema(temas, max_per_tema=args.max)
|
||||
|
||||
# Guardar resultados
|
||||
if metadata:
|
||||
json_path = scraper.save_metadata(metadata)
|
||||
print(f"\n Total imágenes descargadas: {len(metadata)}")
|
||||
print(f" JSON: {json_path}")
|
||||
|
||||
if args.mongo:
|
||||
scraper.save_to_mongo(metadata)
|
||||
else:
|
||||
print("\n No se descargaron 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>
|
||||
385
VISUALIZACION/public/demos/demo_wiki_images.html
Normal file
385
VISUALIZACION/public/demos/demo_wiki_images.html
Normal file
|
|
@ -0,0 +1,385 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>FLUJOS — Demo: Wikipedia Images</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.95) 0%, transparent 100%);
|
||||
display: flex; align-items: center; gap: 14px;
|
||||
}
|
||||
#header h1 { font-size: 1em; color: #39ff14; letter-spacing: 4px; text-shadow: 0 0 10px #39ff14; }
|
||||
.tag { font-size: 0.6em; padding: 3px 8px; border: 1px solid #ff4500; color: #ff4500; letter-spacing: 2px; }
|
||||
.tag2 { font-size: 0.6em; padding: 3px 8px; border: 1px solid #555; color: #555; letter-spacing: 2px; }
|
||||
|
||||
/* Panel de detalle */
|
||||
#detail {
|
||||
position: fixed; bottom: 0; right: 0; z-index: 10;
|
||||
width: 340px;
|
||||
background: rgba(0,0,0,0.95);
|
||||
border-left: 1px solid #222;
|
||||
border-top: 1px solid #222;
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
max-height: 60vh;
|
||||
}
|
||||
#detail img {
|
||||
width: 100%; max-height: 200px;
|
||||
object-fit: cover; display: block;
|
||||
}
|
||||
#detail .detail-body { padding: 14px 16px; overflow-y: auto; }
|
||||
#detail h3 { font-size: 0.8em; color: #fff; margin-bottom: 6px; line-height: 1.4; }
|
||||
#detail .badge {
|
||||
display: inline-block; font-size: 0.6em; padding: 2px 8px;
|
||||
border: 1px solid #ff4500; color: #ff4500; letter-spacing: 2px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
#detail p { font-size: 0.7em; color: #777; line-height: 1.6; margin-bottom: 8px; }
|
||||
#detail .meta { font-size: 0.62em; color: #444; line-height: 1.8; }
|
||||
#detail .meta span { color: #666; }
|
||||
#detail .close {
|
||||
position: absolute; top: 8px; right: 10px;
|
||||
background: rgba(0,0,0,0.7); border: none;
|
||||
color: #555; font-family: inherit; font-size: 0.8em;
|
||||
cursor: pointer; padding: 2px 6px; z-index: 1;
|
||||
}
|
||||
#detail .close:hover { color: #fff; }
|
||||
|
||||
/* Stats */
|
||||
#stats {
|
||||
position: fixed; bottom: 20px; left: 20px; z-index: 10;
|
||||
font-size: 0.62em; color: #333; line-height: 2;
|
||||
}
|
||||
#stats span { color: #555; }
|
||||
|
||||
/* Tooltip custom */
|
||||
.node-tooltip {
|
||||
position: fixed; z-index: 20; pointer-events: none;
|
||||
background: rgba(0,0,0,0.9); border: 1px solid #333;
|
||||
padding: 6px 10px; font-size: 0.7em; color: #fff;
|
||||
max-width: 180px; display: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="graph"></div>
|
||||
|
||||
<div id="header">
|
||||
<h1>FLUJOS</h1>
|
||||
<span class="tag">CAMBIO CLIMÁTICO</span>
|
||||
<span class="tag2">Wikipedia Images · 15 nodos</span>
|
||||
</div>
|
||||
|
||||
<div id="detail">
|
||||
<button class="close" onclick="document.getElementById('detail').style.display='none'">✕</button>
|
||||
<img id="d-img" src="" alt="">
|
||||
<div class="detail-body">
|
||||
<h3 id="d-title"></h3>
|
||||
<span class="badge">CAMBIO CLIMÁTICO</span>
|
||||
<p id="d-desc"></p>
|
||||
<div class="meta">
|
||||
<div>ARTÍCULO <span id="d-articulo"></span></div>
|
||||
<div>LICENCIA <span id="d-licencia"></span></div>
|
||||
<div>AUTOR <span id="d-autor"></span></div>
|
||||
<div>TAMAÑO <span id="d-size"></span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="stats">
|
||||
<div>NODOS <span>15</span></div>
|
||||
<div>TEMA <span>cambio climático</span></div>
|
||||
<div>FUENTE <span>Wikipedia / Wikimedia Commons</span></div>
|
||||
</div>
|
||||
|
||||
<div id="node-tooltip" class="node-tooltip"></div>
|
||||
|
||||
<script type="module">
|
||||
import ForceGraph3D from '3d-force-graph';
|
||||
import * as THREE from 'three';
|
||||
|
||||
// ── Datos: imágenes de Wikipedia sobre cambio climático ─────────────────────
|
||||
const WIKI_NODES = [
|
||||
{
|
||||
id: "cambio_climático_000",
|
||||
archivo: "cambio_climático_000.jpg",
|
||||
img: "/images/wiki/cambio_climático_000.jpg",
|
||||
titulo: "Vista aérea del hielo en Nunavut",
|
||||
articulo: "Cambio climático",
|
||||
descripcion: "Vista aérea del borde del hielo en Nunavut, Canadá. Representa el deshielo polar como consecuencia del cambio climático.",
|
||||
autor: "Doc Searls",
|
||||
licencia: "CC BY-SA 2.0",
|
||||
width: 3504, height: 2336,
|
||||
grupo: "fotografia",
|
||||
},
|
||||
{
|
||||
id: "cambio_climático_001",
|
||||
archivo: "cambio_climático_001.png",
|
||||
img: "/images/wiki/cambio_climático_001.png",
|
||||
titulo: "Variaciones históricas de CO₂",
|
||||
articulo: "Cambio climático",
|
||||
descripcion: "Historia y futuro de la concentración de CO₂ en la atmósfera. Escala logarítmica mostrando los últimos 100 millones de años.",
|
||||
autor: "Hannes Grobe",
|
||||
licencia: "CC BY-SA 2.5",
|
||||
width: 1155, height: 806,
|
||||
grupo: "grafico",
|
||||
},
|
||||
{
|
||||
id: "cambio_climático_002",
|
||||
archivo: "cambio_climático_002.png",
|
||||
img: "/images/wiki/cambio_climático_002.png",
|
||||
titulo: "Factores del cambio climático",
|
||||
articulo: "Cambio climático",
|
||||
descripcion: "Esquema ilustrativo de los principales factores que afectan al cambio climático.",
|
||||
autor: "Medium69 / Ortisa",
|
||||
licencia: "CC BY-SA 4.0",
|
||||
width: 600, height: 582,
|
||||
grupo: "esquema",
|
||||
},
|
||||
{
|
||||
id: "cambio_climático_003",
|
||||
archivo: "cambio_climático_003.jpg",
|
||||
img: "/images/wiki/cambio_climático_003.jpg",
|
||||
titulo: "Consenso científico sobre el clima",
|
||||
articulo: "Cambio climático",
|
||||
descripcion: "Gráfico de Cook et al. (2016) ilustrando los resultados de siete estudios de consenso climático.",
|
||||
autor: "Skeptical Science",
|
||||
licencia: "CC BY-SA 3.0",
|
||||
width: 1920, height: 1080,
|
||||
grupo: "grafico",
|
||||
},
|
||||
{
|
||||
id: "cambio_climático_004",
|
||||
archivo: "cambio_climático_004.png",
|
||||
img: "/images/wiki/cambio_climático_004.png",
|
||||
titulo: "Emisiones CO₂ y temperatura París",
|
||||
articulo: "Cambio climático",
|
||||
descripcion: "Emisiones globales de CO₂ y resultados probabilísticos de temperatura según los anuncios previos a la conferencia de París.",
|
||||
autor: "Jae Edmonds / PNNL",
|
||||
licencia: "Public domain",
|
||||
width: 1920, height: 806,
|
||||
grupo: "grafico",
|
||||
},
|
||||
{
|
||||
id: "cambio_climático_005",
|
||||
archivo: "cambio_climático_005.png",
|
||||
img: "/images/wiki/cambio_climático_005.png",
|
||||
titulo: "Escenarios de emisiones futuras",
|
||||
articulo: "Cambio climático",
|
||||
descripcion: "Proyecciones de emisiones globales de gases de efecto invernadero según distintos escenarios de política climática.",
|
||||
autor: "Hannah Ritchie y Max Roser",
|
||||
licencia: "CC BY 4.0",
|
||||
width: 2041, height: 1422,
|
||||
grupo: "grafico",
|
||||
},
|
||||
{
|
||||
id: "cambio_climático_006",
|
||||
archivo: "cambio_climático_006.png",
|
||||
img: "/images/wiki/cambio_climático_006.png",
|
||||
titulo: "Gases efecto invernadero por sector",
|
||||
articulo: "Cambio climático",
|
||||
descripcion: "Emisión de gases de efecto invernadero desglosada por sector económico.",
|
||||
autor: "Robert A. Rohde / Rojasyesid",
|
||||
licencia: "CC BY-SA 3.0",
|
||||
width: 617, height: 584,
|
||||
grupo: "grafico",
|
||||
},
|
||||
{
|
||||
id: "cambio_climático_007",
|
||||
archivo: "cambio_climático_007.jpg",
|
||||
img: "/images/wiki/cambio_climático_007.jpg",
|
||||
titulo: "Temperatura de la Corriente del Golfo",
|
||||
articulo: "Cambio climático",
|
||||
descripcion: "Imagen en falso color de la temperatura de la Corriente del Golfo. La corriente cálida es visible contra las aguas más frías circundantes.",
|
||||
autor: "NASA / MODIS Ocean Group",
|
||||
licencia: "Public domain",
|
||||
width: 538, height: 566,
|
||||
grupo: "satelite",
|
||||
},
|
||||
{
|
||||
id: "cambio_climático_008",
|
||||
archivo: "cambio_climático_008.png",
|
||||
img: "/images/wiki/cambio_climático_008.png",
|
||||
titulo: "Mapa de Pangea",
|
||||
articulo: "Cambio climático",
|
||||
descripcion: "Mapa del supercontinente Pangea, relevante para entender la historia climática de la Tierra a escala geológica.",
|
||||
autor: "en:User:Kieff / user:tsca",
|
||||
licencia: "CC BY-SA 3.0",
|
||||
width: 772, height: 869,
|
||||
grupo: "esquema",
|
||||
},
|
||||
{
|
||||
id: "cambio_climático_009",
|
||||
archivo: "cambio_climático_009.jpg",
|
||||
img: "/images/wiki/cambio_climático_009.jpg",
|
||||
titulo: "Precesión y estaciones",
|
||||
articulo: "Cambio climático",
|
||||
descripcion: "Diagrama de la precesión de la Tierra y su efecto sobre las estaciones, factor clave en los ciclos climáticos naturales.",
|
||||
autor: "Wikimedia Commons",
|
||||
licencia: "CC BY-SA 3.0",
|
||||
width: 593, height: 445,
|
||||
grupo: "esquema",
|
||||
},
|
||||
{
|
||||
id: "cambio_climático_010",
|
||||
archivo: "cambio_climático_010.png",
|
||||
img: "/images/wiki/cambio_climático_010.png",
|
||||
titulo: "Proyecciones nivel del mar",
|
||||
articulo: "Cambio climático",
|
||||
descripcion: "Proyecciones del aumento del nivel medio del mar según Parris et al. (2012), traducidas al español.",
|
||||
autor: "Enescot / Hiperfelix",
|
||||
licencia: "CC0",
|
||||
width: 1355, height: 761,
|
||||
grupo: "grafico",
|
||||
},
|
||||
{
|
||||
id: "cambio_climático_011",
|
||||
archivo: "cambio_climático_011.jpg",
|
||||
img: "/images/wiki/cambio_climático_011.jpg",
|
||||
titulo: "Palma de aceite en Riau, Sumatra",
|
||||
articulo: "Cambio climático",
|
||||
descripcion: "Concesión de palma de aceite en Riau, Sumatra (Indonesia). La deforestación tropical es un factor relevante en el cambio climático.",
|
||||
autor: "Hayden / Flickr",
|
||||
licencia: "CC BY 2.0",
|
||||
width: 2048, height: 1365,
|
||||
grupo: "fotografia",
|
||||
},
|
||||
{
|
||||
id: "cambio_climático_012",
|
||||
archivo: "cambio_climático_012.png",
|
||||
img: "/images/wiki/cambio_climático_012.png",
|
||||
titulo: "Sistema climático terrestre",
|
||||
articulo: "Cambio climático",
|
||||
descripcion: "Diagrama del sistema climático terrestre mostrando las interacciones entre atmósfera, hidrosfera, criosfera y biosfera.",
|
||||
autor: "Martín, Rodrigo",
|
||||
licencia: "CC BY-SA 3.0",
|
||||
width: 1164, height: 612,
|
||||
grupo: "esquema",
|
||||
},
|
||||
{
|
||||
id: "cambio_climático_013",
|
||||
archivo: "cambio_climático_013.png",
|
||||
img: "/images/wiki/cambio_climático_013.png",
|
||||
titulo: "Ciclos solares e irradiancia",
|
||||
articulo: "Cambio climático",
|
||||
descripcion: "Los últimos tres ciclos solares medidos en irradiancia solar, manchas solares, actividad de erupciones y flujo de radio.",
|
||||
autor: "Robert A. Rohde",
|
||||
licencia: "CC BY-SA 3.0",
|
||||
width: 700, height: 466,
|
||||
grupo: "grafico",
|
||||
},
|
||||
{
|
||||
id: "cambio_climático_014",
|
||||
archivo: "cambio_climático_014.jpg",
|
||||
img: "/images/wiki/cambio_climático_014.jpg",
|
||||
titulo: "Activismo climático juvenil (TedX)",
|
||||
articulo: "Cambio climático e infancia",
|
||||
descripcion: "Aayan Aggarwal durante su charla TedX sobre cambio climático e infancia en el NMIMS de Shirpur.",
|
||||
autor: "Dk4588",
|
||||
licencia: "CC BY-SA 4.0",
|
||||
width: 791, height: 640,
|
||||
grupo: "fotografia",
|
||||
},
|
||||
];
|
||||
|
||||
// ── Conexiones temáticas entre imágenes ─────────────────────────────────────
|
||||
const GRUPO_COLORS = {
|
||||
fotografia: '#ff4500',
|
||||
grafico: '#39ff14',
|
||||
esquema: '#00fff2',
|
||||
satelite: '#cc44ff',
|
||||
};
|
||||
|
||||
// Conectar imágenes del mismo grupo y artículo
|
||||
const links = [];
|
||||
for (let i = 0; i < WIKI_NODES.length; i++) {
|
||||
for (let j = i + 1; j < WIKI_NODES.length; j++) {
|
||||
const a = WIKI_NODES[i], b = WIKI_NODES[j];
|
||||
if (a.articulo === b.articulo) {
|
||||
links.push({ source: a.id, target: b.id, value: a.grupo === b.grupo ? 80 : 40 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const graphData = { nodes: WIKI_NODES, links };
|
||||
|
||||
// ── Render ───────────────────────────────────────────────────────────────────
|
||||
const elem = document.getElementById('graph');
|
||||
const textureCache = {};
|
||||
const loader = new THREE.TextureLoader();
|
||||
|
||||
function getTexture(url) {
|
||||
if (!textureCache[url]) {
|
||||
const t = loader.load(url);
|
||||
t.colorSpace = THREE.SRGBColorSpace;
|
||||
textureCache[url] = t;
|
||||
}
|
||||
return textureCache[url];
|
||||
}
|
||||
|
||||
const Graph = ForceGraph3D()(elem)
|
||||
.backgroundColor('#000000')
|
||||
.nodeLabel(node => `<div style="font-family:Fira Code,monospace;font-size:11px;color:${GRUPO_COLORS[node.grupo]};background:rgba(0,0,0,0.8);padding:4px 8px;border:1px solid ${GRUPO_COLORS[node.grupo]}33">${node.titulo}</div>`)
|
||||
.nodeColor(node => GRUPO_COLORS[node.grupo] || '#fff')
|
||||
.nodeVal(node => {
|
||||
const area = node.width * node.height;
|
||||
return Math.max(1.5, Math.min(6, area / 500000));
|
||||
})
|
||||
.linkColor(link => {
|
||||
const val = link.value || 40;
|
||||
return val > 60 ? 'rgba(57,255,20,0.4)' : 'rgba(57,255,20,0.15)';
|
||||
})
|
||||
.linkWidth(link => link.value > 60 ? 1.2 : 0.4)
|
||||
.nodeThreeObject(node => {
|
||||
const texture = getTexture(node.img);
|
||||
const material = new THREE.SpriteMaterial({ map: texture, transparent: true });
|
||||
const sprite = new THREE.Sprite(material);
|
||||
// Escalar manteniendo aspect ratio aproximado
|
||||
const ratio = node.width / node.height;
|
||||
const baseSize = 14;
|
||||
sprite.scale.set(baseSize * Math.min(ratio, 1.6), baseSize / Math.max(ratio, 0.6));
|
||||
return sprite;
|
||||
})
|
||||
.onNodeClick(node => {
|
||||
const panel = document.getElementById('detail');
|
||||
document.getElementById('d-img').src = node.img;
|
||||
document.getElementById('d-title').textContent = node.titulo;
|
||||
document.getElementById('d-desc').textContent = node.descripcion;
|
||||
document.getElementById('d-articulo').textContent = node.articulo;
|
||||
document.getElementById('d-licencia').textContent = node.licencia;
|
||||
document.getElementById('d-autor').textContent = node.autor;
|
||||
document.getElementById('d-size').textContent = `${node.width}×${node.height}px`;
|
||||
panel.style.display = 'flex';
|
||||
})
|
||||
.onNodeHover(node => { elem.style.cursor = node ? 'pointer' : null; })
|
||||
.graphData(graphData);
|
||||
|
||||
Graph.d3Force('charge').strength(-250);
|
||||
setTimeout(() => Graph.zoomToFit(800, 80), 1500);
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
Graph.width(elem.clientWidth).height(elem.clientHeight);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
2
VISUALIZACION/public/images/wiki/.gitignore
vendored
Normal file
2
VISUALIZACION/public/images/wiki/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
*
|
||||
!.gitignore
|
||||
Loading…
Add table
Add a link
Reference in a new issue