feat: mejoras de navegación y visualización en sub-páginas

- sub-nav.css: barra de navegación compartida con botones estilo home
  (fondos por sección, texto negro, borde negro, hover neón, fix WebGL z-index)
- Móvil: panel de detalle ocupa 50% inferior, grafo permanece visible arriba
- Imágenes de nodo duplicadas en tamaño (160×104), detail-img a 28vh
- output_*.js: función showEgoGraph — filtra el grafo al ego-network del nodo
  seleccionado; botón "Ver solo conexiones" (solo si hay relaciones);
  botón flotante "← Volver al grafo completo"
- int-sec.js: eliminado makeTextSprite, igualado al resto (nulos para no-imagen)
- Eliminado footer de los 5 sub-HTML
- image_analyzer.py: cuantización int4 (NF4) para Qwen3-VL-8B → 6.4 GB VRAM

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
CAPITANSITO 2026-04-28 20:23:22 +02:00
parent 778db90d78
commit 9b67e2915b
15 changed files with 2070 additions and 1083 deletions

View file

@ -19,10 +19,10 @@ app.use(
helmet.contentSecurityPolicy({ helmet.contentSecurityPolicy({
directives: { directives: {
defaultSrc: ["'self'"], defaultSrc: ["'self'"],
scriptSrc: ["'self'", 'https://unpkg.com', 'https://cdnjs.cloudflare.com'], scriptSrc: ["'self'", "'unsafe-inline'", 'https://cdn.jsdelivr.net', 'https://esm.sh', 'https://unpkg.com', 'https://cdnjs.cloudflare.com'],
styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'], styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'],
imgSrc: ["'self'", 'data:', 'blob:'], imgSrc: ["'self'", 'data:', 'blob:'],
connectSrc: ["'self'"], connectSrc: ["'self'", 'https://esm.sh'],
fontSrc: ["'self'", 'https://fonts.gstatic.com'], fontSrc: ["'self'", 'https://fonts.gstatic.com'],
frameAncestors: ["'none'"], frameAncestors: ["'none'"],
objectSrc: ["'none'"], objectSrc: ["'none'"],
@ -150,10 +150,8 @@ app.get('/api/data', async (req, res) => {
if (subtematica) nodesQuery.subtema = subtematica; if (subtematica) nodesQuery.subtema = subtematica;
if (regexKw) nodesQuery.texto = { $regex: regexKw, $options: 'i' }; if (regexKw) nodesQuery.texto = { $regex: regexKw, $options: 'i' };
if (fechaGte) nodesQuery.fecha = { $gte: fechaGte, $lte: fechaLte }; if (fechaGte) nodesQuery.fecha = { $gte: fechaGte, $lte: fechaLte };
// Ejecutar la consulta y obtener los resultados de las colecciones // Imágenes: proporcional a nodosLimit (1/3), mínimo 3 para siempre mostrar algo
console.log('Ejecutando consulta para nodos en colecciones...'); const imagenesLimit = Math.max(3, Math.floor(nodosLimit / 3));
// Límite de imágenes: un tercio del total para no saturar el grafo
const imagenesLimit = Math.floor(nodosLimit / 3);
const [wikipediaNodes, noticiasNodes, torrentsNodes, imagenesNodes, imagenesWikiNodes] = await Promise.all([ const [wikipediaNodes, noticiasNodes, torrentsNodes, imagenesNodes, imagenesWikiNodes] = await Promise.all([
wikipediaCollection.find(nodesQuery).limit(nodosLimit).toArray(), wikipediaCollection.find(nodesQuery).limit(nodosLimit).toArray(),
@ -162,15 +160,11 @@ app.get('/api/data', async (req, res) => {
imagenesCollection.find(nodesQuery).limit(imagenesLimit).toArray(), imagenesCollection.find(nodesQuery).limit(imagenesLimit).toArray(),
imagenesWikiCollection.find(nodesQuery).limit(imagenesLimit).toArray(), imagenesWikiCollection.find(nodesQuery).limit(imagenesLimit).toArray(),
]); ]);
console.log('Nodos Wikipedia:', wikipediaNodes.length, '| Noticias:', noticiasNodes.length,
'| Torrents:', torrentsNodes.length, '| Imágenes:', imagenesNodes.length,
'| Imágenes Wiki:', imagenesWikiNodes.length);
// Preferir imagenes analizadas (con keywords); si no hay, usar imagenes_wiki // Preferir imagenes analizadas (con keywords); si no hay, usar imagenes_wiki
const imgNodes = imagenesNodes.length > 0 ? imagenesNodes : imagenesWikiNodes; const imgNodes = imagenesNodes.length > 0 ? imagenesNodes : imagenesWikiNodes;
const nodes = [...wikipediaNodes, ...noticiasNodes, ...torrentsNodes]; const nodes = [...wikipediaNodes, ...noticiasNodes, ...torrentsNodes];
console.log('Total nodos texto:', nodes.length, '| Nodos imagen:', imgNodes.length);
// Nodos de texto // Nodos de texto
const formattedNodes = nodes.map((result) => ({ const formattedNodes = nodes.map((result) => ({
@ -179,21 +173,28 @@ app.get('/api/data', async (req, res) => {
tema: result.tema || 'sin tema', tema: result.tema || 'sin tema',
content: result.texto || '', content: result.texto || '',
fecha: result.fecha || '', fecha: result.fecha || '',
fuente: result.fuente || result.autor || result.source || '',
type: 'texto', type: 'texto',
})); }));
// Nodos de imagen — image_url construida desde image_path absoluto // Nodos de imagen — image_url construida desde image_path absoluto
const WIKI_IMAGES_BASE = '/var/www/theflows.net/flujos/FLUJOS_DATOS/IMAGENES/output/wiki_images/'; const WIKI_IMAGES_BASE = '/var/www/theflows.net/flujos/FLUJOS_DATOS/IMAGENES/output/wiki_images/';
const imgFormattedNodes = imgNodes.map((result) => { const imgFormattedNodes = imgNodes.map((result) => {
const relativePath = result.image_path let relativePath = null;
? result.image_path.replace(WIKI_IMAGES_BASE, '') if (result.image_path && result.image_path.startsWith(WIKI_IMAGES_BASE)) {
: null; const rel = result.image_path.slice(WIKI_IMAGES_BASE.length);
// Reject paths with traversal sequences or absolute paths
if (rel && !rel.includes('..') && !rel.startsWith('/')) {
relativePath = rel;
}
}
return { return {
id: result.archivo.trim(), id: result.archivo.trim(),
group: result.subtema || result.tema || 'imagen', group: result.subtema || result.tema || 'imagen',
tema: result.tema || 'sin tema', tema: result.tema || 'sin tema',
content: result.texto || result.descripcion_wiki || '', content: result.texto || result.descripcion_wiki || '',
fecha: result.fecha || '', fecha: result.fecha || '',
fuente: result.fuente || result.autor || result.source || '',
type: 'imagen', type: 'imagen',
image_url: relativePath ? `/wiki-images/${relativePath}` : null, image_url: relativePath ? `/wiki-images/${relativePath}` : null,
label: result.subtema || result.tema || result.archivo, label: result.subtema || result.tema || result.archivo,
@ -201,47 +202,186 @@ app.get('/api/data', async (req, res) => {
}).filter(n => n.image_url); }).filter(n => n.image_url);
const allNodes = [...formattedNodes, ...imgFormattedNodes]; const allNodes = [...formattedNodes, ...imgFormattedNodes];
const formattedNodes_final = allNodes; // alias para claridad const formattedNodes_final = allNodes;
// Obtener los IDs de todos los nodos (texto + imagen)
const nodeIds = formattedNodes_final.map(node => node.id); const nodeIds = formattedNodes_final.map(node => node.id);
console.log('IDs de nodos:', nodeIds);
// Construir la consulta para obtener los enlaces relacionados con los nodos obtenidos // Links texto-texto: filtro por complejidad del usuario.
// Links imagen-texto (source1_type='imagen'): umbral fijo de 3% porque la similitud
// TF-IDF keyword↔texto es estructuralmente baja (~3-10%) y quedarían siempre ocultos.
const IMG_UMBRAL = 3.0;
const linksQuery = { const linksQuery = {
porcentaje_similitud: { $gte: porcentajeSimilitudMin },
noticia1: { $in: nodeIds }, noticia1: { $in: nodeIds },
noticia2: { $in: nodeIds }, noticia2: { $in: nodeIds },
$or: [
{ porcentaje_similitud: { $gte: porcentajeSimilitudMin } },
{ porcentaje_similitud: { $gte: IMG_UMBRAL }, source1_type: 'imagen' },
],
}; };
console.log('Consulta para enlaces:', linksQuery);
// Ejecutar la consulta y obtener los enlaces
console.log('Ejecutando consulta para enlaces...');
const links = await comparacionesCollection.find(linksQuery).toArray(); const links = await comparacionesCollection.find(linksQuery).toArray();
console.log('Enlaces obtenidos:', links.length);
// Formatear los enlaces sin normalizar los IDs // Limitar links por nodo: cada nodo muestra solo sus top MAX conexiones más fuertes.
const formattedLinks = links.map((result) => ({ // Sin esto, los 100 nodos wikipedia del mismo tema se conectan todos entre sí
// (>8000 links) y forman una bola densa inmanejable.
const MAX_LINKS_PER_NODE = 8;
links.sort((a, b) => b.porcentaje_similitud - a.porcentaje_similitud);
const nodeDegree = new Map();
const selectedLinks = [];
for (const link of links) {
const n1 = link.noticia1.trim();
const n2 = link.noticia2.trim();
const d1 = nodeDegree.get(n1) || 0;
const d2 = nodeDegree.get(n2) || 0;
if (d1 < MAX_LINKS_PER_NODE && d2 < MAX_LINKS_PER_NODE) {
selectedLinks.push(link);
nodeDegree.set(n1, d1 + 1);
nodeDegree.set(n2, d2 + 1);
}
}
const formattedLinks = selectedLinks.map((result) => ({
source: result.noticia1.trim(), source: result.noticia1.trim(),
target: result.noticia2.trim(), target: result.noticia2.trim(),
value: result.porcentaje_similitud, value: result.porcentaje_similitud,
})); }));
// Enviar los datos al cliente en formato JSON
res.json({ nodes: formattedNodes_final, links: formattedLinks }); res.json({ nodes: formattedNodes_final, links: formattedLinks });
console.log('Datos enviados al cliente');
} catch (error) { } catch (error) {
console.error('Error al obtener datos:', error); console.error('Error al obtener datos:', error);
res.status(500).json({ error: 'Error al obtener datos' }); res.status(500).json({ error: 'Error al obtener datos' });
} }
}); });
// ── STATS: cálculo pesado → MongoDB, endpoint solo lee ─────────────────────────
const STATS_TEMAS = ['guerra global','inteligencia y seguridad','cambio climático','demografía y sociedad','economía y corporaciones'];
const STATS_TEXT_COLS = ['wikipedia','noticias','torrents','imagenes_wiki'];
async function computeAndSaveStats() {
if (!db) return;
console.log('[stats] Calculando stats...');
try {
const [wikipedia, noticias, torrents, imagenes, imagenes_wiki, comparaciones,
comp_imagen_total] = await Promise.all([
db.collection('wikipedia').countDocuments({}),
db.collection('noticias').countDocuments({}),
db.collection('torrents').countDocuments({}),
db.collection('imagenes').countDocuments({}),
db.collection('imagenes_wiki').countDocuments({}),
db.collection('comparaciones').estimatedDocumentCount(),
db.collection('comparaciones').countDocuments({ source1_type: 'imagen' }),
]);
const por_tema = {};
await Promise.all(STATS_TEMAS.map(async tema => {
const counts = await Promise.all(
STATS_TEXT_COLS.map(col => db.collection(col).countDocuments({ tema }))
);
por_tema[tema] = {};
STATS_TEXT_COLS.forEach((col, i) => { por_tema[tema][col] = counts[i]; });
}));
const subtemaRaw = await db.collection('wikipedia').aggregate([
{ $match: { tema: { $in: STATS_TEMAS } } },
{ $group: { _id: { tema: '$tema', subtema: '$subtema' }, n: { $sum: 1 } } },
{ $sort: { n: -1 } },
]).toArray();
const subtemas = {};
for (const doc of subtemaRaw) {
const { tema, subtema } = doc._id;
if (!subtemas[tema]) subtemas[tema] = [];
if (subtemas[tema].length < 10)
subtemas[tema].push({ subtema: subtema || 'sin subtema', n: doc.n });
}
const notSubRaw = await db.collection('noticias').aggregate([
{ $match: { tema: { $in: STATS_TEMAS } } },
{ $group: { _id: { tema: '$tema', subtema: '$subtema' }, n: { $sum: 1 } } },
{ $sort: { n: -1 } },
]).toArray();
const subtemas_noticias = {};
for (const doc of notSubRaw) {
const { tema, subtema } = doc._id;
if (!subtemas_noticias[tema]) subtemas_noticias[tema] = [];
if (subtemas_noticias[tema].length < 8)
subtemas_noticias[tema].push({ subtema: subtema || 'sin subtema', n: doc.n });
}
const compImgRaw = await db.collection('comparaciones').aggregate([
{ $match: { source1_type: 'imagen' } },
{ $group: {
_id: '$tema',
count: { $sum: 1 },
avg_sim: { $avg: '$porcentaje_similitud' },
max_sim: { $max: '$porcentaje_similitud' },
}},
]).toArray();
const comp_imagen = { total: comp_imagen_total, por_tema: {} };
for (const d of compImgRaw) {
comp_imagen.por_tema[d._id] = {
count: d.count,
avg_sim: Math.round(d.avg_sim * 10) / 10,
max_sim: Math.round(d.max_sim * 10) / 10,
};
}
// $sample sin filtro — 99.94% de docs son texto-texto
const simRaw = await db.collection('comparaciones').aggregate([
{ $sample: { size: 3000 } },
{ $group: { _id: null, avg_sim: { $avg: '$porcentaje_similitud' }, max_sim: { $max: '$porcentaje_similitud' } } },
]).toArray();
const comp_texto = simRaw[0]
? { avg_sim: Math.round(simRaw[0].avg_sim * 10) / 10, max_sim: Math.round(simRaw[0].max_sim * 10) / 10 }
: { avg_sim: 0, max_sim: 0 };
const snapshot = {
_id: 'stats',
computed_at: new Date(),
totales: { wikipedia, noticias, torrents, imagenes, imagenes_wiki, comparaciones },
por_tema, subtemas, subtemas_noticias, comp_imagen, comp_texto,
};
await db.collection('stats_cache').replaceOne({ _id: 'stats' }, snapshot, { upsert: true });
console.log('[stats] Stats guardadas en MongoDB');
} catch (e) {
console.error('[stats] Error calculando stats:', e.message);
}
}
// Calcular al arrancar (si no hay cache o tiene más de 6h) y luego cada 6h
async function initStats() {
if (!db) return;
const cached = await db.collection('stats_cache').findOne({ _id: 'stats' });
const sixHours = 6 * 60 * 60 * 1000;
if (!cached || !cached.computed_at || Date.now() - new Date(cached.computed_at).getTime() > sixHours) {
computeAndSaveStats(); // async, no bloquea el arranque
}
setInterval(computeAndSaveStats, sixHours);
}
app.get('/api/stats', async (req, res) => {
try {
if (!db) return res.status(500).json({ error: 'Sin conexión a BD' });
const cached = await db.collection('stats_cache').findOne({ _id: 'stats' });
if (!cached) return res.status(503).json({ error: 'Stats aún calculándose, intenta en unos minutos' });
const { _id, ...data } = cached;
res.json(data);
} catch (e) {
console.error('Error /api/stats:', e);
res.status(500).json({ error: 'Error al obtener estadísticas' });
}
});
// **Luego, definir las rutas de las páginas principales** // **Luego, definir las rutas de las páginas principales**
// Rutas para las páginas principales // Rutas para las páginas principales
app.get('/', (req, res) => { app.get('/', (req, res) => {
res.sendFile(path.join(__dirname, '../VISUALIZACION/public/index.html')); res.sendFile(path.join(__dirname, '../VISUALIZACION/public/index.html'));
}); });
app.get('/stats.html', (req, res) => {
res.sendFile(path.join(__dirname, '../VISUALIZACION/public/stats.html'));
});
app.get('/climate.html', (req, res) => { app.get('/climate.html', (req, res) => {
res.sendFile(path.join(__dirname, '../VISUALIZACION/public/climate.html')); res.sendFile(path.join(__dirname, '../VISUALIZACION/public/climate.html'));
}); });
@ -287,6 +427,7 @@ async function connectToMongoDB() {
await mongoClient.connect(); await mongoClient.connect();
db = mongoClient.db(dbName); db = mongoClient.db(dbName);
console.log('Conectado a MongoDB'); console.log('Conectado a MongoDB');
initStats();
} catch (error) { } catch (error) {
console.error('Error al conectar a MongoDB:', error); console.error('Error al conectar a MongoDB:', error);
process.exit(1); // Salir de la aplicación si no se puede conectar process.exit(1); // Salir de la aplicación si no se puede conectar

View file

@ -4,43 +4,120 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>CLIMATE</title> <title>CLIMATE</title>
<link rel="stylesheet" href="climate.css"> <link rel="stylesheet" href="climate.css">
<link rel="stylesheet" href="sub-nav.css">
<!-- Fuentes de Google Fonts --> <!-- Fuentes de Google Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;700&display=swap" rel="stylesheet">
<!-- Cargar 3d-force-graph (incluye Three.js) -->
<!-- CORRECTO: d3 primero, force-graph después -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/6.7.0/d3.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/d3/6.7.0/d3.min.js"></script>
<script src="https://unpkg.com/3d-force-graph"></script> <script type="importmap">
{ "imports": { "three": "https://esm.sh/three@0.179.0", "three/": "https://esm.sh/three@0.179.0/" } }
</script>
</head> </head>
<body> <body>
<nav> <nav class="top-nav">
<ul class="nav-links"> <div class="section-buttons">
<li><a href="climate.html" class="climate">CLIMATE</a></li>
</ul> <div class="section-item">
<a href="glob-war.html" class="section-btn glob-war-btn">GLOB-WAR</a>
</div>
<div class="section-item">
<a href="int-sec.html" class="section-btn int-sec-btn">INT-SEC</a>
</div>
<div class="section-item current">
<a href="climate.html" class="section-btn climate-btn">CLIMATE <span class="dropdown-arrow"></span></a>
<div class="section-dropdown">
<a href="#" onclick="setSubtema('cambio climático');return false;">Cambio Climático</a>
<a href="#" onclick="setSubtema('desastres naturales');return false;">Desastres Naturales</a>
<a href="#" onclick="setSubtema('conservación');return false;">Conservación</a>
<a href="#" onclick="setSubtema('energía renovable');return false;">Energía Renovable</a>
<a href="#" onclick="setSubtema('contaminacion');return false;">Escasez de Agua</a>
</div>
</div>
<div class="section-item">
<a href="eco-corp.html" class="section-btn eco-corp-btn">ECO-CORP</a>
</div>
<div class="section-item">
<a href="popl-up.html" class="section-btn popl-up-btn">POPL-UP</a>
</div>
</div>
</nav> </nav>
<main> <script>
<div id="climateContainer" style="position: absolute; width: 100%; height: 100%;"></div> function setSubtema(val) {
<div class="background"> var sel = document.getElementById('param2');
<img src="/images/flujos6.jpg"> if (sel) { sel.value = val; }
<img src="/images/flujos6.jpg"> var form = document.getElementById('paramForm');
<img src="/images/flujos6.jpg"> if (form) form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
<img src="/images/flujos6.jpg"> }
<img src="/images/flujos6.jpg"> document.addEventListener('DOMContentLoaded', function() {
<script> /* ── Dropdown touch (móvil) ── */
setTimeout(function() { var curr = document.querySelector('.section-item.current');
var fondo = document.querySelector('.background'); if (curr) {
fondo.classList.add('fade-out'); curr.querySelector('.section-btn').addEventListener('touchend', function(e) {
fondo.style.pointerEvents = 'none'; e.preventDefault();
}, 1000); curr.classList.toggle('open');
</script> });
document.addEventListener('touchend', function(e) {
if (!curr.contains(e.target)) curr.classList.remove('open');
});
}
/* ── Sidebar: botón toggle visible en móvil ── */
var toggle = document.getElementById('sidebarToggle');
var sidebar = document.getElementById('sidebar');
if (toggle && sidebar) {
toggle.style.display = 'block';
toggle.addEventListener('click', function() {
sidebar.classList.toggle('active');
});
}
});
</script>
<main class="split-screen">
<div id="graphPanel" style="position:relative;">
<button id="volverGrafoBtn">← Volver al grafo completo</button>
<div id="climateContainer" style="position: absolute; width: 100%; height: 100%;"></div>
<div class="background">
<img src="/images/flujos6.jpg">
<img src="/images/flujos6.jpg">
<img src="/images/flujos6.jpg">
<img src="/images/flujos6.jpg">
<img src="/images/flujos6.jpg">
<script>
setTimeout(function() {
var fondo = document.querySelector('.background');
fondo.classList.add('fade-out');
fondo.style.pointerEvents = 'none';
}, 1000);
</script>
</div>
</div>
<div id="detailPanel">
<button id="closeDetail">✕ cerrar</button>
<div id="detailContent">
<div class="detail-meta">
<span class="detail-author"></span>
<span class="detail-date"></span>
</div>
<h2 class="detail-title"></h2>
<div class="detail-relations">
<span class="relations-count"></span>
<div class="relations-nav">
<button id="prevRelation" disabled>&#8592; ant</button>
<span id="relationLabel"></span>
<button id="nextRelation" disabled>sig &#8594;</button>
</div>
</div>
<div class="detail-body"></div>
</div>
</div> </div>
</main> </main>
<div id="sidebar"> <div id="sidebar">
@ -54,22 +131,23 @@
<input type="date" id="fecha_fin" name="fecha_fin"> <input type="date" id="fecha_fin" name="fecha_fin">
<label for="nodos">Nodos:</label> <label for="nodos">Nodos:</label>
<input type="number" id="nodos" name="nodos" value="100"> <input type="number" id="nodos" name="nodos" value="100" min="5" max="500">
<label for="complejidad">Complejidad:</label> <label for="complejidad">% similitud: <span id="complejidadVal" class="sim-val">8</span></label>
<input type="range" id="complejidad" name="complejidad" min="1" max="40" value="20"> <input type="range" id="complejidad" name="complejidad" min="1" max="25" value="8"
oninput="document.getElementById('complejidadVal').textContent = this.value">
<label for="param1">Búsqueda por palabra:</label> <label for="param1">Palabra clave:</label>
<input type="text" id="param1" name="param1"> <input type="text" id="param1" name="param1" placeholder="ej: glaciares, CO2...">
<label for="color1">Color 1:</label> <label for="param2">Subtematica:</label>
<input type="color" id="color1" name="color1"> <select id="param2" name="param2">
<option value="">— Todas —</option>
<label for="param2">Búsqueda por temática personalizada:</label> <option value="conservación">conservación</option>
<input type="text" id="param2" name="param2"> <option value="cambio climático">cambio climático</option>
<option value="energía renovable">energía renovable</option>
<label for="color2">Color 2:</label> <option value="desastres naturales">desastres naturales</option>
<input type="color" id="color2" name="color2"> </select>
<input type="submit" value="Aplicar"> <input type="submit" value="Aplicar">
</form> </form>
@ -77,11 +155,7 @@
<button id="sidebarToggle">Toggle Sidebar</button> <button id="sidebarToggle">Toggle Sidebar</button>
<footer>
<p><a href="#">GitHub</a> | <a href="#">Telegram</a> | <a href="#">Email</a> | <a href="#">Web de Tor</a></p>
</footer>
<!-- Movemos la inclusión del script aquí, al final del body --> <!-- Movemos la inclusión del script aquí, al final del body -->
<script src="output_climate_pruebas.js"></script> <script type="module" src="output_climate_pruebas.js"></script>
</body> </body>
</html> </html>

View file

@ -4,6 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>Economía y Corporaciones</title> <title>Economía y Corporaciones</title>
<link rel="stylesheet" href="eco-corp.css"> <link rel="stylesheet" href="eco-corp.css">
<link rel="stylesheet" href="sub-nav.css">
<!-- Fuentes de Google Fonts --> <!-- Fuentes de Google Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
@ -11,32 +12,111 @@
<!-- Cargar D3.js --> <!-- Cargar D3.js -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/6.7.0/d3.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/d3/6.7.0/d3.min.js"></script>
<!-- Cargar 3d-force-graph (incluye Three.js) --> <script type="importmap">
<script src="https://unpkg.com/3d-force-graph"></script> { "imports": { "three": "https://esm.sh/three@0.179.0", "three/": "https://esm.sh/three@0.179.0/" } }
</script>
</head> </head>
<body> <body>
<nav> <nav class="top-nav">
<ul class="nav-links"> <div class="section-buttons">
<li><a href="eco-corp.html" class="eco-corp">Economía y Corporaciones</a></li>
</ul> <div class="section-item">
<a href="glob-war.html" class="section-btn glob-war-btn">GLOB-WAR</a>
</div>
<div class="section-item">
<a href="int-sec.html" class="section-btn int-sec-btn">INT-SEC</a>
</div>
<div class="section-item">
<a href="climate.html" class="section-btn climate-btn">CLIMATE</a>
</div>
<div class="section-item current">
<a href="eco-corp.html" class="section-btn eco-corp-btn">ECO-CORP <span class="dropdown-arrow"></span></a>
<div class="section-dropdown">
<a href="#" onclick="setSubtema('economía global');return false;">Economía Global</a>
<a href="#" onclick="setSubtema('corporaciones multinacionales');return false;">Corporaciones Multinacionales</a>
<a href="#" onclick="setSubtema('comercio internacional');return false;">Comercio Internacional</a>
<a href="#" onclick="setSubtema('organismos financieros');return false;">Organismos Financieros</a>
<a href="#" onclick="setSubtema('desigualdad económica');return false;">Desigualdad Económica</a>
</div>
</div>
<div class="section-item">
<a href="popl-up.html" class="section-btn popl-up-btn">POPL-UP</a>
</div>
</div>
</nav> </nav>
<main> <script>
<div id="ecoCorpContainer" style="position: absolute; width: 100%; height: 100%;"></div> function setSubtema(val) {
<div class="background"> var sel = document.getElementById('param2');
<img src="/images/flujos.jpg"> if (sel) { sel.value = val; }
<img src="/images/flujos.jpg"> var form = document.getElementById('paramForm');
<img src="/images/flujos.jpg"> if (form) form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
<img src="/images/flujos.jpg"> }
<img src="/images/flujos.jpg"> document.addEventListener('DOMContentLoaded', function() {
<script> /* ── Dropdown touch (móvil) ── */
setTimeout(function() { var curr = document.querySelector('.section-item.current');
var fondo = document.querySelector('.background'); if (curr) {
fondo.classList.add('fade-out'); curr.querySelector('.section-btn').addEventListener('touchend', function(e) {
fondo.style.pointerEvents = 'none'; e.preventDefault();
}, 1000); curr.classList.toggle('open');
</script> });
document.addEventListener('touchend', function(e) {
if (!curr.contains(e.target)) curr.classList.remove('open');
});
}
/* ── Sidebar: botón toggle visible en móvil ── */
var toggle = document.getElementById('sidebarToggle');
var sidebar = document.getElementById('sidebar');
if (toggle && sidebar) {
toggle.style.display = 'block';
toggle.addEventListener('click', function() {
sidebar.classList.toggle('active');
});
}
});
</script>
<main class="split-screen">
<div id="graphPanel" style="position:relative;">
<button id="volverGrafoBtn">← Volver al grafo completo</button>
<div id="ecoCorpContainer" style="position: absolute; width: 100%; height: 100%;"></div>
<div class="background">
<img src="/images/flujos.jpg">
<img src="/images/flujos.jpg">
<img src="/images/flujos.jpg">
<img src="/images/flujos.jpg">
<img src="/images/flujos.jpg">
<script>
setTimeout(function() {
var fondo = document.querySelector('.background');
fondo.classList.add('fade-out');
fondo.style.pointerEvents = 'none';
}, 1000);
</script>
</div>
</div>
<div id="detailPanel">
<button id="closeDetail">✕ cerrar</button>
<div id="detailContent">
<div class="detail-meta">
<span class="detail-author"></span>
<span class="detail-date"></span>
</div>
<h2 class="detail-title"></h2>
<div class="detail-relations">
<span class="relations-count"></span>
<div class="relations-nav">
<button id="prevRelation" disabled>&#8592; ant</button>
<span id="relationLabel"></span>
<button id="nextRelation" disabled>sig &#8594;</button>
</div>
</div>
<div class="detail-body"></div>
</div>
</div> </div>
</main> </main>
@ -50,16 +130,24 @@
<input type="date" id="fecha_fin" name="fecha_fin"> <input type="date" id="fecha_fin" name="fecha_fin">
<label for="nodos">Nodos:</label> <label for="nodos">Nodos:</label>
<input type="number" id="nodos" name="nodos" value="100"> <input type="number" id="nodos" name="nodos" value="100" min="5" max="500">
<label for="complejidad">Complejidad:</label> <label for="complejidad">% similitud: <span id="complejidadVal" class="sim-val">8</span></label>
<input type="range" id="complejidad" name="complejidad" min="1" max="40" value="20"> <input type="range" id="complejidad" name="complejidad" min="1" max="25" value="8"
oninput="document.getElementById('complejidadVal').textContent = this.value">
<label for="param1">Búsqueda por palabra:</label> <label for="param1">Palabra clave:</label>
<input type="text" id="param1" name="param1"> <input type="text" id="param1" name="param1" placeholder="ej: FMI, Amazon...">
<label for="param2">Búsqueda por temática personalizada:</label> <label for="param2">Subtematica:</label>
<input type="text" id="param2" name="param2"> <select id="param2" name="param2">
<option value="">— Todas —</option>
<option value="comercio internacional">comercio internacional</option>
<option value="economía global">economía global</option>
<option value="desigualdad económica">desigualdad económica</option>
<option value="corporaciones multinacionales">corporaciones multinacionales</option>
<option value="organismos financieros">organismos financieros</option>
</select>
<input type="submit" value="Aplicar"> <input type="submit" value="Aplicar">
</form> </form>
@ -67,11 +155,7 @@
<button id="sidebarToggle">Toggle Sidebar</button> <button id="sidebarToggle">Toggle Sidebar</button>
<footer>
<p><a href="#">GitHub</a> | <a href="#">Telegram</a> | <a href="#">Email</a> | <a href="#">Web de Tor</a></p>
</footer>
<!-- Incluir tu script al final del body --> <!-- Incluir tu script al final del body -->
<script src="output_eco_corp_pruebas.js"></script> <script type="module" src="output_eco_corp_pruebas.js"></script>
</body> </body>
</html> </html>

View file

@ -4,41 +4,117 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>Glob-War</title> <title>Glob-War</title>
<link rel="stylesheet" href="glob-war.css"> <link rel="stylesheet" href="glob-war.css">
<link rel="stylesheet" href="sub-nav.css">
<!-- Fuentes de Google Fonts --> <!-- Fuentes de Google Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;700&display=swap" rel="stylesheet">
<!-- Cargar D3.js --> <script type="importmap">
{ "imports": { "three": "https://esm.sh/three@0.179.0", "three/": "https://esm.sh/three@0.179.0/" } }
</script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/6.7.0/d3.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/d3/6.7.0/d3.min.js"></script>
<!-- Three.js (necesario para Sprite/TextureLoader en nodos imagen) -->
<script src="https://unpkg.com/three@0.168/build/three.min.js"></script>
<!-- Cargar 3d-force-graph -->
<script src="https://unpkg.com/3d-force-graph"></script>
</head> </head>
<body> <body>
<nav> <nav class="top-nav">
<ul class="nav-links"> <div class="section-buttons">
<li><a href="glob-war.html" class="glob-war">GLOB-WAR</a></li>
</ul> <div class="section-item current">
<a href="glob-war.html" class="section-btn glob-war-btn">GLOB-WAR <span class="dropdown-arrow"></span></a>
<div class="section-dropdown">
<a href="#" onclick="setSubtema('conflictos internacionales');return false;">Conflictos Internacionales</a>
<a href="#" onclick="setSubtema('guerras civiles');return false;">Guerras Civiles</a>
<a href="#" onclick="setSubtema('terrorismo');return false;">Terrorismo</a>
<a href="#" onclick="setSubtema('armas');return false;">Armas</a>
<a href="#" onclick="setSubtema('alianzas militares');return false;">Alianzas Militares</a>
</div>
</div>
<div class="section-item">
<a href="int-sec.html" class="section-btn int-sec-btn">INT-SEC</a>
</div>
<div class="section-item">
<a href="climate.html" class="section-btn climate-btn">CLIMATE</a>
</div>
<div class="section-item">
<a href="eco-corp.html" class="section-btn eco-corp-btn">ECO-CORP</a>
</div>
<div class="section-item">
<a href="popl-up.html" class="section-btn popl-up-btn">POPL-UP</a>
</div>
</div>
</nav> </nav>
<main> <script>
<div id="globWarContainer" style="position: absolute; width: 100%; height: 100%;"></div> function setSubtema(val) {
<div class="background"> var sel = document.getElementById('param2');
<img src="/images/flujos.jpg"> if (sel) { sel.value = val; }
<img src="/images/flujos.jpg"> var form = document.getElementById('paramForm');
<img src="/images/flujos.jpg"> if (form) form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
<img src="/images/flujos.jpg"> }
<img src="/images/flujos.jpg"> document.addEventListener('DOMContentLoaded', function() {
<script> /* ── Dropdown touch (móvil) ── */
setTimeout(function() { var curr = document.querySelector('.section-item.current');
var fondo = document.querySelector('.background'); if (curr) {
fondo.classList.add('fade-out'); curr.querySelector('.section-btn').addEventListener('touchend', function(e) {
fondo.style.pointerEvents = 'none'; e.preventDefault();
}, 1000); curr.classList.toggle('open');
</script> });
document.addEventListener('touchend', function(e) {
if (!curr.contains(e.target)) curr.classList.remove('open');
});
}
/* ── Sidebar: botón toggle visible en móvil ── */
var toggle = document.getElementById('sidebarToggle');
var sidebar = document.getElementById('sidebar');
if (toggle && sidebar) {
toggle.style.display = 'block';
toggle.addEventListener('click', function() {
sidebar.classList.toggle('active');
});
}
});
</script>
<main class="split-screen">
<div id="graphPanel" style="position:relative;">
<button id="volverGrafoBtn">← Volver al grafo completo</button>
<div id="globWarContainer" style="position: absolute; width: 100%; height: 100%;"></div>
<div class="background">
<img src="/images/flujos.jpg">
<img src="/images/flujos.jpg">
<img src="/images/flujos.jpg">
<img src="/images/flujos.jpg">
<img src="/images/flujos.jpg">
<script>
setTimeout(function() {
var fondo = document.querySelector('.background');
fondo.classList.add('fade-out');
fondo.style.pointerEvents = 'none';
}, 1000);
</script>
</div>
</div>
<div id="detailPanel">
<button id="closeDetail">✕ cerrar</button>
<div id="detailContent">
<div class="detail-meta">
<span class="detail-author"></span>
<span class="detail-date"></span>
</div>
<h2 class="detail-title"></h2>
<div class="detail-relations">
<span class="relations-count"></span>
<div class="relations-nav">
<button id="prevRelation" disabled>&#8592; ant</button>
<span id="relationLabel"></span>
<button id="nextRelation" disabled>sig &#8594;</button>
</div>
</div>
<div class="detail-body"></div>
</div>
</div> </div>
</main> </main>
@ -52,22 +128,24 @@
<input type="date" id="fecha_fin" name="fecha_fin"> <input type="date" id="fecha_fin" name="fecha_fin">
<label for="nodos">Nodos:</label> <label for="nodos">Nodos:</label>
<input type="number" id="nodos" name="nodos" value="100"> <input type="number" id="nodos" name="nodos" value="100" min="5" max="500">
<label for="complejidad">Complejidad:</label> <label for="complejidad">% similitud: <span id="complejidadVal" class="sim-val">8</span></label>
<input type="range" id="complejidad" name="complejidad" min="1" max="40" value="20"> <input type="range" id="complejidad" name="complejidad" min="1" max="25" value="8"
oninput="document.getElementById('complejidadVal').textContent = this.value">
<label for="param1">Búsqueda por palabra:</label> <label for="param1">Palabra clave:</label>
<input type="text" id="param1" name="param1"> <input type="text" id="param1" name="param1" placeholder="ej: misiles, OTAN...">
<label for="color1">Color 1:</label> <label for="param2">Subtematica:</label>
<input type="color" id="color1" name="color1"> <select id="param2" name="param2">
<option value="">— Todas —</option>
<label for="param2">Búsqueda por temática personalizada:</label> <option value="armas">armas</option>
<input type="text" id="param2" name="param2"> <option value="terrorismo">terrorismo</option>
<option value="guerras civiles">guerras civiles</option>
<label for="color2">Color 2:</label> <option value="conflictos internacionales">conflictos internacionales</option>
<input type="color" id="color2" name="color2"> <option value="alianzas militares">alianzas militares</option>
</select>
<input type="submit" value="Aplicar"> <input type="submit" value="Aplicar">
</form> </form>
@ -75,11 +153,7 @@
<button id="sidebarToggle">Toggle Sidebar</button> <button id="sidebarToggle">Toggle Sidebar</button>
<footer>
<p><a href="#">GitHub</a> | <a href="#">Telegram</a> | <a href="#">Email</a> | <a href="#">Web de Tor</a></p>
</footer>
<!-- Incluir tu script al final del body --> <!-- Incluir tu script al final del body -->
<script src="output_glob_war_pruebas.js"></script> <script type="module" src="output_glob_war_pruebas.js"></script>
</body> </body>
</html> </html>

View file

@ -14,8 +14,10 @@
<header> <header>
<div class="header-content"> <div class="header-content">
<h1 class="title">UP-LEAKS</h1> <h1 class="title">UP-LEAKS</h1>
<div class="header-buttons"> <div class="header-buttons header-buttons--left">
<a href="/coconews/" class="small-button coconews-btn">COCONEWS</a>
<a href="journalist.html" class="small-button">Journalist</a> <a href="journalist.html" class="small-button">Journalist</a>
<a href="stats.html" class="small-button stats-btn">STATS</a>
</div> </div>
</div> </div>
</header> </header>

View file

@ -4,6 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>Inteligencia y Seguridad</title> <title>Inteligencia y Seguridad</title>
<link rel="stylesheet" href="int-sec.css"> <link rel="stylesheet" href="int-sec.css">
<link rel="stylesheet" href="sub-nav.css">
<!-- Fuentes de Google Fonts --> <!-- Fuentes de Google Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
@ -11,19 +12,77 @@
<!-- Cargar D3.js --> <!-- Cargar D3.js -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/6.7.0/d3.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/d3/6.7.0/d3.min.js"></script>
<!-- Cargar 3d-force-graph (incluye Three.js) --> <script type="importmap">
<script src="https://unpkg.com/3d-force-graph"></script> { "imports": { "three": "https://esm.sh/three@0.179.0", "three/": "https://esm.sh/three@0.179.0/" } }
</script>
</head> </head>
<body> <body>
<nav> <nav class="top-nav">
<ul class="nav-links"> <div class="section-buttons">
<li><a href="int-sec.html" class="int-sec">Inteligencia y Seguridad</a></li>
</ul> <div class="section-item">
<a href="glob-war.html" class="section-btn glob-war-btn">GLOB-WAR</a>
</div>
<div class="section-item current">
<a href="int-sec.html" class="section-btn int-sec-btn">INT-SEC <span class="dropdown-arrow"></span></a>
<div class="section-dropdown">
<a href="#" onclick="setSubtema('inteligencia');return false;">Inteligencia</a>
<a href="#" onclick="setSubtema('ciberseguridad');return false;">Ciberseguridad</a>
<a href="#" onclick="setSubtema('espionaje');return false;">Espionaje</a>
<a href="#" onclick="setSubtema('seguridad nacional');return false;">Seguridad Nacional</a>
<a href="#" onclick="setSubtema('contraterrorismo');return false;">Contraterrorismo</a>
</div>
</div>
<div class="section-item">
<a href="climate.html" class="section-btn climate-btn">CLIMATE</a>
</div>
<div class="section-item">
<a href="eco-corp.html" class="section-btn eco-corp-btn">ECO-CORP</a>
</div>
<div class="section-item">
<a href="popl-up.html" class="section-btn popl-up-btn">POPL-UP</a>
</div>
</div>
</nav> </nav>
<script>
function setSubtema(val) {
var sel = document.getElementById('param2');
if (sel) { sel.value = val; }
var form = document.getElementById('paramForm');
if (form) form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
}
document.addEventListener('DOMContentLoaded', function() {
/* ── Dropdown touch (móvil) ── */
var curr = document.querySelector('.section-item.current');
if (curr) {
curr.querySelector('.section-btn').addEventListener('touchend', function(e) {
e.preventDefault();
curr.classList.toggle('open');
});
document.addEventListener('touchend', function(e) {
if (!curr.contains(e.target)) curr.classList.remove('open');
});
}
/* ── Sidebar: botón toggle visible en móvil ── */
var toggle = document.getElementById('sidebarToggle');
var sidebar = document.getElementById('sidebar');
if (toggle && sidebar) {
toggle.style.display = 'block';
toggle.addEventListener('click', function() {
sidebar.classList.toggle('active');
});
}
});
</script>
<main class="split-screen"> <main class="split-screen">
<!-- PANEL IZQUIERDO: el grafo --> <!-- PANEL IZQUIERDO: el grafo -->
<div id="graphPanel"> <div id="graphPanel" style="position:relative;">
<button id="volverGrafoBtn">← Volver al grafo completo</button>
<div id="intSecContainer"></div> <div id="intSecContainer"></div>
<div class="background"> <div class="background">
<img src="/images/flujos.jpg"> <img src="/images/flujos.jpg">
@ -43,7 +102,23 @@
<!-- PANEL DERECHO: detalle de la noticia --> <!-- PANEL DERECHO: detalle de la noticia -->
<div id="detailPanel"> <div id="detailPanel">
<p class="placeholder">Haz click en un nodo para ver aquí la noticia completa.</p> <button id="closeDetail">✕ cerrar</button>
<div id="detailContent">
<div class="detail-meta">
<span class="detail-author"></span>
<span class="detail-date"></span>
</div>
<h2 class="detail-title"></h2>
<div class="detail-relations">
<span class="relations-count"></span>
<div class="relations-nav">
<button id="prevRelation" disabled>&#8592; ant</button>
<span id="relationLabel"></span>
<button id="nextRelation" disabled>sig &#8594;</button>
</div>
</div>
<div class="detail-body"></div>
</div>
</div> </div>
</main> </main>
@ -57,16 +132,24 @@
<input type="date" id="fecha_fin" name="fecha_fin"> <input type="date" id="fecha_fin" name="fecha_fin">
<label for="nodos">Nodos:</label> <label for="nodos">Nodos:</label>
<input type="number" id="nodos" name="nodos" value="100"> <input type="number" id="nodos" name="nodos" value="100" min="5" max="500">
<label for="complejidad">Complejidad:</label> <label for="complejidad">% similitud: <span id="complejidadVal" class="sim-val">8</span></label>
<input type="range" id="complejidad" name="complejidad" min="1" max="40" value="20"> <input type="range" id="complejidad" name="complejidad" min="1" max="25" value="8"
oninput="document.getElementById('complejidadVal').textContent = this.value">
<label for="param1">Búsqueda por palabra:</label> <label for="param1">Palabra clave:</label>
<input type="text" id="param1" name="param1"> <input type="text" id="param1" name="param1" placeholder="ej: CIA, GCHQ...">
<label for="param2">Búsqueda por temática personalizada:</label> <label for="param2">Subtematica:</label>
<input type="text" id="param2" name="param2"> <select id="param2" name="param2">
<option value="">— Todas —</option>
<option value="inteligencia">inteligencia</option>
<option value="seguridad nacional">seguridad nacional</option>
<option value="espionaje">espionaje</option>
<option value="ciberseguridad">ciberseguridad</option>
<option value="contraterrorismo">contraterrorismo</option>
</select>
<input type="submit" value="Aplicar"> <input type="submit" value="Aplicar">
</form> </form>
@ -74,16 +157,7 @@
<button id="sidebarToggle">Toggle Sidebar</button> <button id="sidebarToggle">Toggle Sidebar</button>
<footer>
<p>
<a href="#">GitHub</a> |
<a href="#">Telegram</a> |
<a href="#">Email</a> |
<a href="#">Web de Tor</a>
</p>
</footer>
<!-- Incluir tu script al final del body --> <!-- Incluir tu script al final del body -->
<script src="output_int_sec.js"></script> <script type="module" src="output_int_sec.js"></script>
</body> </body>
</html> </html>

View file

@ -1,29 +1,50 @@
// output_climate_pruebas.js // output_climate_pruebas.js
import ForceGraph3D from 'https://esm.sh/3d-force-graph?external=three';
import * as THREE from 'three';
// Obtener el contenedor del gráfico
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const elem = document.getElementById('climateContainer'); const elem = document.getElementById('climateContainer');
const form = document.getElementById('paramForm'); const form = document.getElementById('paramForm');
// Inicializar el gráfico 3D let lastGraphData = { nodes: [], links: [] };
let fullGraphData = null;
let currentRelatedNodes = [];
let currentRelatedIdx = 0;
let currentNode = null;
const textureCache = {};
function getTexture(url) {
if (!textureCache[url]) textureCache[url] = new THREE.TextureLoader().load(url);
return textureCache[url];
}
const graph = ForceGraph3D()(elem) const graph = ForceGraph3D()(elem)
.backgroundColor('#000000') .backgroundColor('#000000')
.nodeLabel('id') .nodeLabel(node => node.type === 'imagen' ? (node.label || node.id) : node.id)
.nodeAutoColorBy('group') .nodeAutoColorBy('group')
.nodeVal(1) .nodeVal(node => node.type === 'imagen' ? 20 : 2)
.linkColor(() => '#42FF00') .linkColor(() => '#42FF00')
.onNodeClick(node => showNodeContent(node.content)) .onNodeClick(node => showNodeDetail(node))
.onNodeHover(node => { elem.style.cursor = node ? 'pointer' : null; }) .onNodeHover(node => { elem.style.cursor = node ? 'pointer' : null; })
.forceEngine('d3') .nodeThreeObject(node => {
//.d3Force('charge', d3.forceManyBody().strength(-300)) if (node.type !== 'imagen' || !node.image_url) return null;
//.d3Force('link', d3.forceLink().distance(300).strength(1)); const texture = getTexture(node.image_url);
texture.colorSpace = THREE.SRGBColorSpace;
const material = new THREE.SpriteMaterial({ map: texture, depthWrite: false });
const sprite = new THREE.Sprite(material);
sprite.scale.set(160, 104, 1);
return sprite;
})
.nodeThreeObjectExtend(false)
.forceEngine('d3');
graph.d3Force('charge').strength(-150);
graph.d3Force('link').distance(70).strength(0.3);
graph.d3Force('center').strength(0.05);
// Centrado automático del gráfico
function centerGraph() { function centerGraph() {
setTimeout(() => { setTimeout(() => {
const width = elem.clientWidth; graph.zoomToFit(400, Math.min(elem.clientWidth, elem.clientHeight) * 0.1);
const height = elem.clientHeight;
graph.zoomToFit(400, Math.min(width, height) * 0.1);
}, 500); }, 500);
} }
window.addEventListener('resize', () => { window.addEventListener('resize', () => {
@ -32,95 +53,175 @@ document.addEventListener('DOMContentLoaded', () => {
centerGraph(); centerGraph();
}); });
// Mostrar contenido del nodo function getAuthor(node) { return node.autor || node.fuente || node.source || ''; }
function showNodeContent(content) { function formatDate(fecha) {
console.log('Contenido del nodo:', content); if (!fecha) return '';
try { return new Date(fecha).toLocaleDateString('es-ES'); } catch { return String(fecha); }
}
function formatTitle(id) { return String(id).replace(/_/g, ' '); }
function getRelated(node) {
const nid = node.id;
const relIds = new Set();
for (const lk of lastGraphData.links) {
const s = typeof lk.source === 'object' ? lk.source.id : lk.source;
const t = typeof lk.target === 'object' ? lk.target.id : lk.target;
if (s === nid) relIds.add(t);
else if (t === nid) relIds.add(s);
}
return lastGraphData.nodes.filter(n => relIds.has(n.id));
} }
// Función para obtener datos del servidor function renderDetailPanel(node, related, idx) {
const detailContent = document.getElementById('detailContent');
const split = document.querySelector('main.split-screen');
if (!detailContent || !split) return;
detailContent.querySelector('.detail-author').textContent = getAuthor(node);
detailContent.querySelector('.detail-date').textContent = formatDate(node.fecha || node.date);
detailContent.querySelector('.detail-title').textContent = formatTitle(node.id);
const relCount = detailContent.querySelector('.relations-count');
const relLabel = document.getElementById('relationLabel');
const prevBtn = document.getElementById('prevRelation');
const nextBtn = document.getElementById('nextRelation');
relCount.textContent = `${related.length} relación${related.length !== 1 ? 'es' : ''}`;
if (related.length > 0) {
relLabel.textContent = formatTitle(related[idx].id);
prevBtn.disabled = idx <= 0;
nextBtn.disabled = idx >= related.length - 1;
} else {
relLabel.textContent = '—';
prevBtn.disabled = true;
nextBtn.disabled = true;
}
const body = detailContent.querySelector('.detail-body');
body.innerHTML = '';
if (node.type === 'imagen' && node.image_url) {
const img = document.createElement('img');
img.src = node.image_url;
img.className = 'detail-img';
body.appendChild(img);
}
const text = document.createElement('span');
text.textContent = node.content || node.texto || node.descripcion || 'Sin contenido disponible.';
body.appendChild(text);
if (related.length > 0) {
const conexBtn = document.createElement('button');
conexBtn.id = 'verConexionesBtn';
conexBtn.textContent = '⬡ Ver solo conexiones de este nodo';
conexBtn.addEventListener('click', () => showEgoGraph(node));
body.appendChild(conexBtn);
}
split.classList.add('show-detail');
setTimeout(() => { graph.width(elem.clientWidth); graph.height(elem.clientHeight); }, 350);
}
function showEgoGraph(node) {
const nid = node.id;
const relIds = new Set();
for (const lk of lastGraphData.links) {
const s = typeof lk.source === 'object' ? lk.source.id : lk.source;
const t = typeof lk.target === 'object' ? lk.target.id : lk.target;
if (s === nid) relIds.add(t);
else if (t === nid) relIds.add(s);
}
const egoNodes = lastGraphData.nodes.filter(n => n.id === nid || relIds.has(n.id));
const egoIds = new Set(egoNodes.map(n => n.id));
const egoLinks = lastGraphData.links.filter(lk => {
const s = typeof lk.source === 'object' ? lk.source.id : lk.source;
const t = typeof lk.target === 'object' ? lk.target.id : lk.target;
return egoIds.has(s) && egoIds.has(t);
});
fullGraphData = lastGraphData;
graph.graphData({ nodes: egoNodes, links: egoLinks });
centerGraph();
document.getElementById('volverGrafoBtn').classList.add('visible');
}
function restoreFullGraph() {
if (fullGraphData) {
lastGraphData = fullGraphData;
fullGraphData = null;
graph.graphData({ nodes: lastGraphData.nodes, links: lastGraphData.links });
centerGraph();
document.getElementById('volverGrafoBtn').classList.remove('visible');
}
}
function showNodeDetail(node) {
currentNode = node;
currentRelatedNodes = getRelated(node);
currentRelatedIdx = 0;
renderDetailPanel(node, currentRelatedNodes, currentRelatedIdx);
}
document.getElementById('prevRelation').addEventListener('click', () => {
if (currentRelatedIdx > 0) {
currentRelatedIdx--;
renderDetailPanel(currentNode, currentRelatedNodes, currentRelatedIdx);
}
});
document.getElementById('nextRelation').addEventListener('click', () => {
if (currentRelatedIdx < currentRelatedNodes.length - 1) {
currentRelatedIdx++;
renderDetailPanel(currentNode, currentRelatedNodes, currentRelatedIdx);
}
});
document.getElementById('closeDetail').addEventListener('click', () => {
const split = document.querySelector('main.split-screen');
if (split) split.classList.remove('show-detail');
restoreFullGraph();
setTimeout(() => { graph.width(elem.clientWidth); graph.height(elem.clientHeight); }, 350);
});
document.getElementById('volverGrafoBtn').addEventListener('click', () => {
restoreFullGraph();
});
async function getData(paramsObj = {}) { async function getData(paramsObj = {}) {
try { try {
let url = '/api/data'; let url = '/api/data';
const params = new URLSearchParams(); const params = new URLSearchParams();
// Establecer el tema fijo
params.append('tema', 'cambio climático'); params.append('tema', 'cambio climático');
// Agregar parámetros del formulario, incluyendo complejidad como umbral
for (const key in paramsObj) { for (const key in paramsObj) {
if (paramsObj[key] !== undefined && paramsObj[key] !== '') { if (paramsObj[key] !== undefined && paramsObj[key] !== '') {
params.append(key, paramsObj[key]); params.append(key, paramsObj[key]);
} }
} }
url += `?${params.toString()}`; url += `?${params.toString()}`;
console.log('🔎 Fetch URL:', url); console.log('Fetch URL:', url);
const response = await fetch(url); const response = await fetch(url);
if (!response.ok) throw new Error(response.statusText); if (!response.ok) throw new Error(response.statusText);
const data = await response.json(); const data = await response.json();
console.log('Datos recibidos del servidor:', data); const nodeIds = new Set(data.nodes.map(n => n.id));
data.links = data.links.filter(lk => nodeIds.has(lk.source) && nodeIds.has(lk.target));
// Filtrar enlaces inválidos
const nodeIds = new Set(data.nodes.map(node => node.id));
data.links = data.links.filter(link => nodeIds.has(link.source) && nodeIds.has(link.target));
return data; return data;
} catch (error) { } catch (error) {
console.error('Error al obtener datos del servidor:', error); console.error('Error al obtener datos:', error);
return null; return null;
} }
} }
// Función principal: fetch, filtrar por complejidad/umbral y renderizar
async function fetchAndRender() { async function fetchAndRender() {
const subtematica = document.getElementById('param2').value;
const palabraClave = document.getElementById('param1').value;
const fechaInicio = document.getElementById('fecha_inicio').value;
const fechaFin = document.getElementById('fecha_fin').value;
const nodos = document.getElementById('nodos').value;
const complejidad = document.getElementById('complejidad').value;
// Usamos 'complejidad' como porcentaje de similitud mínima (umbral)
const paramsObj = { const paramsObj = {
subtematica, subtematica: document.getElementById('param2').value,
palabraClave, palabraClave: document.getElementById('param1').value,
fechaInicio, fechaInicio: document.getElementById('fecha_inicio').value,
fechaFin, fechaFin: document.getElementById('fecha_fin').value,
nodos, nodos: document.getElementById('nodos').value,
complejidad // enviado al backend y usado de umbral en cliente complejidad: document.getElementById('complejidad').value,
}; };
// Parsear complejidad a número
const umbralPct = parseFloat(complejidad) || 0;
console.log(`Aplicando umbral de similitud: ${umbralPct}%`);
const graphData = await getData(paramsObj); const graphData = await getData(paramsObj);
if (!graphData) return; if (!graphData) return;
lastGraphData = graphData;
// Filtrar enlaces por umbral de similitud graph.graphData({ nodes: graphData.nodes, links: graphData.links });
let filteredLinks = graphData.links;
if (umbralPct > 0) {
filteredLinks = filteredLinks.filter(link => Number(link.value) >= umbralPct);
console.log(`Enlaces tras aplicar umbral: ${filteredLinks.length}`);
}
graph.graphData({ nodes: graphData.nodes, links: filteredLinks });
centerGraph(); centerGraph();
} }
// Escuchar evento submit del formulario form.addEventListener('submit', event => { event.preventDefault(); fetchAndRender(); });
form.addEventListener('submit', event => {
event.preventDefault();
fetchAndRender();
});
// Cargar gráfico inicial
fetchAndRender(); fetchAndRender();
}); });

View file

@ -1,29 +1,50 @@
// output_eco_corp.js // output_eco_corp.js
import ForceGraph3D from 'https://esm.sh/3d-force-graph?external=three';
import * as THREE from 'three';
// Obtener el contenedor del gráfico
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const elem = document.getElementById('ecoCorpContainer'); const elem = document.getElementById('ecoCorpContainer');
const form = document.getElementById('paramForm'); const form = document.getElementById('paramForm');
// Inicializar el gráfico 3D let lastGraphData = { nodes: [], links: [] };
let fullGraphData = null;
let currentRelatedNodes = [];
let currentRelatedIdx = 0;
let currentNode = null;
const textureCache = {};
function getTexture(url) {
if (!textureCache[url]) textureCache[url] = new THREE.TextureLoader().load(url);
return textureCache[url];
}
const graph = ForceGraph3D()(elem) const graph = ForceGraph3D()(elem)
.backgroundColor('#000000') .backgroundColor('#000000')
.nodeLabel('id') .nodeLabel(node => node.type === 'imagen' ? (node.label || node.id) : node.id)
.nodeAutoColorBy('group') .nodeAutoColorBy('group')
.nodeVal(1) .nodeVal(node => node.type === 'imagen' ? 20 : 2)
.linkColor(() => 'yellow') .linkColor(() => 'yellow')
.onNodeClick(node => showNodeContent(node.content)) .onNodeClick(node => showNodeDetail(node))
.onNodeHover(node => { elem.style.cursor = node ? 'pointer' : null; }) .onNodeHover(node => { elem.style.cursor = node ? 'pointer' : null; })
.forceEngine('d3') .nodeThreeObject(node => {
//.d3Force('charge', d3.forceManyBody().strength(-300)) if (node.type !== 'imagen' || !node.image_url) return null;
//.d3Force('link', d3.forceLink().distance(300).strength(1)); const texture = getTexture(node.image_url);
texture.colorSpace = THREE.SRGBColorSpace;
const material = new THREE.SpriteMaterial({ map: texture, depthWrite: false });
const sprite = new THREE.Sprite(material);
sprite.scale.set(160, 104, 1);
return sprite;
})
.nodeThreeObjectExtend(false)
.forceEngine('d3');
graph.d3Force('charge').strength(-150);
graph.d3Force('link').distance(70).strength(0.3);
graph.d3Force('center').strength(0.05);
// Centrado automático del gráfico
function centerGraph() { function centerGraph() {
setTimeout(() => { setTimeout(() => {
const width = elem.clientWidth; graph.zoomToFit(400, Math.min(elem.clientWidth, elem.clientHeight) * 0.1);
const height = elem.clientHeight;
graph.zoomToFit(400, Math.min(width, height) * 0.1);
}, 500); }, 500);
} }
window.addEventListener('resize', () => { window.addEventListener('resize', () => {
@ -32,89 +53,175 @@ document.addEventListener('DOMContentLoaded', () => {
centerGraph(); centerGraph();
}); });
// Mostrar contenido del nodo function getAuthor(node) { return node.autor || node.fuente || node.source || ''; }
function showNodeContent(content) { function formatDate(fecha) {
console.log('Contenido del nodo:', content); if (!fecha) return '';
try { return new Date(fecha).toLocaleDateString('es-ES'); } catch { return String(fecha); }
}
function formatTitle(id) { return String(id).replace(/_/g, ' '); }
function getRelated(node) {
const nid = node.id;
const relIds = new Set();
for (const lk of lastGraphData.links) {
const s = typeof lk.source === 'object' ? lk.source.id : lk.source;
const t = typeof lk.target === 'object' ? lk.target.id : lk.target;
if (s === nid) relIds.add(t);
else if (t === nid) relIds.add(s);
}
return lastGraphData.nodes.filter(n => relIds.has(n.id));
} }
// Función para obtener datos del servidor function renderDetailPanel(node, related, idx) {
const detailContent = document.getElementById('detailContent');
const split = document.querySelector('main.split-screen');
if (!detailContent || !split) return;
detailContent.querySelector('.detail-author').textContent = getAuthor(node);
detailContent.querySelector('.detail-date').textContent = formatDate(node.fecha || node.date);
detailContent.querySelector('.detail-title').textContent = formatTitle(node.id);
const relCount = detailContent.querySelector('.relations-count');
const relLabel = document.getElementById('relationLabel');
const prevBtn = document.getElementById('prevRelation');
const nextBtn = document.getElementById('nextRelation');
relCount.textContent = `${related.length} relación${related.length !== 1 ? 'es' : ''}`;
if (related.length > 0) {
relLabel.textContent = formatTitle(related[idx].id);
prevBtn.disabled = idx <= 0;
nextBtn.disabled = idx >= related.length - 1;
} else {
relLabel.textContent = '—';
prevBtn.disabled = true;
nextBtn.disabled = true;
}
const body = detailContent.querySelector('.detail-body');
body.innerHTML = '';
if (node.type === 'imagen' && node.image_url) {
const img = document.createElement('img');
img.src = node.image_url;
img.className = 'detail-img';
body.appendChild(img);
}
const text = document.createElement('span');
text.textContent = node.content || node.texto || node.descripcion || 'Sin contenido disponible.';
body.appendChild(text);
if (related.length > 0) {
const conexBtn = document.createElement('button');
conexBtn.id = 'verConexionesBtn';
conexBtn.textContent = '⬡ Ver solo conexiones de este nodo';
conexBtn.addEventListener('click', () => showEgoGraph(node));
body.appendChild(conexBtn);
}
split.classList.add('show-detail');
setTimeout(() => { graph.width(elem.clientWidth); graph.height(elem.clientHeight); }, 350);
}
function showEgoGraph(node) {
const nid = node.id;
const relIds = new Set();
for (const lk of lastGraphData.links) {
const s = typeof lk.source === 'object' ? lk.source.id : lk.source;
const t = typeof lk.target === 'object' ? lk.target.id : lk.target;
if (s === nid) relIds.add(t);
else if (t === nid) relIds.add(s);
}
const egoNodes = lastGraphData.nodes.filter(n => n.id === nid || relIds.has(n.id));
const egoIds = new Set(egoNodes.map(n => n.id));
const egoLinks = lastGraphData.links.filter(lk => {
const s = typeof lk.source === 'object' ? lk.source.id : lk.source;
const t = typeof lk.target === 'object' ? lk.target.id : lk.target;
return egoIds.has(s) && egoIds.has(t);
});
fullGraphData = lastGraphData;
graph.graphData({ nodes: egoNodes, links: egoLinks });
centerGraph();
document.getElementById('volverGrafoBtn').classList.add('visible');
}
function restoreFullGraph() {
if (fullGraphData) {
lastGraphData = fullGraphData;
fullGraphData = null;
graph.graphData({ nodes: lastGraphData.nodes, links: lastGraphData.links });
centerGraph();
document.getElementById('volverGrafoBtn').classList.remove('visible');
}
}
function showNodeDetail(node) {
currentNode = node;
currentRelatedNodes = getRelated(node);
currentRelatedIdx = 0;
renderDetailPanel(node, currentRelatedNodes, currentRelatedIdx);
}
document.getElementById('prevRelation').addEventListener('click', () => {
if (currentRelatedIdx > 0) {
currentRelatedIdx--;
renderDetailPanel(currentNode, currentRelatedNodes, currentRelatedIdx);
}
});
document.getElementById('nextRelation').addEventListener('click', () => {
if (currentRelatedIdx < currentRelatedNodes.length - 1) {
currentRelatedIdx++;
renderDetailPanel(currentNode, currentRelatedNodes, currentRelatedIdx);
}
});
document.getElementById('closeDetail').addEventListener('click', () => {
const split = document.querySelector('main.split-screen');
if (split) split.classList.remove('show-detail');
restoreFullGraph();
setTimeout(() => { graph.width(elem.clientWidth); graph.height(elem.clientHeight); }, 350);
});
document.getElementById('volverGrafoBtn').addEventListener('click', () => {
restoreFullGraph();
});
async function getData(paramsObj = {}) { async function getData(paramsObj = {}) {
try { try {
let url = '/api/data'; let url = '/api/data';
const params = new URLSearchParams(); const params = new URLSearchParams();
// Establecer el tema fijo
params.append('tema', 'economía y corporaciones'); params.append('tema', 'economía y corporaciones');
// Agregar parámetros del formulario, incluyendo complejidad como umbral
for (const key in paramsObj) { for (const key in paramsObj) {
if (paramsObj[key] !== undefined && paramsObj[key] !== '') { if (paramsObj[key] !== undefined && paramsObj[key] !== '') {
params.append(key, paramsObj[key]); params.append(key, paramsObj[key]);
} }
} }
url += `?${params.toString()}`; url += `?${params.toString()}`;
console.log('🔎 Fetch URL:', url); console.log('Fetch URL:', url);
const response = await fetch(url); const response = await fetch(url);
if (!response.ok) throw new Error(response.statusText); if (!response.ok) throw new Error(response.statusText);
const data = await response.json(); const data = await response.json();
console.log('Datos recibidos del servidor:', data); const nodeIds = new Set(data.nodes.map(n => n.id));
data.links = data.links.filter(lk => nodeIds.has(lk.source) && nodeIds.has(lk.target));
// Filtrar enlaces inválidos
const nodeIds = new Set(data.nodes.map(node => node.id));
data.links = data.links.filter(link => nodeIds.has(link.source) && nodeIds.has(link.target));
return data; return data;
} catch (error) { } catch (error) {
console.error('Error al obtener datos del servidor:', error); console.error('Error al obtener datos:', error);
return null; return null;
} }
} }
// Función principal: fetch, filtrar por complejidad/umbral y renderizar
async function fetchAndRender() { async function fetchAndRender() {
const subtematica = document.getElementById('param2').value;
const palabraClave = document.getElementById('param1').value;
const fechaInicio = document.getElementById('fecha_inicio').value;
const fechaFin = document.getElementById('fecha_fin').value;
const nodos = document.getElementById('nodos').value;
const complejidad = document.getElementById('complejidad').value;
// Usamos 'complejidad' como porcentaje de similitud mínima (umbral)
const paramsObj = { const paramsObj = {
subtematica, subtematica: document.getElementById('param2').value,
palabraClave, palabraClave: document.getElementById('param1').value,
fechaInicio, fechaInicio: document.getElementById('fecha_inicio').value,
fechaFin, fechaFin: document.getElementById('fecha_fin').value,
nodos, nodos: document.getElementById('nodos').value,
complejidad // enviado al backend y usado de umbral en cliente complejidad: document.getElementById('complejidad').value,
}; };
// Parsear complejidad a número
const umbralPct = parseFloat(complejidad) || 0;
console.log(`Aplicando umbral de similitud: ${umbralPct}%`);
const graphData = await getData(paramsObj); const graphData = await getData(paramsObj);
if (!graphData) return; if (!graphData) return;
lastGraphData = graphData;
// Filtrar enlaces por umbral de similitud graph.graphData({ nodes: graphData.nodes, links: graphData.links });
let filteredLinks = graphData.links;
if (umbralPct > 0) {
filteredLinks = filteredLinks.filter(link => Number(link.value) >= umbralPct);
console.log(`Enlaces tras aplicar umbral: ${filteredLinks.length}`);
}
graph.graphData({ nodes: graphData.nodes, links: filteredLinks });
centerGraph(); centerGraph();
} }
// Escuchar evento submit del formulario form.addEventListener('submit', event => { event.preventDefault(); fetchAndRender(); });
form.addEventListener('submit', event => {
event.preventDefault();
fetchAndRender();
});
// Cargar gráfico inicial
fetchAndRender(); fetchAndRender();
}); });

View file

@ -1,29 +1,50 @@
// output_glob_war.js // output_glob_war.js
import ForceGraph3D from 'https://esm.sh/3d-force-graph?external=three';
import * as THREE from 'three';
// Obtener el contenedor del gráfico
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const elem = document.getElementById('globWarContainer'); const elem = document.getElementById('globWarContainer');
const form = document.getElementById('paramForm'); const form = document.getElementById('paramForm');
// Inicializar el gráfico 3D let lastGraphData = { nodes: [], links: [] };
let fullGraphData = null;
let currentRelatedNodes = [];
let currentRelatedIdx = 0;
let currentNode = null;
const textureCache = {};
function getTexture(url) {
if (!textureCache[url]) textureCache[url] = new THREE.TextureLoader().load(url);
return textureCache[url];
}
const graph = ForceGraph3D()(elem) const graph = ForceGraph3D()(elem)
.backgroundColor('#000000') .backgroundColor('#000000')
.nodeLabel('id') .nodeLabel(node => node.type === 'imagen' ? (node.label || node.id) : node.id)
.nodeAutoColorBy('group') .nodeAutoColorBy('group')
.nodeVal(1) .nodeVal(node => node.type === 'imagen' ? 20 : 2)
.linkColor(() => 'red') .linkColor(() => 'red')
.onNodeClick(node => showNodeContent(node.content)) .onNodeClick(node => showNodeDetail(node))
.onNodeHover(node => { elem.style.cursor = node ? 'pointer' : null; }) .onNodeHover(node => { elem.style.cursor = node ? 'pointer' : null; })
.forceEngine('d3') .nodeThreeObject(node => {
//.d3Force('charge', d3.forceManyBody().strength(-300)) if (node.type !== 'imagen' || !node.image_url) return null;
//.d3Force('link', d3.forceLink().distance(300).strength(1)); const texture = getTexture(node.image_url);
texture.colorSpace = THREE.SRGBColorSpace;
const material = new THREE.SpriteMaterial({ map: texture, depthWrite: false });
const sprite = new THREE.Sprite(material);
sprite.scale.set(160, 104, 1);
return sprite;
})
.nodeThreeObjectExtend(false)
.forceEngine('d3');
graph.d3Force('charge').strength(-150);
graph.d3Force('link').distance(70).strength(0.3);
graph.d3Force('center').strength(0.05);
// Centrado automático del gráfico
function centerGraph() { function centerGraph() {
setTimeout(() => { setTimeout(() => {
const width = elem.clientWidth; graph.zoomToFit(400, Math.min(elem.clientWidth, elem.clientHeight) * 0.1);
const height = elem.clientHeight;
graph.zoomToFit(400, Math.min(width, height) * 0.1);
}, 500); }, 500);
} }
window.addEventListener('resize', () => { window.addEventListener('resize', () => {
@ -32,89 +53,177 @@ document.addEventListener('DOMContentLoaded', () => {
centerGraph(); centerGraph();
}); });
// Mostrar contenido del nodo // --- Detail panel helpers ---
function showNodeContent(content) { function getAuthor(node) { return node.autor || node.fuente || node.source || ''; }
console.log('Contenido del nodo:', content); function formatDate(fecha) {
if (!fecha) return '';
try { return new Date(fecha).toLocaleDateString('es-ES'); } catch { return String(fecha); }
}
function formatTitle(id) { return String(id).replace(/_/g, ' '); }
function getRelated(node) {
const nid = node.id;
const relIds = new Set();
for (const lk of lastGraphData.links) {
const s = typeof lk.source === 'object' ? lk.source.id : lk.source;
const t = typeof lk.target === 'object' ? lk.target.id : lk.target;
if (s === nid) relIds.add(t);
else if (t === nid) relIds.add(s);
}
return lastGraphData.nodes.filter(n => relIds.has(n.id));
} }
// Función para obtener datos del servidor function renderDetailPanel(node, related, idx) {
const detailContent = document.getElementById('detailContent');
const split = document.querySelector('main.split-screen');
if (!detailContent || !split) return;
detailContent.querySelector('.detail-author').textContent = getAuthor(node);
detailContent.querySelector('.detail-date').textContent = formatDate(node.fecha || node.date);
detailContent.querySelector('.detail-title').textContent = formatTitle(node.id);
const relCount = detailContent.querySelector('.relations-count');
const relLabel = document.getElementById('relationLabel');
const prevBtn = document.getElementById('prevRelation');
const nextBtn = document.getElementById('nextRelation');
relCount.textContent = `${related.length} relación${related.length !== 1 ? 'es' : ''}`;
if (related.length > 0) {
relLabel.textContent = formatTitle(related[idx].id);
prevBtn.disabled = idx <= 0;
nextBtn.disabled = idx >= related.length - 1;
} else {
relLabel.textContent = '—';
prevBtn.disabled = true;
nextBtn.disabled = true;
}
const body = detailContent.querySelector('.detail-body');
body.innerHTML = '';
if (node.type === 'imagen' && node.image_url) {
const img = document.createElement('img');
img.src = node.image_url;
img.className = 'detail-img';
body.appendChild(img);
}
const text = document.createElement('span');
text.textContent = node.content || node.texto || node.descripcion || 'Sin contenido disponible.';
body.appendChild(text);
if (related.length > 0) {
const conexBtn = document.createElement('button');
conexBtn.id = 'verConexionesBtn';
conexBtn.textContent = '⬡ Ver solo conexiones de este nodo';
conexBtn.addEventListener('click', () => showEgoGraph(node));
body.appendChild(conexBtn);
}
split.classList.add('show-detail');
setTimeout(() => { graph.width(elem.clientWidth); graph.height(elem.clientHeight); }, 350);
}
function showEgoGraph(node) {
const nid = node.id;
const relIds = new Set();
for (const lk of lastGraphData.links) {
const s = typeof lk.source === 'object' ? lk.source.id : lk.source;
const t = typeof lk.target === 'object' ? lk.target.id : lk.target;
if (s === nid) relIds.add(t);
else if (t === nid) relIds.add(s);
}
const egoNodes = lastGraphData.nodes.filter(n => n.id === nid || relIds.has(n.id));
const egoIds = new Set(egoNodes.map(n => n.id));
const egoLinks = lastGraphData.links.filter(lk => {
const s = typeof lk.source === 'object' ? lk.source.id : lk.source;
const t = typeof lk.target === 'object' ? lk.target.id : lk.target;
return egoIds.has(s) && egoIds.has(t);
});
fullGraphData = lastGraphData;
graph.graphData({ nodes: egoNodes, links: egoLinks });
centerGraph();
document.getElementById('volverGrafoBtn').classList.add('visible');
}
function restoreFullGraph() {
if (fullGraphData) {
lastGraphData = fullGraphData;
fullGraphData = null;
graph.graphData({ nodes: lastGraphData.nodes, links: lastGraphData.links });
centerGraph();
document.getElementById('volverGrafoBtn').classList.remove('visible');
}
}
function showNodeDetail(node) {
currentNode = node;
currentRelatedNodes = getRelated(node);
currentRelatedIdx = 0;
renderDetailPanel(node, currentRelatedNodes, currentRelatedIdx);
}
document.getElementById('prevRelation').addEventListener('click', () => {
if (currentRelatedIdx > 0) {
currentRelatedIdx--;
renderDetailPanel(currentNode, currentRelatedNodes, currentRelatedIdx);
}
});
document.getElementById('nextRelation').addEventListener('click', () => {
if (currentRelatedIdx < currentRelatedNodes.length - 1) {
currentRelatedIdx++;
renderDetailPanel(currentNode, currentRelatedNodes, currentRelatedIdx);
}
});
document.getElementById('closeDetail').addEventListener('click', () => {
const split = document.querySelector('main.split-screen');
if (split) split.classList.remove('show-detail');
restoreFullGraph();
setTimeout(() => { graph.width(elem.clientWidth); graph.height(elem.clientHeight); }, 350);
});
document.getElementById('volverGrafoBtn').addEventListener('click', () => {
restoreFullGraph();
});
// --- Data fetch ---
async function getData(paramsObj = {}) { async function getData(paramsObj = {}) {
try { try {
let url = '/api/data'; let url = '/api/data';
const params = new URLSearchParams(); const params = new URLSearchParams();
// Establecer el tema fijo
params.append('tema', 'guerra global'); params.append('tema', 'guerra global');
// Agregar parámetros del formulario, incluyendo complejidad como umbral
for (const key in paramsObj) { for (const key in paramsObj) {
if (paramsObj[key] !== undefined && paramsObj[key] !== '') { if (paramsObj[key] !== undefined && paramsObj[key] !== '') {
params.append(key, paramsObj[key]); params.append(key, paramsObj[key]);
} }
} }
url += `?${params.toString()}`; url += `?${params.toString()}`;
console.log('🔎 Fetch URL:', url); console.log('Fetch URL:', url);
const response = await fetch(url); const response = await fetch(url);
if (!response.ok) throw new Error(response.statusText); if (!response.ok) throw new Error(response.statusText);
const data = await response.json(); const data = await response.json();
console.log('Datos recibidos del servidor:', data); const nodeIds = new Set(data.nodes.map(n => n.id));
data.links = data.links.filter(lk => nodeIds.has(lk.source) && nodeIds.has(lk.target));
// Filtrar enlaces inválidos
const nodeIds = new Set(data.nodes.map(node => node.id));
data.links = data.links.filter(link => nodeIds.has(link.source) && nodeIds.has(link.target));
return data; return data;
} catch (error) { } catch (error) {
console.error('Error al obtener datos del servidor:', error); console.error('Error al obtener datos:', error);
return null; return null;
} }
} }
// Función principal: fetch, filtrar por complejidad/umbral y renderizar
async function fetchAndRender() { async function fetchAndRender() {
const subtematica = document.getElementById('param2').value;
const palabraClave = document.getElementById('param1').value;
const fechaInicio = document.getElementById('fecha_inicio').value;
const fechaFin = document.getElementById('fecha_fin').value;
const nodos = document.getElementById('nodos').value;
const complejidad = document.getElementById('complejidad').value;
// Usamos 'complejidad' como porcentaje de similitud mínima (umbral)
const paramsObj = { const paramsObj = {
subtematica, subtematica: document.getElementById('param2').value,
palabraClave, palabraClave: document.getElementById('param1').value,
fechaInicio, fechaInicio: document.getElementById('fecha_inicio').value,
fechaFin, fechaFin: document.getElementById('fecha_fin').value,
nodos, nodos: document.getElementById('nodos').value,
complejidad // enviado al backend y usado de umbral en cliente complejidad: document.getElementById('complejidad').value,
}; };
// Parsear complejidad a número
const umbralPct = parseFloat(complejidad) || 0;
console.log(`Aplicando umbral de similitud: ${umbralPct}%`);
const graphData = await getData(paramsObj); const graphData = await getData(paramsObj);
if (!graphData) return; if (!graphData) return;
lastGraphData = graphData;
// Filtrar enlaces por umbral de similitud graph.graphData({ nodes: graphData.nodes, links: graphData.links });
let filteredLinks = graphData.links;
if (umbralPct > 0) {
filteredLinks = filteredLinks.filter(link => Number(link.value) >= umbralPct);
console.log(`Enlaces tras aplicar umbral: ${filteredLinks.length}`);
}
graph.graphData({ nodes: graphData.nodes, links: filteredLinks });
centerGraph(); centerGraph();
} }
// Escuchar evento submit del formulario form.addEventListener('submit', event => { event.preventDefault(); fetchAndRender(); });
form.addEventListener('submit', event => {
event.preventDefault();
fetchAndRender();
});
// Cargar gráfico inicial
fetchAndRender(); fetchAndRender();
}); });

View file

@ -1,78 +1,50 @@
// output_int_sec.js // output_int_sec.js
import ForceGraph3D from 'https://esm.sh/3d-force-graph?external=three';
import * as THREE from 'three';
// Obtener el contenedor del gráfico
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const elem = document.getElementById('intSecContainer'); const elem = document.getElementById('intSecContainer');
const form = document.getElementById('paramForm'); const form = document.getElementById('paramForm');
const detailPanel = document.getElementById('detailPanel');
// --- Etiqueta de texto con CanvasTexture (sin SpriteText externo) --- let lastGraphData = { nodes: [], links: [] };
function makeTextSprite(text) { let fullGraphData = null;
if (!window.THREE) { let currentRelatedNodes = [];
console.warn('THREE no está disponible; omito etiquetas.'); let currentRelatedIdx = 0;
return undefined; let currentNode = null;
}
const pad = 16; // más padding para nitidez
const font = 'bold 36px Fira Code, monospace'; // fuente más grande
// 1) preparar canvas al tamaño del texto const textureCache = {};
const c = document.createElement('canvas'); function getTexture(url) {
const ctx = c.getContext('2d'); if (!textureCache[url]) textureCache[url] = new THREE.TextureLoader().load(url);
ctx.font = font; return textureCache[url];
const w = Math.ceil(ctx.measureText(text).width) + pad * 2;
const h = 50 + pad * 2;
c.width = w; c.height = h;
// 2) dibujar texto
ctx.font = font;
ctx.fillStyle = 'rgba(255,255,255,0.98)';
ctx.textBaseline = 'middle';
ctx.fillText(text, pad, h / 2);
// 3) textura -> sprite
const tex = new THREE.CanvasTexture(c);
tex.needsUpdate = true;
const mat = new THREE.SpriteMaterial({
map: tex,
transparent: true,
depthTest: false, // que no lo tape la esfera
depthWrite: false // que no escriba en el z-buffer
});
const spr = new THREE.Sprite(mat);
// tamaño del texto en “mundo”
const k = 0.22; // ajusta tamaño del texto (↑ más grande, ↓ más pequeño)
spr.scale.set(w * k, h * k, 1);
// elevar el texto sobre el nodo
spr.position.y = 8; // sube/baja si lo ves muy pegado
return spr;
} }
// Inicializar el gráfico 3D
const graph = ForceGraph3D()(elem) const graph = ForceGraph3D()(elem)
.backgroundColor('#000000') .backgroundColor('#000000')
.nodeLabel('id') .nodeLabel(node => node.type === 'imagen' ? (node.label || node.id) : node.id)
.nodeAutoColorBy('group') .nodeAutoColorBy('group')
.nodeVal(1) .nodeVal(node => node.type === 'imagen' ? 20 : 2)
.linkColor(() => 'blue') .linkColor(() => 'blue')
// Texto fijo sobre cada nodo (usando CanvasTexture) .onNodeClick(node => showNodeDetail(node))
.nodeThreeObject(n => makeTextSprite(n.id))
.nodeThreeObjectExtend(true) // mantiene esfera + texto
.onNodeClick(node => showNodeContent(node.content))
.onNodeHover(node => { elem.style.cursor = node ? 'pointer' : null; }) .onNodeHover(node => { elem.style.cursor = node ? 'pointer' : null; })
.nodeThreeObject(node => {
if (node.type !== 'imagen' || !node.image_url) return null;
const texture = getTexture(node.image_url);
texture.colorSpace = THREE.SRGBColorSpace;
const material = new THREE.SpriteMaterial({ map: texture, depthWrite: false });
const sprite = new THREE.Sprite(material);
sprite.scale.set(160, 104, 1);
return sprite;
})
.nodeThreeObjectExtend(false)
.forceEngine('d3'); .forceEngine('d3');
// .d3Force('charge', d3.forceManyBody().strength(-400))
// .d3Force('link', d3.forceLink().distance(300).strength(1))
// Centrado automático del gráfico graph.d3Force('charge').strength(-150);
graph.d3Force('link').distance(70).strength(0.3);
graph.d3Force('center').strength(0.05);
function centerGraph() { function centerGraph() {
setTimeout(() => { setTimeout(() => {
const width = elem.clientWidth; graph.zoomToFit(400, Math.min(elem.clientWidth, elem.clientHeight) * 0.1);
const height = elem.clientHeight;
graph.zoomToFit(400, Math.min(width, height) * 0.1);
}, 500); }, 500);
} }
window.addEventListener('resize', () => { window.addEventListener('resize', () => {
@ -81,101 +53,175 @@ document.addEventListener('DOMContentLoaded', () => {
centerGraph(); centerGraph();
}); });
// Mostrar contenido del nodo function getAuthor(node) { return node.autor || node.fuente || node.source || ''; }
function showNodeContent(content) { function formatDate(fecha) {
if (!detailPanel) { if (!fecha) return '';
console.log('Contenido del nodo:', content); try { return new Date(fecha).toLocaleDateString('es-ES'); } catch { return String(fecha); }
return; }
function formatTitle(id) { return String(id).replace(/_/g, ' '); }
function getRelated(node) {
const nid = node.id;
const relIds = new Set();
for (const lk of lastGraphData.links) {
const s = typeof lk.source === 'object' ? lk.source.id : lk.source;
const t = typeof lk.target === 'object' ? lk.target.id : lk.target;
if (s === nid) relIds.add(t);
else if (t === nid) relIds.add(s);
} }
const h2 = document.createElement('h2'); return lastGraphData.nodes.filter(n => relIds.has(n.id));
h2.style.cssText = 'margin:0 0 8px';
h2.textContent = 'Detalle';
const pre = document.createElement('pre');
pre.style.cssText = 'white-space:pre-wrap; line-height:1.35; font-family:\'Fira Code\', monospace; font-size:14px; color:#e5e5e5;';
pre.textContent = content || 'No hay contenido disponible.';
detailPanel.replaceChildren(h2, pre);
const split = document.querySelector('main.split-screen');
if (split) split.classList.add('show-detail');
} }
// Función para obtener datos del servidor function renderDetailPanel(node, related, idx) {
const detailContent = document.getElementById('detailContent');
const split = document.querySelector('main.split-screen');
if (!detailContent || !split) return;
detailContent.querySelector('.detail-author').textContent = getAuthor(node);
detailContent.querySelector('.detail-date').textContent = formatDate(node.fecha || node.date);
detailContent.querySelector('.detail-title').textContent = formatTitle(node.id);
const relCount = detailContent.querySelector('.relations-count');
const relLabel = document.getElementById('relationLabel');
const prevBtn = document.getElementById('prevRelation');
const nextBtn = document.getElementById('nextRelation');
relCount.textContent = `${related.length} relación${related.length !== 1 ? 'es' : ''}`;
if (related.length > 0) {
relLabel.textContent = formatTitle(related[idx].id);
prevBtn.disabled = idx <= 0;
nextBtn.disabled = idx >= related.length - 1;
} else {
relLabel.textContent = '—';
prevBtn.disabled = true;
nextBtn.disabled = true;
}
const body = detailContent.querySelector('.detail-body');
body.innerHTML = '';
if (node.type === 'imagen' && node.image_url) {
const img = document.createElement('img');
img.src = node.image_url;
img.className = 'detail-img';
body.appendChild(img);
}
const text = document.createElement('span');
text.textContent = node.content || node.texto || node.descripcion || 'Sin contenido disponible.';
body.appendChild(text);
if (related.length > 0) {
const conexBtn = document.createElement('button');
conexBtn.id = 'verConexionesBtn';
conexBtn.textContent = '⬡ Ver solo conexiones de este nodo';
conexBtn.addEventListener('click', () => showEgoGraph(node));
body.appendChild(conexBtn);
}
split.classList.add('show-detail');
setTimeout(() => { graph.width(elem.clientWidth); graph.height(elem.clientHeight); }, 350);
}
function showEgoGraph(node) {
const nid = node.id;
const relIds = new Set();
for (const lk of lastGraphData.links) {
const s = typeof lk.source === 'object' ? lk.source.id : lk.source;
const t = typeof lk.target === 'object' ? lk.target.id : lk.target;
if (s === nid) relIds.add(t);
else if (t === nid) relIds.add(s);
}
const egoNodes = lastGraphData.nodes.filter(n => n.id === nid || relIds.has(n.id));
const egoIds = new Set(egoNodes.map(n => n.id));
const egoLinks = lastGraphData.links.filter(lk => {
const s = typeof lk.source === 'object' ? lk.source.id : lk.source;
const t = typeof lk.target === 'object' ? lk.target.id : lk.target;
return egoIds.has(s) && egoIds.has(t);
});
fullGraphData = lastGraphData;
graph.graphData({ nodes: egoNodes, links: egoLinks });
centerGraph();
document.getElementById('volverGrafoBtn').classList.add('visible');
}
function restoreFullGraph() {
if (fullGraphData) {
lastGraphData = fullGraphData;
fullGraphData = null;
graph.graphData({ nodes: lastGraphData.nodes, links: lastGraphData.links });
centerGraph();
document.getElementById('volverGrafoBtn').classList.remove('visible');
}
}
function showNodeDetail(node) {
currentNode = node;
currentRelatedNodes = getRelated(node);
currentRelatedIdx = 0;
renderDetailPanel(node, currentRelatedNodes, currentRelatedIdx);
}
document.getElementById('prevRelation').addEventListener('click', () => {
if (currentRelatedIdx > 0) {
currentRelatedIdx--;
renderDetailPanel(currentNode, currentRelatedNodes, currentRelatedIdx);
}
});
document.getElementById('nextRelation').addEventListener('click', () => {
if (currentRelatedIdx < currentRelatedNodes.length - 1) {
currentRelatedIdx++;
renderDetailPanel(currentNode, currentRelatedNodes, currentRelatedIdx);
}
});
document.getElementById('closeDetail').addEventListener('click', () => {
const split = document.querySelector('main.split-screen');
if (split) split.classList.remove('show-detail');
restoreFullGraph();
setTimeout(() => { graph.width(elem.clientWidth); graph.height(elem.clientHeight); }, 350);
});
document.getElementById('volverGrafoBtn').addEventListener('click', () => {
restoreFullGraph();
});
async function getData(paramsObj = {}) { async function getData(paramsObj = {}) {
try { try {
let url = '/api/data'; let url = '/api/data';
const params = new URLSearchParams(); const params = new URLSearchParams();
// Establecer el tema fijo
params.append('tema', 'inteligencia y seguridad'); params.append('tema', 'inteligencia y seguridad');
// Agregar parámetros del formulario, incluyendo complejidad como umbral
for (const key in paramsObj) { for (const key in paramsObj) {
if (paramsObj[key] !== undefined && paramsObj[key] !== '') { if (paramsObj[key] !== undefined && paramsObj[key] !== '') {
params.append(key, paramsObj[key]); params.append(key, paramsObj[key]);
} }
} }
url += `?${params.toString()}`; url += `?${params.toString()}`;
console.log('🔎 Fetch URL:', url); console.log('Fetch URL:', url);
const response = await fetch(url); const response = await fetch(url);
if (!response.ok) throw new Error(response.statusText); if (!response.ok) throw new Error(response.statusText);
const data = await response.json(); const data = await response.json();
console.log('Datos recibidos del servidor:', data); const nodeIds = new Set(data.nodes.map(n => n.id));
data.links = data.links.filter(lk => nodeIds.has(lk.source) && nodeIds.has(lk.target));
// Filtrar enlaces inválidos
const nodeIds = new Set(data.nodes.map(node => node.id));
data.links = data.links.filter(link => nodeIds.has(link.source) && nodeIds.has(link.target));
return data; return data;
} catch (error) { } catch (error) {
console.error('Error al obtener datos del servidor:', error); console.error('Error al obtener datos:', error);
return null; return null;
} }
} }
// Función principal: fetch, filtrar por complejidad/umbral y renderizar
async function fetchAndRender() { async function fetchAndRender() {
const subtematica = document.getElementById('param2').value;
const palabraClave = document.getElementById('param1').value;
const fechaInicio = document.getElementById('fecha_inicio').value;
const fechaFin = document.getElementById('fecha_fin').value;
const nodos = document.getElementById('nodos').value;
const complejidad = document.getElementById('complejidad').value;
// Usamos 'complejidad' como porcentaje de similitud mínima (umbral)
const paramsObj = { const paramsObj = {
subtematica, subtematica: document.getElementById('param2').value,
palabraClave, palabraClave: document.getElementById('param1').value,
fechaInicio, fechaInicio: document.getElementById('fecha_inicio').value,
fechaFin, fechaFin: document.getElementById('fecha_fin').value,
nodos, nodos: document.getElementById('nodos').value,
complejidad // enviado al backend y usado de umbral en cliente complejidad: document.getElementById('complejidad').value,
}; };
// Parsear complejidad a número
const umbralPct = parseFloat(complejidad) || 0;
console.log(`Aplicando umbral de similitud: ${umbralPct}%`);
const graphData = await getData(paramsObj); const graphData = await getData(paramsObj);
if (!graphData) return; if (!graphData) return;
lastGraphData = graphData;
// Filtrar enlaces por umbral de similitud graph.graphData({ nodes: graphData.nodes, links: graphData.links });
let filteredLinks = graphData.links;
if (umbralPct > 0) {
filteredLinks = filteredLinks.filter(link => Number(link.value) >= umbralPct);
console.log(`Enlaces tras aplicar umbral: ${filteredLinks.length}`);
}
graph.graphData({ nodes: graphData.nodes, links: filteredLinks });
centerGraph(); centerGraph();
} }
// Escuchar evento submit del formulario form.addEventListener('submit', event => { event.preventDefault(); fetchAndRender(); });
form.addEventListener('submit', event => {
event.preventDefault();
fetchAndRender();
});
// Cargar gráfico inicial
fetchAndRender(); fetchAndRender();
}); });

