⎇ a908d8420cc8f4e2b5c4386f1f784f0977de1747
crates/apps/gioser-web/cytoscape-graph.js
History
Who touched what
raw
gioser-web: fix graph not appearing — use MutationObserver
1/**
2 * gioser-graph.js — Grafo semántico con Cytoscape.js
3 *
4 * Detecta automáticamente elementos <gioser-graph> agregados al DOM
5 * (incluso los creados dinámicamente por el WASM) y monta el grafo.
6 *
7 * Efecto "wineandcheesemap": clic en nodo → centra + desvanece resto.
8 * Doble clic → callback de navegación (window.__gioserGraphNavigate).
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 // Si cytoscape no ha cargado, esperar
32 if (typeof cytoscape === 'undefined') {
33 const check = setInterval(() => {
34 if (typeof cytoscape !== 'undefined') {
35 clearInterval(check);
36 initGraph(container);
37 }
38 }, 100);
39 return;
40 }
41
42 fetch(apiUrl + '/graph?limit=500')
43 .then(function (r) { return r.json(); })
44 .then(function (data) {
45 if (!data.nodes || data.nodes.length === 0) return;
46
47 var elements = [];
48
49 for (var i = 0; i < data.nodes.length; i++) {
50 var d = data.nodes[i].data;
51 if (!d.doc_id) continue;
52 var color = caminoColor(d.camino);
53 elements.push({
54 group: 'nodes',
55 data: {
56 id: d.id,
57 doc_id: d.doc_id,
58 label: d.name.length > 24 ? d.name.slice(0, 22) + '…' : d.name,
59 camino: d.camino.toUpperCase(),
60 color: color,
61 },
62 });
63 }
64
65 var nodeIds = {};
66 for (var i = 0; i < elements.length; i++) {
67 nodeIds[elements[i].data.id] = true;
68 }
69
70 for (var i = 0; i < data.edges.length; i++) {
71 var ed = data.edges[i].data;
72 if (!nodeIds[ed.source] || !nodeIds[ed.target]) continue;
73 var weight = ed.weight || 0.7;
74 elements.push({
75 group: 'edges',
76 data: {
77 id: ed.id,
78 source: ed.source,
79 target: ed.target,
80 weight: weight,
81 },
82 });
83 }
84
85 // Guardar payload completo para tooltip
86 var tipMap = {};
87 for (var i = 0; i < data.nodes.length; i++) {
88 var d = data.nodes[i].data;
89 if (d.doc_id) tipMap[d.id] = d;
90 }
91
92 var cy = cytoscape({
93 container: container,
94 elements: elements,
95 style: [
96 {
97 selector: 'edge',
98 style: {
99 'width': 'mapData(weight, 0.5, 1.0, 0.5, 4.5)',
100 'line-color': 'rgba(255,255,255,0.16)',
101 'curve-style': 'haystack',
102 'haystack-radius': 0,
103 'opacity': 0.6,
104 },
105 },
106 {
107 selector: 'node',
108 style: {
109 'shape': 'round-rectangle',
110 'width': 130,
111 'height': 34,
112 'background-color': 'data(color)',
113 'background-opacity': 0.18,
114 'border-color': 'data(color)',
115 'border-width': 1.8,
116 'border-opacity': 0.55,
117 'color': 'rgba(232,234,245,0.88)',
118 'font-size': 11,
119 'font-family': 'Inter, system-ui, sans-serif',
120 'font-weight': 500,
121 'text-valign': 'center',
122 'text-halign': 'center',
123 'label': 'data(label)',
124 'min-zoomed-font-size': 8,
125 'transition-property': 'background-opacity, border-opacity, border-width',
126 'transition-duration': 180,
127 },
128 },
129 ],
130 layout: {
131 name: 'cose',
132 animate: false,
133 idealEdgeLength: 150,
134 nodeRepulsion: 7000,
135 gravity: 0.2,
136 numIter: 800,
137 fit: true,
138 padding: 25,
139 },
140 });
141
142 // Tooltip
143 var tooltipEl = document.createElement('div');
144 tooltipEl.className = 'cy-tooltip';
145 tooltipEl.style.cssText =
146 'position:absolute;z-index:10;pointer-events:none;' +
147 'background:rgba(6,5,13,0.90);color:#e8eaf5;' +
148 'padding:6px 10px;border-radius:8px;font-size:11px;' +
149 'font-family:Inter,sans-serif;line-height:1.4;' +
150 'border:1px solid rgba(255,255,255,0.10);' +
151 'backdrop-filter:blur(8px);max-width:220px;' +
152 'opacity:0;transition:opacity 180ms ease;';
153 container.style.position = 'relative';
154 container.appendChild(tooltipEl);
155
156 cy.on('mouseover', 'node', function (ev) {
157 var n = ev.target;
158 n.style({ 'background-opacity': 0.45, 'border-opacity': 0.9, 'border-width': 2.2 });
159 var tipData = tipMap[n.id()];
160 if (tipData && tipData.preview) {
161 tooltipEl.textContent = tipData.preview.slice(0, 130);
162 tooltipEl.style.opacity = '1';
163 }
164 });
165
166 cy.on('mouseout', 'node', function (ev) {
167 var n = ev.target;
168 n.style({ 'background-opacity': 0.18, 'border-opacity': 0.55, 'border-width': 1.8 });
169 tooltipEl.style.opacity = '0';
170 });
171
172 cy.on('mousemove', function (ev) {
173 if (tooltipEl.style.opacity === '1') {
174 var pos = ev.renderedPosition || { x: 0, y: 0 };
175 tooltipEl.style.left = (pos.x + 14) + 'px';
176 tooltipEl.style.top = (pos.y - 10) + 'px';
177 }
178 });
179
180 // Click nodo: centrar + desvanecer resto
181 cy.on('click', 'node', function (ev) {
182 var node = ev.target;
183 // Vecinos
184 cy.nodes().not(node).not(node.neighborhood()).forEach(function (n) {
185 n.style({ 'opacity': 0.12 });
186 });
187 cy.edges().forEach(function (e) {
188 e.style({ 'opacity': 0.06 });
189 });
190 node.neighborhood().nodes().forEach(function (n) {
191 n.style({ 'opacity': 1 });
192 });
193 node.style({ 'opacity': 1, 'background-opacity': 0.40, 'border-opacity': 1 });
194 node.connectedEdges().forEach(function (e) {
195 e.style({ 'opacity': 0.7 });
196 });
197 cy.animate({
198 center: { eles: node },
199 zoom: 2.5,
200 duration: 350,
201 });
202 });
203
204 // Doble clic: callback de navegación
205 cy.on('dblclick', 'node', function (ev) {
206 var docId = ev.target.data('doc_id');
207 if (onNavigate && docId) onNavigate(docId);
208 });
209
210 // Clic en fondo: restaurar todo
211 cy.on('click', function (ev) {
212 if (ev.target === cy) {
213 cy.nodes().forEach(function (n) {
214 n.style({ 'opacity': 1, 'background-opacity': 0.18, 'border-opacity': 0.55, 'border-width': 1.8 });
215 });
216 cy.edges().forEach(function (e) {
217 e.style({ 'opacity': 0.6 });
218 });
219 cy.animate({ zoom: 1, pan: { x: 0, y: 0 }, duration: 300 });
220 }
221 });
222
223 // ResizeObserver para redimensionar con el contenedor
224 var ro = new ResizeObserver(function () {
225 cy.resize().fit(25);
226 });
227 ro.observe(container);
228
229 // Scroll del deck: pausar interacciones del grafo
230 var deckEl = container.closest('.deck');
231 if (deckEl) {
232 deckEl.addEventListener('scroll', function () {
233 // No hacemos nada especial, el grafo se redimensiona solo
234 });
235 }
236 })
237 .catch(function (err) {
238 console.warn('gioser-graph: error:', err);
239 container.innerHTML =
240 '<div style="padding:1rem;text-align:center;color:rgba(232,234,245,0.30);' +
241 'font-size:0.8rem;font-family:Inter,sans-serif;">' +
242 '· grafo no disponible ·</div>';
243 });
244 }
245
246 // MutationObserver: detecta <gioser-graph> agregados en cualquier momento
247 var observer = new MutationObserver(function (mutations) {
248 for (var m = 0; m < mutations.length; m++) {
249 var added = mutations[m].addedNodes;
250 for (var i = 0; i < added.length; i++) {
251 var el = added[i];
252 if (el.tagName && el.tagName.toLowerCase() === 'gioser-graph') {
253 initGraph(el);
254 }
255 // También revisar hijos
256 var graphs = el.querySelectorAll ? el.querySelectorAll('gioser-graph') : [];
257 for (var j = 0; j < graphs.length; j++) {
258 initGraph(graphs[j]);
259 }
260 }
261 }
262 });
263
264 observer.observe(document.documentElement, {
265 childList: true,
266 subtree: true,
267 });
268
269 // También inicializar los que ya existen (si el DOM ya está listo)
270 var existing = document.querySelectorAll('gioser-graph');
271 for (var i = 0; i < existing.length; i++) {
272 initGraph(existing[i]);
273 }
274})();