⎇ d4c31d70b77d750a02162f12c014a69b12fca4fa
crates/apps/gioser-web/cytoscape-graph.js
Historia
Quién tocó qué
crudo
gioser-web: replace Rust SVG graph with Cytoscape.js
1/**
2 * gioser-graph.js — Grafo semántico con Cytoscape.js
3 *
4 * Se monta automáticamente cuando hay un contenedor <gioser-graph>
5 * en el DOM con atributo data-api-url.
6 *
7 * Efecto "wineandcheesemap": clic en nodo → centra + desvanece resto.
8 * Doble clic → callback de navegación.
9 */
10
11(function () {
12 'use strict';
13
14 const COLORS = {
15 logos: '#d0dbff',
16 aire: '#d0dbff',
17 nomos: '#f59056',
18 fuego: '#f59056',
19 kay: '#d49873',
20 tierra: '#d49873',
21 uku: '#6cd0f3',
22 agua: '#6cd0f3',
23 };
24
25 function caminoColor(c) { return COLORS[c] || '#888'; }
26
27 function initGraph(container) {
28 const apiUrl = container.getAttribute('data-api-url') || 'https://api.gioser.net';
29 const onNavigate = window.__gioserGraphNavigate || null;
30
31 fetch(`${apiUrl}/graph?limit=500`)
32 .then(r => r.json())
33 .then(data => {
34 if (!data.nodes || data.nodes.length === 0) return;
35
36 // Construir elementos Cytoscape
37 const elements = [];
38
39 for (const n of data.nodes) {
40 const d = n.data;
41 if (!d.doc_id) continue;
42 const color = caminoColor(d.camino);
43 elements.push({
44 data: {
45 id: d.id,
46 doc_id: d.doc_id,
47 label: d.name.length > 22 ? d.name.slice(0, 20) + '…' : d.name,
48 camino: d.camino.toUpperCase(),
49 color,
50 },
51 });
52 }
53
54 const nodeIds = new Set(elements.map(e => e.data.id));
55
56 for (const e of data.edges) {
57 const ed = e.data;
58 if (!nodeIds.has(ed.source) || !nodeIds.has(ed.target)) continue;
59 const weight = ed.weight || 0.7;
60 elements.push({
61 data: {
62 id: ed.id,
63 source: ed.source,
64 target: ed.target,
65 weight,
66 },
67 });
68 }
69
70 const cy = cytoscape({
71 container,
72 elements,
73 style: [
74 // Aristas: grosor según peso
75 {
76 selector: 'edge',
77 style: {
78 'width': 'mapData(weight, 0.5, 1.0, 0.5, 4)',
79 'line-color': 'rgba(255,255,255,0.18)',
80 'target-arrow-color': 'rgba(255,255,255,0.12)',
81 'curve-style': 'haystack',
82 'haystack-radius': 0,
83 'opacity': 0.6,
84 },
85 },
86 // Nodo: rectángulo redondeado
87 {
88 selector: 'node',
89 style: {
90 'shape': 'round-rectangle',
91 'width': 130,
92 'height': 32,
93 'background-color': 'data(color)',
94 'background-opacity': 0.20,
95 'border-color': 'data(color)',
96 'border-width': 1.5,
97 'border-opacity': 0.7,
98 'color': 'rgba(232,234,245,0.90)',
99 'font-size': 11,
100 'font-family': 'Inter, system-ui, sans-serif',
101 'font-weight': 500,
102 'text-valign': 'center',
103 'text-halign': 'center',
104 'label': 'data(label)',
105 'min-zoomed-font-size': 8,
106 'shadow-blur': 6,
107 'shadow-color': 'rgba(0,0,0,0.4)',
108 'shadow-offset-x': 0,
109 'shadow-offset-y': 2,
110 'shadow-opacity': 0.5,
111 'transition-property': 'background-opacity, border-opacity, shadow-blur',
112 'transition-duration': 200,
113 },
114 },
115 // Sublabel del camino — lo ponemos como label secundario
116 // Cytoscape no soporta dos labels nativamente; usamos un
117 // badge de esquina con la data (camino) en el tooltip.
118 ],
119 layout: {
120 name: 'cose',
121 animate: false,
122 idealEdgeLength: 160,
123 nodeRepulsion: 8000,
124 gravity: 0.25,
125 numIter: 1000,
126 fit: true,
127 padding: 30,
128 },
129 });
130
131 // Tooltip con preview al hover
132 const tips = {};
133 for (const n of data.nodes) {
134 const d = n.data;
135 if (d.doc_id) tips[d.id] = d;
136 }
137
138 let tooltipEl = container.querySelector('.cy-tooltip');
139 if (!tooltipEl) {
140 tooltipEl = document.createElement('div');
141 tooltipEl.className = 'cy-tooltip';
142 tooltipEl.style.cssText =
143 'position:absolute;z-index:10;pointer-events:none;' +
144 'background:rgba(6,5,13,0.88);color:#e8eaf5;' +
145 'padding:6px 10px;border-radius:8px;font-size:11px;' +
146 'font-family:Inter,sans-serif;line-height:1.4;' +
147 'border:1px solid rgba(255,255,255,0.10);' +
148 'backdrop-filter:blur(8px);max-width:240px;' +
149 'opacity:0;transition:opacity 180ms ease;';
150 container.style.position = 'relative';
151 container.appendChild(tooltipEl);
152 }
153
154 cy.on('mouseover', 'node', (ev) => {
155 const node = ev.target;
156 node.style({ 'background-opacity': 0.45, 'border-opacity': 1, 'shadow-blur': 12 });
157 const tipData = tips[node.id()];
158 if (tipData && tipData.preview) {
159 tooltipEl.textContent = tipData.preview.slice(0, 120);
160 tooltipEl.style.opacity = '1';
161 }
162 });
163
164 cy.on('mouseout', 'node', (ev) => {
165 const node = ev.target;
166 node.style({ 'background-opacity': 0.20, 'border-opacity': 0.7, 'shadow-blur': 6 });
167 tooltipEl.style.opacity = '0';
168 });
169
170 cy.on('mousemove', 'node', (ev) => {
171 const pos = ev.renderedPosition || { x: 0, y: 0 };
172 tooltipEl.style.left = (pos.x + 14) + 'px';
173 tooltipEl.style.top = (pos.y - 10) + 'px';
174 });
175
176 // Click: centrar nodo + desvanecer resto (wineandcheesemap effect)
177 cy.on('click', 'node', (ev) => {
178 const node = ev.target;
179 // Animar vecindario: opacidad plena en nodo + vecinos
180 cy.nodes().not(node).not(node.neighborhood()).forEach(n => {
181 n.style({ 'opacity': 0.15 });
182 });
183 cy.edges().forEach(e => {
184 e.style({ 'opacity': 0.08 });
185 });
186 // Vecinos directos opacidad normal
187 node.neighborhood().nodes().forEach(n => {
188 n.style({ 'opacity': 1 });
189 });
190 node.style({ 'opacity': 1 });
191 // Aristas del vecindario visibles
192 node.connectedEdges().forEach(e => {
193 e.style({ 'opacity': 0.7 });
194 });
195 // Centrar
196 cy.animate({
197 center: { eles: node },
198 zoom: 2.2,
199 duration: 400,
200 });
201 });
202
203 // Doble clic: navegar a la página
204 cy.on('dblclick', 'node', (ev) => {
205 const docId = ev.target.data('doc_id');
206 if (onNavigate && docId) onNavigate(docId);
207 });
208
209 // Clic en fondo: restaurar todo
210 cy.on('click', (ev) => {
211 if (ev.target === cy) {
212 cy.nodes().forEach(n => n.style({ 'opacity': 1 }));
213 cy.edges().forEach(e => e.style({ 'opacity': 0.6 }));
214 cy.animate({ zoom: 1, pan: { x: 0, y: 0 }, duration: 300 });
215 }
216 });
217
218 // Resize al cambiar tamaño del contenedor
219 const ro = new ResizeObserver(() => cy.resize().fit(30));
220 ro.observe(container);
221
222 // Scroll del contenedor padre: pausar interacción si no visible
223 container.__cy = cy;
224 })
225 .catch(err => {
226 console.warn('gioser-graph: error fetching graph:', err);
227 container.innerHTML =
228 '<div style="padding:1rem;text-align:center;color:rgba(232,234,245,0.35);' +
229 'font-size:0.8rem;font-family:Inter,sans-serif;">' +
230 '· grafo no disponible ·</div>';
231 });
232 }
233
234 // Auto-inicializar todos los <gioser-graph> en la página
235 function boot() {
236 const els = document.querySelectorAll('gioser-graph');
237 for (const el of els) {
238 // Esperar a que Cytoscape esté cargado
239 if (typeof cytoscape !== 'undefined') {
240 initGraph(el);
241 } else {
242 // Si el CDN no ha cargado, esperar
243 const check = setInterval(() => {
244 if (typeof cytoscape !== 'undefined') {
245 clearInterval(check);
246 initGraph(el);
247 }
248 }, 100);
249 }
250 }
251 }
252
253 if (document.readyState === 'loading') {
254 document.addEventListener('DOMContentLoaded', boot);
255 } else {
256 boot();
257 }
258})();