View file

@ -1,29 +1,50 @@
// output_popl_up_pruebas.js // output_popl_up_pruebas.js
import ForceGraph3D from 'https://esm.sh/3d-force-graph?external=three';
import * as THREE from 'three';
// Obtener el contenedor del gráfico
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const elem = document.getElementById('poplUpContainer'); const elem = document.getElementById('poplUpContainer');
const form = document.getElementById('paramForm'); const form = document.getElementById('paramForm');
// Inicializar el gráfico 3D let lastGraphData = { nodes: [], links: [] };
let fullGraphData = null;
let currentRelatedNodes = [];
let currentRelatedIdx = 0;
let currentNode = null;
const textureCache = {};
function getTexture(url) {
if (!textureCache[url]) textureCache[url] = new THREE.TextureLoader().load(url);
return textureCache[url];
}
const graph = ForceGraph3D()(elem) const graph = ForceGraph3D()(elem)
.backgroundColor('#000000') .backgroundColor('#000000')
.nodeLabel('id') .nodeLabel(node => node.type === 'imagen' ? (node.label || node.id) : node.id)
.nodeAutoColorBy('group') .nodeAutoColorBy('group')
.nodeVal(1) .nodeVal(node => node.type === 'imagen' ? 20 : 2)
.linkColor(() => 'orange') .linkColor(() => 'orange')
.onNodeClick(node => showNodeContent(node.content)) .onNodeClick(node => showNodeDetail(node))
.onNodeHover(node => { elem.style.cursor = node ? 'pointer' : null; }) .onNodeHover(node => { elem.style.cursor = node ? 'pointer' : null; })
.forceEngine('d3') .nodeThreeObject(node => {
//.d3Force('charge', d3.forceManyBody().strength(-300)) if (node.type !== 'imagen' || !node.image_url) return null;
//.d3Force('link', d3.forceLink().distance(300).strength(1)); const texture = getTexture(node.image_url);
texture.colorSpace = THREE.SRGBColorSpace;
const material = new THREE.SpriteMaterial({ map: texture, depthWrite: false });
const sprite = new THREE.Sprite(material);
sprite.scale.set(160, 104, 1);
return sprite;
})
.nodeThreeObjectExtend(false)
.forceEngine('d3');
graph.d3Force('charge').strength(-150);
graph.d3Force('link').distance(70).strength(0.3);
graph.d3Force('center').strength(0.05);
// Centrado automático del gráfico
function centerGraph() { function centerGraph() {
setTimeout(() => { setTimeout(() => {
const width = elem.clientWidth; graph.zoomToFit(400, Math.min(elem.clientWidth, elem.clientHeight) * 0.1);
const height = elem.clientHeight;
graph.zoomToFit(400, Math.min(width, height) * 0.1);
}, 500); }, 500);
} }
window.addEventListener('resize', () => { window.addEventListener('resize', () => {
@ -32,98 +53,175 @@ document.addEventListener('DOMContentLoaded', () => {
centerGraph(); centerGraph();
}); });
// Mostrar contenido del nodo function getAuthor(node) { return node.autor || node.fuente || node.source || ''; }
function showNodeContent(content) { function formatDate(fecha) {
console.log('Contenido del nodo:', content); if (!fecha) return '';
try { return new Date(fecha).toLocaleDateString('es-ES'); } catch { return String(fecha); }
}
function formatTitle(id) { return String(id).replace(/_/g, ' '); }
function getRelated(node) {
const nid = node.id;
const relIds = new Set();
for (const lk of lastGraphData.links) {
const s = typeof lk.source === 'object' ? lk.source.id : lk.source;
const t = typeof lk.target === 'object' ? lk.target.id : lk.target;
if (s === nid) relIds.add(t);
else if (t === nid) relIds.add(s);
}
return lastGraphData.nodes.filter(n => relIds.has(n.id));
} }
// Función para obtener datos del servidor function renderDetailPanel(node, related, idx) {
const detailContent = document.getElementById('detailContent');
const split = document.querySelector('main.split-screen');
if (!detailContent || !split) return;
detailContent.querySelector('.detail-author').textContent = getAuthor(node);
detailContent.querySelector('.detail-date').textContent = formatDate(node.fecha || node.date);
detailContent.querySelector('.detail-title').textContent = formatTitle(node.id);
const relCount = detailContent.querySelector('.relations-count');
const relLabel = document.getElementById('relationLabel');
const prevBtn = document.getElementById('prevRelation');
const nextBtn = document.getElementById('nextRelation');
relCount.textContent = `${related.length} relación${related.length !== 1 ? 'es' : ''}`;
if (related.length > 0) {
relLabel.textContent = formatTitle(related[idx].id);
prevBtn.disabled = idx <= 0;
nextBtn.disabled = idx >= related.length - 1;
} else {
relLabel.textContent = '—';
prevBtn.disabled = true;
nextBtn.disabled = true;
}
const body = detailContent.querySelector('.detail-body');
body.innerHTML = '';
if (node.type === 'imagen' && node.image_url) {
const img = document.createElement('img');
img.src = node.image_url;
img.className = 'detail-img';
body.appendChild(img);
}
const text = document.createElement('span');
text.textContent = node.content || node.texto || node.descripcion || 'Sin contenido disponible.';
body.appendChild(text);
if (related.length > 0) {
const conexBtn = document.createElement('button');
conexBtn.id = 'verConexionesBtn';
conexBtn.textContent = '⬡ Ver solo conexiones de este nodo';
conexBtn.addEventListener('click', () => showEgoGraph(node));
body.appendChild(conexBtn);
}
split.classList.add('show-detail');
setTimeout(() => { graph.width(elem.clientWidth); graph.height(elem.clientHeight); }, 350);
}
function showEgoGraph(node) {
const nid = node.id;
const relIds = new Set();
for (const lk of lastGraphData.links) {
const s = typeof lk.source === 'object' ? lk.source.id : lk.source;
const t = typeof lk.target === 'object' ? lk.target.id : lk.target;
if (s === nid) relIds.add(t);
else if (t === nid) relIds.add(s);
}
const egoNodes = lastGraphData.nodes.filter(n => n.id === nid || relIds.has(n.id));
const egoIds = new Set(egoNodes.map(n => n.id));
const egoLinks = lastGraphData.links.filter(lk => {
const s = typeof lk.source === 'object' ? lk.source.id : lk.source;
const t = typeof lk.target === 'object' ? lk.target.id : lk.target;
return egoIds.has(s) && egoIds.has(t);
});
fullGraphData = lastGraphData;
graph.graphData({ nodes: egoNodes, links: egoLinks });
centerGraph();
document.getElementById('volverGrafoBtn').classList.add('visible');
}
function restoreFullGraph() {
if (fullGraphData) {
lastGraphData = fullGraphData;
fullGraphData = null;
graph.graphData({ nodes: lastGraphData.nodes, links: lastGraphData.links });
centerGraph();
document.getElementById('volverGrafoBtn').classList.remove('visible');
}
}
function showNodeDetail(node) {
currentNode = node;
currentRelatedNodes = getRelated(node);
currentRelatedIdx = 0;
renderDetailPanel(node, currentRelatedNodes, currentRelatedIdx);
}
document.getElementById('prevRelation').addEventListener('click', () => {
if (currentRelatedIdx > 0) {
currentRelatedIdx--;
renderDetailPanel(currentNode, currentRelatedNodes, currentRelatedIdx);
}
});
document.getElementById('nextRelation').addEventListener('click', () => {
if (currentRelatedIdx < currentRelatedNodes.length - 1) {
currentRelatedIdx++;
renderDetailPanel(currentNode, currentRelatedNodes, currentRelatedIdx);
}
});
document.getElementById('closeDetail').addEventListener('click', () => {
const split = document.querySelector('main.split-screen');
if (split) split.classList.remove('show-detail');
restoreFullGraph();
setTimeout(() => { graph.width(elem.clientWidth); graph.height(elem.clientHeight); }, 350);
});
document.getElementById('volverGrafoBtn').addEventListener('click', () => {
restoreFullGraph();
});
async function getData(paramsObj = {}) { async function getData(paramsObj = {}) {
try { try {
let url = '/api/data'; let url = '/api/data';
const params = new URLSearchParams(); const params = new URLSearchParams();
// Establecer el tema fijo
params.append('tema', 'demografía y sociedad'); params.append('tema', 'demografía y sociedad');
// Agregar parámetros del formulario, incluyendo complejidad como umbral
for (const key in paramsObj) { for (const key in paramsObj) {
if (paramsObj[key] !== undefined && paramsObj[key] !== '') { if (paramsObj[key] !== undefined && paramsObj[key] !== '') {
params.append(key, paramsObj[key]); params.append(key, paramsObj[key]);
} }
} }
url += `?${params.toString()}`; url += `?${params.toString()}`;
console.log('🔎 Fetch URL:', url); console.log('Fetch URL:', url);
const response = await fetch(url); const response = await fetch(url);
if (!response.ok) throw new Error(response.statusText); if (!response.ok) throw new Error(response.statusText);
const data = await response.json(); const data = await response.json();
console.log('Datos recibidos del servidor:', data); const nodeIds = new Set(data.nodes.map(n => n.id));
data.links = data.links.filter(lk => nodeIds.has(lk.source) && nodeIds.has(lk.target));
// Filtrar enlaces inválidos
const nodeIds = new Set(data.nodes.map(node => node.id));
data.links = data.links.filter(link => nodeIds.has(link.source) && nodeIds.has(link.target));
return data; return data;
} catch (error) { } catch (error) {
console.error('Error al obtener datos del servidor:', error); console.error('Error al obtener datos:', error);
return null; return null;
} }
} }
// Función principal: fetch, filtrar por complejidad/umbral y renderizar
async function fetchAndRender() { async function fetchAndRender() {
const subtematica = document.getElementById('param2').value;
const palabraClave = document.getElementById('param1').value;
const fechaInicio = document.getElementById('fecha_inicio').value;
const fechaFin = document.getElementById('fecha_fin').value;
const nodos = document.getElementById('nodos').value;
const complejidad = document.getElementById('complejidad').value;
// Usamos 'complejidad' como porcentaje de similitud mínima (umbral)
const paramsObj = { const paramsObj = {
subtematica, subtematica: document.getElementById('param2').value,
palabraClave, palabraClave: document.getElementById('param1').value,
fechaInicio, fechaInicio: document.getElementById('fecha_inicio').value,
fechaFin, fechaFin: document.getElementById('fecha_fin').value,
nodos, nodos: document.getElementById('nodos').value,
complejidad // enviado al backend y usado de umbral en cliente complejidad: document.getElementById('complejidad').value,
}; };
// Parsear complejidad a número
const umbralPct = parseFloat(complejidad) || 0;
console.log(`Aplicando umbral de similitud: ${umbralPct}%`);
const graphData = await getData(paramsObj); const graphData = await getData(paramsObj);
if (!graphData) return; if (!graphData) return;
lastGraphData = graphData;
// Filtrar enlaces por umbral de similitud graph.graphData({ nodes: graphData.nodes, links: graphData.links });
let filteredLinks = graphData.links;
if (umbralPct > 0) {
filteredLinks = filteredLinks.filter(link => Number(link.value) >= umbralPct);
console.log(`Enlaces tras aplicar umbral: ${filteredLinks.length}`);
}
graph.graphData({ nodes: graphData.nodes, links: filteredLinks });
centerGraph(); centerGraph();
} }
// Escuchar evento submit del formulario form.addEventListener('submit', event => { event.preventDefault(); fetchAndRender(); });
form.addEventListener('submit', event => {
event.preventDefault();
fetchAndRender();
});
// Cargar gráfico inicial
fetchAndRender(); fetchAndRender();
}); });

