tawasuyu

tawasuyu/brahman

Rust 572TOML 217Markdown 68JSON 198326 Sutichaykuna

⎇ d4c31d70b77d750a02162f12c014a69b12fca4fa crates/apps/gioser-web/cytoscape-graph.js JavaScript · 258 siq'ikuna · 8.4 KB Ñawpa willay Pi imata llamkarqan ch'uya

gioser-web: replace Rust SVG graph with Cytoscape.js d4c31d70b · Sergio ·

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})();