Compare commits

..

3 commits

Author SHA1 Message Date
SITO
b80c28c4f2 feat: demo visualización imágenes Wikipedia + gitignore wiki/
- demo_wiki_images.html: grafo 3D con las 15 imágenes de Wikipedia
  sobre cambio climático como nodos sprite, panel de detalle al click
- images/wiki/.gitignore: excluye imágenes binarias del repo

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 20:48:18 +02:00
SITO
0975f44a0e feat: scraper de imágenes Wikipedia + debug
- wikipedia_image_scraper.py: descarga imágenes de Wikipedia por tema
  usando Wikimedia API, con filtros de tamaño/extensión y metadatos
  (autor, licencia, dimensiones, artículo origen)
- debug_wiki.py: script de diagnóstico para verificar API responses
- .gitignore: excluye output/ y __pycache__

Fix: normalizar prefijo "Archivo:" → "File:" para Wikimedia Commons API

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 19:41:11 +02:00
SITO
b992e25f8f feat: pipeline de imágenes + demos de visualización 3D
- BACK_BACK/IMAGENES/: nueva pipeline para análisis de imágenes con VLM
  - image_analyzer.py: imagen → keywords via ollama
  - image_comparator.py: TF-IDF similitud keywords vs corpus
  - mongo_helper.py: CRUD colecciones imagenes y comparaciones
  - pipeline_pruebas.py: script end-to-end con CLI
  - requirements_imagenes.txt

- BACK_BACK/FLUJOS_APP_PRUEBAS.js: servidor Express ligero (port 3001)
  sin MongoDB para testear los demos de visualización

- VISUALIZACION/public/demos/: tres demos de 3d-force-graph
  - demo_text_nodes.html: nodos como texto (three-spritetext)
  - demo_img_nodes.html: nodos como imágenes (THREE.Sprite)
  - demo_mixed_nodes.html: cards HTML en 3D (CSS2DRenderer)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 22:28:34 +01:00
14 changed files with 2663 additions and 0 deletions

View 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
View file

@ -0,0 +1,3 @@
__pycache__/
output/
*.pyc

View 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})")

View 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)")

View 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),
}

View 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

View 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,
)

View 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

View 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.")

View 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 =&gt; 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>

View 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 =&gt; 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>

View 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 =&gt; 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>

View 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 &nbsp;<span id="d-articulo"></span></div>
<div>LICENCIA &nbsp;<span id="d-licencia"></span></div>
<div>AUTOR &nbsp;&nbsp;&nbsp;<span id="d-autor"></span></div>
<div>TAMAÑO &nbsp;&nbsp;<span id="d-size"></span></div>
</div>
</div>
</div>
<div id="stats">
<div>NODOS &nbsp;<span>15</span></div>
<div>TEMA &nbsp;&nbsp;<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>

View file

@ -0,0 +1,2 @@
*
!.gitignore