View file

@ -4,6 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>Demografía y Sociedad</title> <title>Demografía y Sociedad</title>
<link rel="stylesheet" href="popl-up.css"> <link rel="stylesheet" href="popl-up.css">
<link rel="stylesheet" href="sub-nav.css">
<!-- Fuentes --> <!-- Fuentes -->
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
@ -12,37 +13,114 @@
<!-- Librerías necesarias --> <!-- Librerías necesarias -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/6.7.0/d3.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/d3/6.7.0/d3.min.js"></script>
<script src="https://unpkg.com/3d-force-graph"></script> <script type="importmap">
{ "imports": { "three": "https://esm.sh/three@0.179.0", "three/": "https://esm.sh/three@0.179.0/" } }
</script>
</head> </head>
<body> <body>
<!-- Navegación --> <!-- Navegación -->
<nav> <nav class="top-nav">
<ul class="nav-links"> <div class="section-buttons">
<li><a href="popl-up.html" class="popl-up">Demografía y Sociedad</a></li>
</ul> <div class="section-item">
<a href="glob-war.html" class="section-btn glob-war-btn">GLOB-WAR</a>
</div>
<div class="section-item">
<a href="int-sec.html" class="section-btn int-sec-btn">INT-SEC</a>
</div>
<div class="section-item">
<a href="climate.html" class="section-btn climate-btn">CLIMATE</a>
</div>
<div class="section-item">
<a href="eco-corp.html" class="section-btn eco-corp-btn">ECO-CORP</a>
</div>
<div class="section-item current">
<a href="popl-up.html" class="section-btn popl-up-btn">POPL-UP <span class="dropdown-arrow"></span></a>
<div class="section-dropdown">
<a href="#" onclick="setSubtema('sobrepoblación');return false;">Sobrepoblación</a>
<a href="#" onclick="setSubtema('enfermedades');return false;">COVID / Enfermedades</a>
<a href="#" onclick="setSubtema('migraciones');return false;">Migraciones</a>
<a href="#" onclick="setSubtema('urbanización');return false;">Urbanización</a>
<a href="#" onclick="setSubtema('distribucion_edad');return false;">Despoblación Rural</a>
</div>
</div>
</div>
</nav> </nav>
<script>
function setSubtema(val) {
var sel = document.getElementById('param2');
if (sel) { sel.value = val; }
var form = document.getElementById('paramForm');
if (form) form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
}
document.addEventListener('DOMContentLoaded', function() {
/* ── Dropdown touch (móvil) ── */
var curr = document.querySelector('.section-item.current');
if (curr) {
curr.querySelector('.section-btn').addEventListener('touchend', function(e) {
e.preventDefault();
curr.classList.toggle('open');
});
document.addEventListener('touchend', function(e) {
if (!curr.contains(e.target)) curr.classList.remove('open');
});
}
/* ── Sidebar: botón toggle visible en móvil ── */
var toggle = document.getElementById('sidebarToggle');
var sidebar = document.getElementById('sidebar');
if (toggle && sidebar) {
toggle.style.display = 'block';
toggle.addEventListener('click', function() {
sidebar.classList.toggle('active');
});
}
});
</script>
<!-- Contenedor principal --> <!-- Contenedor principal -->
<main> <main class="split-screen">
<div id="poplUpContainer" style="position: absolute; width: 100%; height: 100%; z-index: 0;"></div> <div id="graphPanel" style="position:relative;">
<button id="volverGrafoBtn">← Volver al grafo completo</button>
<!-- Fondo animado --> <div id="poplUpContainer" style="position: absolute; width: 100%; height: 100%; z-index: 0;"></div>
<div class="background"> <!-- Fondo animado -->
<img src="/images/flujos3.jpg"> <div class="background">
<img src="/images/flujos3.jpg"> <img src="/images/flujos3.jpg">
<img src="/images/flujos3.jpg"> <img src="/images/flujos3.jpg">
<img src="/images/flujos3.jpg"> <img src="/images/flujos3.jpg">
<img src="/images/flujos3.jpg"> <img src="/images/flujos3.jpg">
<img src="/images/flujos3.jpg">
</div>
<script>
setTimeout(() => {
const fondo = document.querySelector('.background');
fondo.classList.add('fade-out');
fondo.style.pointerEvents = 'none';
}, 1000);
</script>
</div>
<div id="detailPanel">
<button id="closeDetail">✕ cerrar</button>
<div id="detailContent">
<div class="detail-meta">
<span class="detail-author"></span>
<span class="detail-date"></span>
</div>
<h2 class="detail-title"></h2>
<div class="detail-relations">
<span class="relations-count"></span>
<div class="relations-nav">
<button id="prevRelation" disabled>&#8592; ant</button>
<span id="relationLabel"></span>
<button id="nextRelation" disabled>sig &#8594;</button>
</div>
</div>
<div class="detail-body"></div>
</div>
</div> </div>
<script>
setTimeout(() => {
const fondo = document.querySelector('.background');
fondo.classList.add('fade-out');
fondo.style.pointerEvents = 'none';
}, 1000);
</script>
</main> </main>
<!-- Barra lateral de filtros --> <!-- Barra lateral de filtros -->
@ -56,16 +134,23 @@
<input type="date" id="fecha_fin" name="fecha_fin"> <input type="date" id="fecha_fin" name="fecha_fin">
<label for="nodos">Nodos:</label> <label for="nodos">Nodos:</label>
<input type="number" id="nodos" name="nodos" value="100"> <input type="number" id="nodos" name="nodos" value="100" min="5" max="500">
<label for="complejidad">Complejidad:</label> <label for="complejidad">% similitud: <span id="complejidadVal" class="sim-val">8</span></label>
<input type="range" id="complejidad" name="complejidad" min="1" max="40" value="20"> <input type="range" id="complejidad" name="complejidad" min="1" max="25" value="8"
oninput="document.getElementById('complejidadVal').textContent = this.value">
<label for="param1">Búsqueda por palabra:</label> <label for="param1">Palabra clave:</label>
<input type="text" id="param1" name="param1"> <input type="text" id="param1" name="param1" placeholder="ej: migración, pandemia...">
<label for="param2">Búsqueda por temática personalizada:</label> <label for="param2">Subtematica:</label>
<input type="text" id="param2" name="param2"> <select id="param2" name="param2">
<option value="">— Todas —</option>
<option value="enfermedades">enfermedades</option>
<option value="urbanización">urbanización</option>
<option value="migraciones">migraciones</option>
<option value="sobrepoblación">sobrepoblación</option>
</select>
<input type="submit" value="Aplicar"> <input type="submit" value="Aplicar">
</form> </form>
@ -74,17 +159,7 @@
<!-- Botón para colapsar la barra --> <!-- Botón para colapsar la barra -->
<button id="sidebarToggle">Toggle Sidebar</button> <button id="sidebarToggle">Toggle Sidebar</button>
<!-- Footer -->
<footer>
<p>
<a href="#">GitHub</a> |
<a href="#">Telegram</a> |
<a href="#">Email</a> |
<a href="#">Web de Tor</a>
</p>
</footer>
<!-- Script principal --> <!-- Script principal -->
<script src="output_popl_up_pruebas.js"></script> <script type="module" src="output_popl_up_pruebas.js"></script>
</body> </body>
</html> </html>

View file

@ -42,19 +42,35 @@ header {
display: flex; display: flex;
align-items: center; align-items: center;
position: relative; position: relative;
max-width: 1200px;
width: 100%; width: 100%;
padding: 0 14px;
} }
.header-buttons { .header-buttons {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px;
position: absolute; position: absolute;
top: 70%; top: 50%;
right: 10px; right: 14px;
transform: translateY(-50%); transform: translateY(-50%);
} }
.header-buttons--left {
right: auto;
left: 14px;
}
.stats-btn {
border-color: #39ff14;
color: #39ff14;
}
.stats-btn:hover {
background-color: #39ff14;
color: #000;
}
.small-button { .small-button {
margin-left: 5px; margin-left: 5px;
margin-right: 3px; margin-right: 3px;
@ -495,501 +511,192 @@ footer p {
padding: 12px 20px; padding: 12px 20px;
} }
} }
/* Para teléfonos de 768px de ancho en portrait */ /* ============================================================
@media screen and (max-width: 768px) and (orientation: portrait) { MÓVIL Portrait ( 1024px)
.background a { ============================================================ */
width: 90%;
}
.button-column { @media screen and (max-width: 1024px) and (orientation: portrait) {
width: 90%; header { height: auto; padding: 12px 10px; }
gap: 10px;
}
.overlay-button { .title { font-size: 3.2rem; }
font-size: 0.6rem;
padding: 6px 8px;
}
.title { .header-buttons {
font-size: 3rem; position: static;
transform: none;
margin-top: 6px;
flex-wrap: wrap;
justify-content: flex-start;
gap: 5px;
} }
.header-buttons--left { position: static; }
.small-button { .small-button {
font-size: 0.3rem; font-size: 0.72rem;
padding: 2px 2px; padding: 5px 10px;
} }
/* Nav: fila horizontal scrollable de botones compactos */
nav {
margin-top: 8px;
margin-bottom: 0;
padding: 6px 8px;
overflow-x: auto;
justify-content: flex-start;
}
.nav-links { .nav-links {
gap: 2em; /* Reduce el espacio entre los enlaces del nav */ flex-wrap: nowrap;
gap: 8px;
padding: 0 2px;
}
.nav-links li { flex-shrink: 0; }
.nav-links a {
width: auto;
height: auto;
font-size: 0.78rem;
padding: 8px 14px;
border-width: 2px;
border-radius: 20px;
} }
.nav-links a { /* Layout de imágenes: 2 columnas en lugar de 5 */
font-size: 0.8rem; .background {
padding: 8px 16px; flex-wrap: wrap;
height: auto;
overflow-x: hidden;
}
.background a {
width: 50%;
height: 30vw;
min-height: 120px;
}
/* Botones overlay: rejilla 2×3 */
.button-overlay {
position: static;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
padding: 14px 12px 60px;
background: rgba(0,0,0,0.75);
height: auto;
top: auto;
}
.button-column {
width: 100%;
gap: 8px;
}
.overlay-button {
font-size: 0.75rem;
padding: 8px 12px;
} }
} }
/* Para teléfonos de 576px de ancho en portrait */ /* ≤ 600px portrait */
@media screen and (max-width: 576px) and (orientation: portrait) { @media screen and (max-width: 600px) and (orientation: portrait) {
.background a { .title { font-size: 2.2rem; }
width: 95%;
}
.button-column {
width: 95%;
gap: 30px;
}
.overlay-button {
font-size: 0.3rem;
padding: 5px 7px;
}
.title {
font-size: 4rem;
}
.small-button { .small-button {
font-size: 0.3rem; font-size: 0.65rem;
padding: 4px 8px;
}
.nav-links a {
font-size: 0.68rem;
padding: 7px 11px;
}
.background a {
width: 50%;
height: 28vw;
}
.button-overlay {
grid-template-columns: 1fr 1fr;
gap: 8px;
padding: 10px 8px 56px;
}
.overlay-button {
font-size: 0.68rem;
padding: 7px 10px;
}
}
/* ≤ 420px portrait */
@media screen and (max-width: 420px) and (orientation: portrait) {
.title { font-size: 1.8rem; }
.header-content { flex-direction: column; align-items: flex-start; gap: 6px; }
.small-button {
font-size: 0.6rem;
padding: 4px 7px; padding: 4px 7px;
} }
.nav-links { .nav-links a {
gap: 1.5em; font-size: 0.62rem;
padding: 6px 10px;
} }
.nav-links a { .background a {
font-size: 0.4rem; width: 50%;
padding: 7px 14px; height: 26vw;
}
.button-overlay {
grid-template-columns: 1fr 1fr;
gap: 6px;
}
.overlay-button {
font-size: 0.62rem;
padding: 6px 8px;
} }
} }
/* Para teléfonos de 411px de ancho en portrait */ /* ============================================================
@media screen and (max-width: 411px) and (orientation: portrait) { MÓVIL Landscape (altura 500px)
.background a { ============================================================ */
width: 100%;
}
.button-column { @media screen and (max-width: 900px) and (orientation: landscape) and (max-height: 500px) {
width: 100%; header { padding: 6px 10px; }
gap: 5px; .title { font-size: 1.6rem; }
}
.overlay-button {
font-size: 0.4rem;
padding: 4px 5px;
}
.title {
font-size: 1.5rem;
}
.small-button { .small-button {
font-size: 0.3rem; font-size: 0.65rem;
padding: 4px 5px;
}
.nav-links {
gap: 1em;
}
.nav-links a {
font-size: 0.6rem;
padding: 6px 12px;
}
}
/* Para teléfonos de 375px de ancho en portrait */
@media screen and (max-width: 375px) and (orientation: portrait) {
.background a {
width: 100%;
}
.button-column {
width: 100%;
gap: 3px;
}
.overlay-button {
font-size: 0.35rem;
padding: 3px 4px;
}
.title {
font-size: 1.3rem;
}
.small-button {
font-size: 0.35rem;
padding: 3px 4px;
}
.nav-links {
gap: 0.75em;
}
.nav-links a {
font-size: 0.5rem;
padding: 5px 10px;
}
}
/* Para teléfonos de 320px de ancho en portrait */
@media screen and (max-width: 320px) and (orientation: portrait) {
.background a {
width: 100%;
}
.button-column {
width: 100%;
gap: 2px;
}
.overlay-button {
font-size: 0.3rem;
padding: 2px 3px;
}
.title {
font-size: 1.2rem;
}
.small-button {
font-size: 0.3rem;
padding: 2px 3px;
}
.nav-links {
gap: 0.5em;
}
.nav-links a {
font-size: 0.4rem;
padding: 4px 8px; padding: 4px 8px;
} }
}
nav { margin-top: 4px; padding: 4px 8px; overflow-x: auto; justify-content: flex-start; }
/* For devices with smaller screens (e.g., 600px - 800px width) */ .nav-links { flex-wrap: nowrap; gap: 6px; }
@media screen and (min-width: 600px) and (max-width: 800px) and (max-height: 400px) and (orientation: landscape) { .nav-links a {
.background { width: auto; height: auto;
display: flex; font-size: 0.65rem;
justify-content: center; padding: 6px 10px;
height: 100vh; border-radius: 16px;
width: 100%;
overflow-x: auto;
} }
.background { height: 100vh; flex-wrap: nowrap; overflow-x: auto; }
.background a { width: 20%; height: 100%; flex-shrink: 0; }
.button-overlay { .button-overlay {
display: flex; display: flex;
justify-content: space-around; justify-content: space-around;
align-items: flex-end; align-items: center;
height: 100%; top: 60px;
padding-bottom: 0px; height: calc(100% - 60px);
} }
.button-column { width: 18%; gap: 4px; }
.button-column {
width: 15%;
display: flex;
flex-direction: column;
justify-content: flex-end;
height: auto;
}
.overlay-button { .overlay-button {
font-size: 0.3rem; font-size: 0.6rem;
padding: 2px 2px; padding: 5px 8px;
}
.title {
font-size: 1rem;
}
.small-button {
font-size: 0.3rem;
padding: 2px 2px;
}
.nav-links a {
font-size: 0.3rem; /* Reduced font size */
padding: 15px 30px; /* Adjusted padding */
} }
} }
/* For medium-sized devices (e.g., 800px - 1000px width) */ /* landscape muy pequeño (≤ 600px ancho) */
@media screen and (min-width: 800px) and (max-width: 1000px) and (max-height: 450px) and (orientation: landscape) { @media screen and (max-width: 600px) and (orientation: landscape) and (max-height: 400px) {
.background { .title { font-size: 1.2rem; }
display: flex; .small-button { font-size: 0.6rem; padding: 3px 6px; }
justify-content: center; .nav-links a { font-size: 0.6rem; padding: 5px 8px; }
height: 100vh; .button-column { width: 28%; }
width: 100%; .overlay-button { font-size: 0.58rem; padding: 4px 6px; }
overflow-x: auto;
}
.button-overlay {
display: flex;
justify-content: space-around;
align-items: flex-end;
height: 100%;
padding-bottom: 0px;
}
.button-column {
width: 16%;
display: flex;
flex-direction: column;
justify-content: flex-end;
height: auto;
}
.overlay-button {
font-size: 0.35rem;
padding: 3px 3px;
}
.title {
font-size: 4rem;
}
.small-button {
font-size: 0.35rem;
padding: 3px 3px;
}
.nav-links a {
font-size: 0.3rem; /* Reduced font size */
padding: 15px 30px; /* Adjusted padding */
}
}
/* For larger devices (e.g., 1000px - 1200px width) */
@media screen and (min-width: 1000px) and (max-width: 1200px) and (max-height: 500px) and (orientation: landscape) {
.background {
display: flex;
justify-content: center;
height: 100vh;
width: 100%;
overflow-x: auto;
}
.button-overlay {
display: flex;
justify-content: space-around;
align-items: flex-end;
height: 100%;
padding-bottom: 0px;
}
.button-column {
width: 17%;
display: flex;
flex-direction: column;
justify-content: flex-end;
height: auto;
}
.overlay-button {
font-size: 0.4rem;
padding: 3px 4px;
}
.title {
font-size: 4rem;
}
.small-button {
font-size: 0.35rem;
padding: 3px 4px;
}
.nav-links a {
font-size: 0.3rem; /* Reduced font size */
padding: 15px 30px; /* Adjusted padding */
}
}
/* For even larger devices (e.g., 1200px - 1500px width) */
@media screen and (min-width: 1200px) and (max-width: 1500px) and (max-height: 700px) and (orientation: landscape) {
.background {
display: flex;
justify-content: center;
height: 100vh;
width: 100%;
overflow-x: auto;
}
.button-overlay {
display: flex;
justify-content: space-around;
align-items: flex-end;
height: 100%;
padding-bottom: 0px;
}
.button-column {
width: 18%;
display: flex;
flex-direction: column;
justify-content: flex-end;
height: auto;
}
.overlay-button {
font-size: 0.45rem;
padding: 4px 5px;
}
.title {
font-size: 4rem;
}
.small-button {
font-size: 0.35rem;
padding: 4px 5px;
}
.nav-links a {
font-size: 0.3rem; /* Reduced font size */
padding: 15px 30px; /* Adjusted padding */
}
}
@media screen and (max-width: 800px) and (orientation: landscape) {
.background a {
width: 100%;
}
.button-column {
width: 20%;
gap: 5px;
margin-top: 40px; /* Push buttons down to avoid navbar */
}
.overlay-button {
font-size: 0.35rem;
padding: 2px 4px;
}
.title {
font-size: 1.4rem;
}
.small-button {
font-size: 0.35rem;
padding: 2px 4px;
}
.nav-links a {
font-size: 0.35rem;
padding: 2px 4px;
}
}
/* For devices with width around 700px */
@media screen and (max-width: 700px) and (orientation: landscape) {
.background a {
width: 100%;
}
.button-column {
width: 25%;
gap: 5px;
margin-top: 50px; /* Further adjustment */
}
.overlay-button {
font-size: 0.3rem;
padding: 2px 3px;
}
.title {
font-size: 1.3rem;
}
.small-button {
font-size: 0.3rem;
padding: 2px 3px;
}
.nav-links a {
font-size: 0.3rem;
padding: 2px 3px;
}
}
/* For smaller devices with width around 600px */
@media screen and (max-width: 600px) and (orientation: landscape) {
.background a {
width: 100%;
}
.button-column {
width: 30%;
gap: 5px;
margin-top: 60px; /* Ensures the buttons are well below the navbar */
}
.overlay-button {
font-size: 0.4rem;
padding: 3px 5px;
}
.title {
font-size: 1.2rem;
}
.small-button {
font-size: 0.5rem;
padding: 3px 5px;
}
.nav-links a {
font-size: 0.5rem;
padding: 3px 5px;
z-index: 2;
}
}
/* Landscape mode for screens up to 480px wide */
@media screen and (max-width: 480px) and (orientation: landscape) {
.button-overlay {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
display: flex;
justify-content: space-around;
align-items: flex-end;
padding-bottom: 5px;
z-index: 1;
}
.button-column {
width: 28%;
gap: 2px;
margin-top:150px;
}
.overlay-button {
font-size: 0.45rem;
padding: 3px 4px;
}
.title {
font-size: 1rem;
}
.small-button {
font-size: 0.45rem;
padding: 3px 4px;
}
.nav-links a {
font-size: 0.45rem;
padding: 3px 4px;
z-index: 2;
}
} }

View file

@ -0,0 +1,278 @@
/* ============================================================
SUB-NAV Barra de navegación compacta, estética = home
============================================================ */
/* ── Contenedor fijo ─────────────────────────────────────── */
.top-nav {
position: fixed;
top: 0;
left: 0;
width: 100%;
transform: translateZ(0); /* capa GPU propia — sobre el canvas WebGL */
z-index: 9999;
background: rgba(0, 0, 0, 0.92);
padding: 8px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
box-sizing: border-box;
}
nav.top-nav {
display: block !important;
margin-top: 0 !important;
margin-bottom: 0 !important;
justify-content: unset;
box-shadow: none !important;
}
/* ── Fila centrada ───────────────────────────────────────── */
.section-buttons {
display: flex;
justify-content: center;
align-items: center;
gap: 18px;
width: 100%;
padding: 0 12px;
box-sizing: border-box;
/* Sin overflow en desktop → dropdown no queda recortado en Y */
}
/* ── Wrapper ─────────────────────────────────────────────── */
.section-item {
position: relative;
flex-shrink: 0;
}
/* ── Botón — texto NEGRO sobre fondo de sección ──────────── */
.section-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
/* Desktop grande — mismo "peso visual" que el home */
padding: 14px 34px;
font-size: 1.05em;
font-family: 'Fira Code', monospace;
font-weight: bold;
text-decoration: none;
color: #000000; /* letra negra — legible sobre cualquier fondo */
border: 3px solid black; /* reborde negro siempre */
position: relative;
z-index: 1;
transition: color 0.35s ease-in, transform 0.3s ease, box-shadow 0.3s ease;
white-space: nowrap;
cursor: pointer;
/* mismo text-shadow del home para profundidad */
text-shadow: 1px 1px 3px rgba(0,0,0,0.4);
}
.section-btn:hover {
transform: scale(1.06);
border: 3px solid black;
box-shadow: 0 0 8px rgba(0, 0, 0, 0.6);
}
.dropdown-arrow {
font-size: 0.75em;
line-height: 1;
transition: transform 0.2s;
}
/* ── Fondos por sección — exactos al home ────────────────── */
/* Solo background y border-color; el texto queda en #000000 */
.glob-war-btn { background-color: red; }
.int-sec-btn { background-color: darkblue; color: #aad4ff; } /* azul oscuro → letra clara */
.climate-btn { background-color: lightgreen; }
.eco-corp-btn { background-color: yellow; }
.popl-up-btn { background-color: orange; }
/* ── Hover — colores neón iguales al home ────────────────── */
.glob-war-btn:hover { color: #39ff14; }
.int-sec-btn:hover { color: #ff69b4; }
.climate-btn:hover { color: #ff4500; }
.eco-corp-btn:hover { color: #00fff2; }
.popl-up-btn:hover { color: #0066ff; }
/* ── Botón activo: borde brillante ──────────────────────── */
.section-item.current .section-btn {
box-shadow: 0 0 12px 3px rgba(0,0,0,0.5), 0 0 0 2px rgba(255,255,255,0.4);
}
.section-item.current:hover .dropdown-arrow,
.section-item.current.open .dropdown-arrow {
transform: rotate(180deg);
}
/* ── Dropdown ────────────────────────────────────────────── */
.section-dropdown {
display: none;
position: absolute;
top: calc(100% + 8px);
left: 50%;
transform: translateX(-50%) translateZ(0);
min-width: 200px;
background: rgba(4, 4, 4, 0.98);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 6px;
padding: 6px 0;
z-index: 99999;
box-shadow: 0 10px 32px rgba(0, 0, 0, 0.85);
}
/* Puente para no perder hover al moverse al dropdown */
.section-item.current::after {
content: '';
position: absolute;
top: 100%;
left: -10px;
right: -10px;
height: 14px;
z-index: 99998;
}
.section-item.current:hover .section-dropdown,
.section-item.current.open .section-dropdown {
display: block;
animation: dropFade 0.14s ease;
}
@keyframes dropFade {
from { opacity: 0; transform: translateX(-50%) translateY(-5px); }
to { opacity: 1; transform: translateX(-50%) translateY(0); }
}
.section-dropdown a {
display: block;
padding: 8px 18px;
font-size: 0.75em;
font-family: 'Fira Code', monospace;
color: #ccc;
text-decoration: none;
text-shadow: none;
transition: background 0.15s, color 0.15s;
white-space: nowrap;
}
.section-dropdown a:hover {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
/* ================================================================
MÓVIL Panel de detalle en la MITAD INFERIOR ( 768px)
Sustituye el "display:none" del graphPanel por layout vertical
================================================================ */
@media (max-width: 768px) {
/* Columna vertical: grafo arriba, detalle abajo */
main.split-screen.show-detail {
flex-direction: column !important;
height: 100vh !important;
}
/* Grafo ocupa la mitad superior — NO se oculta */
main.split-screen.show-detail #graphPanel {
display: block !important;
flex: none !important;
width: 100% !important;
height: 50vh !important;
min-height: 0 !important;
overflow: hidden;
}
/* Panel de detalle ocupa la mitad inferior */
main.split-screen.show-detail #detailPanel {
width: 100% !important;
max-width: 100% !important;
flex: none !important;
height: 50vh !important;
overflow-y: auto;
border-top: 2px solid rgba(57, 255, 20, 0.4);
}
/* El contenido del panel se muestra siempre cuando está abierto */
main.split-screen.show-detail #detailContent {
display: flex !important;
}
}
/* ── Detail image — larger display ──────────────────────── */
.detail-img {
width: 100%;
max-height: 28vh;
object-fit: contain;
display: block;
margin-bottom: 10px;
}
/* ── Ver conexiones button ───────────────────────────────── */
#verConexionesBtn {
display: block;
width: 100%;
margin-top: 14px;
padding: 10px 0;
background: rgba(255,255,255,0.08);
border: 1px solid rgba(255,255,255,0.25);
border-radius: 5px;
color: #aaffaa;
font-family: 'Fira Code', monospace;
font-size: 0.82em;
cursor: pointer;
transition: background 0.2s, color 0.2s;
}
#verConexionesBtn:hover {
background: rgba(255,255,255,0.18);
color: #fff;
}
/* ── Volver al grafo completo — floating overlay ─────────── */
#volverGrafoBtn {
display: none;
position: absolute;
top: 10px;
left: 50%;
transform: translateX(-50%);
z-index: 9990;
padding: 7px 20px;
background: rgba(0,0,0,0.82);
border: 1px solid rgba(255,255,255,0.3);
border-radius: 5px;
color: #fff;
font-family: 'Fira Code', monospace;
font-size: 0.82em;
cursor: pointer;
white-space: nowrap;
}
#volverGrafoBtn.visible { display: block; }
/* ── Móvil — nav más pequeño ≤ 800px ────────────────────── */
@media (max-width: 800px) {
.section-buttons {
justify-content: flex-start;
overflow-x: auto;
overflow-y: visible;
scrollbar-width: none;
-ms-overflow-style: none;
gap: 8px;
}
.section-buttons::-webkit-scrollbar { display: none; }
.section-btn {
padding: 7px 14px;
font-size: 0.72em;
border-width: 2px;
}
/* En móvil con overflow-x el dropdown escapa al contenedor → fixed */
.section-dropdown {
position: fixed !important;
left: 50% !important;
top: 52px !important;
transform: translateX(-50%) translateZ(0) !important;
}
}
@media (max-width: 480px) {
.section-btn {
padding: 6px 10px;
font-size: 0.65em;
}
.section-dropdown { min-width: 160px; }
.section-dropdown a { font-size: 0.7em; padding: 7px 14px; }
}

View file

@ -24,7 +24,7 @@ from pathlib import Path
import torch import torch
from PIL import Image from PIL import Image
from transformers import Qwen3VLForConditionalGeneration, AutoProcessor from transformers import Qwen3VLForConditionalGeneration, AutoProcessor, BitsAndBytesConfig
# ── Configuración ────────────────────────────────────────────────────────────── # ── Configuración ──────────────────────────────────────────────────────────────
@ -33,10 +33,9 @@ CACHE_DIR = os.getenv("HF_HOME", "/var/www/theflows.net/flujos/FLUJOS_DATOS/IMAG
SUPPORTED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"} SUPPORTED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"}
# RAM por imagen en batch (aprox): ~500MB activaciones encoder # int4 via bitsandbytes: modelo ocupa ~4-5GB VRAM en lugar de ~16GB bfloat16
# Modelo base bfloat16: ~16GB # RTX 3060 12GB → sobra VRAM para activaciones
# Batch de 4: ~18GB total → seguro con 64GB DEFAULT_BATCH_SIZE = 1 # batch 1 para seguridad con 12GB
DEFAULT_BATCH_SIZE = 4
KEYWORD_PROMPT = """Analiza esta imagen en detalle. KEYWORD_PROMPT = """Analiza esta imagen en detalle.
Devuelve ÚNICAMENTE un objeto JSON válido con esta estructura exacta, sin texto adicional: Devuelve ÚNICAMENTE un objeto JSON válido con esta estructura exacta, sin texto adicional:
@ -73,17 +72,35 @@ class ImageAnalyzer:
print(f"[ImageAnalyzer] Cargando modelo {self.model_id}...") print(f"[ImageAnalyzer] Cargando modelo {self.model_id}...")
print(f"[ImageAnalyzer] Cache: {CACHE_DIR}") print(f"[ImageAnalyzer] Cache: {CACHE_DIR}")
self._model = Qwen3VLForConditionalGeneration.from_pretrained( device = "cuda" if torch.cuda.is_available() else "cpu"
self.model_id, print(f"[ImageAnalyzer] Dispositivo: {device}")
torch_dtype=torch.bfloat16,
device_map="cpu", if device == "cuda":
cache_dir=CACHE_DIR, bnb_config = BitsAndBytesConfig(
) load_in_4bit=True,
bnb_4bit_compute_dtype=torch.bfloat16,
bnb_4bit_use_double_quant=True,
bnb_4bit_quant_type="nf4",
)
self._model = Qwen3VLForConditionalGeneration.from_pretrained(
self.model_id,
quantization_config=bnb_config,
device_map="auto",
cache_dir=CACHE_DIR,
)
else:
self._model = Qwen3VLForConditionalGeneration.from_pretrained(
self.model_id,
torch_dtype=torch.bfloat16,
device_map="cpu",
cache_dir=CACHE_DIR,
)
self._processor = AutoProcessor.from_pretrained( self._processor = AutoProcessor.from_pretrained(
self.model_id, self.model_id,
cache_dir=CACHE_DIR, cache_dir=CACHE_DIR,
) )
print("[ImageAnalyzer] Modelo cargado.") print("[ImageAnalyzer] Modelo cargado (int4 cuantizado).")
# ── Opción 3: Resume — obtener archivos ya analizados en MongoDB ─────────── # ── Opción 3: Resume — obtener archivos ya analizados en MongoDB ───────────