tawasuyu

tawasuyu/brahman

Rust 582TOML 222Markdown 73JSON 198635 Definitions

⎇ 38e95e062031a5d389c77ad850688ce0f2d4c524 crates/modules/gioser/gioser-graph-web/src/lib.rs Rust · 496 lines · 16.7 KB History Who touched what raw

gioser-web: add gioser-graph-web module for SVG semantic graph 38e95e062 · Sergio ·

1//! `gioser-graph-web` — widget de grafo semántico SVG inline.
2//!
3//! Fetchea `GET /graph` de la API de gioser, parsea nodos + aristas,
4//! y renderiza un grafo SVG interactivo dentro de un contenedor dado.
5//! Los nodos son clicleables: al hacer clic en un nodo se navega a la
6//! página correspondiente (o se pasa un callback).
7//!
8//! ## Layout
9//!
10//! Usa un layout force-directed simple (Fruchterman-Reingold básico)
11//! implementado en Rust/WASM. No requiere canvas WebGL ni librerías
12//! externas. El SVG se renderiza inline y escala responsivamente.
13//!
14//! ## Contrato DOM
15//!
16//! El caller pasa un `<div>` contenedor y un callback `on_navigate(doc_id)`.
17//! El widget monta un `<svg>` dentro con viewBox fijo.
18//!
19//! ## Ejemplo
20//!
21//! ```ignore
22//! let container = document.get_element_by_id("graph-container")
23//!     .unwrap().dyn_into::<HtmlElement>().unwrap();
24//! let graph = GraphWidget::new(container, api_url);
25//! graph.load().await;
26//! ```
27
28use std::cell::RefCell;
29use std::rc::Rc;
30
31use js_sys::Promise;
32use serde::Deserialize;
33use wasm_bindgen::prelude::*;
34use wasm_bindgen::JsCast;
35use wasm_bindgen_futures::JsFuture;
36use web_sys::{
37    Document, Element, HtmlElement, MouseEvent, SvgCircleElement, SvgElement, SvgLineElement,
38    SvgSvgElement, SvgTextElement, Window,
39};
40
41// ─── Tipos de respuesta de `/graph` ──────────────────────────────
42
43#[derive(Deserialize, Debug, Clone)]
44struct GraphResponse {
45    nodes: Vec<GraphNode>,
46    edges: Vec<GraphEdge>,
47    stats: GraphStats,
48}
49
50#[derive(Deserialize, Debug, Clone)]
51struct GraphNode {
52    data: NodeData,
53}
54
55#[derive(Deserialize, Debug, Clone)]
56struct NodeData {
57    id: String,
58    name: String,
59    camino: String,
60    doc_id: Option<String>,
61    chunk: Option<u32>,
62    tags: Option<Vec<String>>,
63    preview: Option<String>,
64}
65
66#[derive(Deserialize, Debug, Clone)]
67struct GraphEdge {
68    data: EdgeData,
69}
70
71#[derive(Deserialize, Debug, Clone)]
72struct EdgeData {
73    id: String,
74    source: String,
75    target: String,
76    weight: Option<f64>,
77}
78
79#[derive(Deserialize, Debug, Clone)]
80struct GraphStats {
81    points: u32,
82    edges: u32,
83    #[allow(dead_code)]
84    by_camino: Option<std::collections::HashMap<String, u32>>,
85}
86
87// ─── Widget ──────────────────────────────────────────────────────
88
89type NavCallback = Rc<RefCell<Option<Box<dyn FnMut(String)>>>>;
90
91const CANVAS_W: f64 = 600.0;
92const CANVAS_H: f64 = 260.0;
93const NODE_RADIUS: f64 = 20.0;
94
95// Paleta por camino (misma convención que gioser-web CSS)
96const CAMINO_COLORS: &[(&str, &str)] = &[
97    ("logos", "#d0dbff"),  // aire
98    ("aire", "#d0dbff"),   // aire (alias)
99    ("nomos", "#f59056"),  // fuego
100    ("fuego", "#f59056"),  // fuego (alias)
101    ("kay", "#d49873"),    // tierra
102    ("tierra", "#d49873"), // tierra (alias)
103    ("uku", "#6cd0f3"),    // agua
104    ("agua", "#6cd0f3"),   // agua (alias)
105];
106
107fn camino_color(camino: &str) -> &str {
108    for (k, v) in CAMINO_COLORS {
109        if *k == camino {
110            return v;
111        }
112    }
113    "#888888"
114}
115
116pub struct GraphWidget {
117    container: HtmlElement,
118    api_url: String,
119    svg: Option<SvgSvgElement>,
120    nodes: Vec<NodeData>,
121    edges: Vec<EdgeData>,
122    on_navigate: NavCallback,
123    document: Document,
124}
125
126impl GraphWidget {
127    /// Crea un nuevo GraphWidget. `container` es el div donde se monta el SVG.
128    /// `api_url` es la URL base de la API de grafo (sin trailing slash).
129    /// `on_navigate` se llama cuando el usuario hace clic en un nodo,
130    /// pasando el `doc_id` del nodo.
131    pub fn new(
132        container: HtmlElement,
133        api_url: &str,
134        on_navigate: Option<Box<dyn FnMut(String)>>,
135    ) -> Self {
136        let doc = crate::document().unwrap_or_else(|| {
137            web_sys::window()
138                .and_then(|w| w.document())
139                .expect("no document")
140        });
141
142        Self {
143            container,
144            api_url: api_url.to_string(),
145            svg: None,
146            nodes: Vec::new(),
147            edges: Vec::new(),
148            on_navigate: Rc::new(RefCell::new(on_navigate)),
149            document: doc,
150        }
151    }
152
153    /// Fetchea `/graph` de la API, aplica layout force-directed y renderiza.
154    pub async fn load(&mut self) -> Result<(), JsValue> {
155        let url = format!("{}/graph?limit=500", self.api_url);
156        let window = web_sys::window().ok_or_else(|| JsValue::from_str("no window"))?;
157
158        let resp_value = JsFuture::from(window.fetch_with_str(&url)).await?;
159        let resp: web_sys::Response = resp_value.dyn_into()?;
160        if !resp.ok() {
161            return Err(JsValue::from_str(&format!("HTTP {}", resp.status())));
162        }
163        let text = JsFuture::from(resp.text()?).await?;
164        let body = text.as_string().unwrap_or_default();
165
166        let graph: GraphResponse =
167            serde_json::from_str(&body).map_err(|e| JsValue::from_str(&format!("JSON: {e}")))?;
168
169        // Solo nodos de nuestro corpus (que tengan doc_id)
170        let nodes: Vec<NodeData> = graph
171            .nodes
172            .into_iter()
173            .map(|n| n.data)
174            .filter(|n| n.doc_id.is_some())
175            .collect();
176        let edges: Vec<EdgeData> = graph.edges.into_iter().map(|e| e.data).collect();
177
178        self.nodes = nodes;
179        self.edges = edges;
180
181        self.render();
182        Ok(())
183    }
184
185    /// Renderiza el SVG con layout force-directed simple.
186    fn render(&self) {
187        // Limpiar contenedor
188        self.container.set_inner_html("");
189
190        if self.nodes.is_empty() {
191            return;
192        }
193
194        // Force-directed layout: Fruchterman-Reingold simple
195        let positions = force_layout(&self.nodes, &self.edges, CANVAS_W, CANVAS_H);
196
197        let ns = "http://www.w3.org/2000/svg";
198        let svg: SvgSvgElement = self
199            .document
200            .create_element_ns(Some(ns), "svg")
201            .unwrap()
202            .dyn_into()
203            .unwrap();
204        svg.set_attribute("viewBox", &format!("0 0 {} {}", CANVAS_W, CANVAS_H)).ok();
205        svg.set_attribute("width", "100%").ok();
206        svg.set_attribute("height", &format!("{}px", CANVAS_H as u32)).ok();
207        svg.style()
208            .set_property("display", "block")
209            .ok();
210        svg.style()
211            .set_property("margin", "1.5rem auto 0")
212            .ok();
213        svg.style()
214            .set_property("max-width", "100%")
215            .ok();
216
217        // Fondo sutil del SVG
218        svg.style()
219            .set_property("background", "rgba(255,255,255,0.02)")
220            .ok();
221        svg.style()
222            .set_property("border-radius", "12px")
223            .ok();
224        svg.style()
225            .set_property("border", "1px solid rgba(216,168,93,0.15)")
226            .ok();
227
228        // Aristas
229        for edge in &self.edges {
230            let src_pos = positions.iter().find(|(id, _)| *id == edge.source);
231            let tgt_pos = positions.iter().find(|(id, _)| *id == edge.target);
232            if let (Some((_, (x1, y1))), Some((_, (x2, y2)))) = (src_pos, tgt_pos) {
233                let line: SvgLineElement = self
234                    .document
235                    .create_element_ns(Some(ns), "line")
236                    .unwrap()
237                    .dyn_into()
238                    .unwrap();
239                line.set_attribute("x1", &format!("{:.1}", x1)).ok();
240                line.set_attribute("y1", &format!("{:.1}", y1)).ok();
241                line.set_attribute("x2", &format!("{:.1}", x2)).ok();
242                line.set_attribute("y2", &format!("{:.1}", y2)).ok();
243                line.set_attribute("stroke", "rgba(255,255,255,0.12)").ok();
244                line.set_attribute("stroke-width", "1.0").ok();
245                // Si hay weight, opacidad proporcional
246                if let Some(w) = edge.weight {
247                    let alpha = ((w - 0.5) * 2.0).clamp(0.1, 0.8);
248                    line.set_attribute("stroke-opacity", &format!("{:.2}", alpha)).ok();
249                }
250                svg.append_child(&line).ok();
251            }
252        }
253
254        // Nodos
255        let on_nav = self.on_navigate.clone();
256        for (i, node) in self.nodes.iter().enumerate() {
257            let (x, y) = positions.get(i).map(|(_, p)| *p).unwrap_or((100.0, 100.0));
258            let color = camino_color(&node.camino);
259
260            // Círculo
261            let circle: SvgCircleElement = self
262                .document
263                .create_element_ns(Some(ns), "circle")
264                .unwrap()
265                .dyn_into()
266                .unwrap();
267            circle.set_attribute("cx", &format!("{:.1}", x)).ok();
268            circle.set_attribute("cy", &format!("{:.1}", y)).ok();
269            circle.set_attribute("r", &format!("{:.1}", NODE_RADIUS)).ok();
270            circle.set_attribute("fill", color).ok();
271            circle.set_attribute("fill-opacity", "0.35").ok();
272            circle.set_attribute("stroke", color).ok();
273            circle.set_attribute("stroke-width", "2").ok();
274            circle.set_attribute("cursor", "pointer").ok();
275
276            // Glow
277            circle.style()
278                .set_property("filter", "drop-shadow(0 0 6px rgba(255,255,255,0.1))")
279                .ok();
280            circle.style()
281                .set_property("transition", "all 250ms ease")
282                .ok();
283
284            // Hover
285            let doc_id = node.doc_id.clone().unwrap_or_default();
286            let preview = node.preview.clone().unwrap_or_default();
287            let name = node.name.clone();
288            let circle_clone = circle.clone();
289            let on_nav_clone = on_nav.clone();
290
291            let mouseenter = Closure::<dyn FnMut(MouseEvent)>::new(move |_| {
292                circle_clone
293                    .set_attribute("fill-opacity", "0.6")
294                    .ok();
295                circle_clone.style()
296                    .set_property("filter", &format!("drop-shadow(0 0 12px {})", color))
297                    .ok();
298            });
299            circle
300                .add_event_listener_with_callback("mouseenter", mouseenter.as_ref().unchecked_ref())
301                .ok();
302            mouseenter.forget();
303
304            let circle_clone2 = circle.clone();
305            let mouseleave = Closure::<dyn FnMut(MouseEvent)>::new(move |_| {
306                circle_clone2
307                    .set_attribute("fill-opacity", "0.35")
308                    .ok();
309                circle_clone2.style()
310                    .set_property("filter", "drop-shadow(0 0 6px rgba(255,255,255,0.1))")
311                    .ok();
312            });
313            circle
314                .add_event_listener_with_callback("mouseleave", mouseleave.as_ref().unchecked_ref())
315                .ok();
316            mouseleave.forget();
317
318            let circle_clone3 = circle.clone();
319            let on_nav_clone2 = on_nav.clone();
320            let doc_id_clone = doc_id.clone();
321            let click = Closure::<dyn FnMut(MouseEvent)>::new(move |_| {
322                let mut cb = on_nav_clone2.borrow_mut();
323                if let Some(ref mut f) = *cb {
324                    f(doc_id_clone.clone());
325                }
326            });
327            circle
328                .add_event_listener_with_callback("click", click.as_ref().unchecked_ref())
329                .ok();
330            click.forget();
331
332            svg.append_child(&circle).ok();
333
334            // Título del nodo (abreviado si muy largo)
335            let label = if name.len() > 20 {
336                format!("{}…", &name[..18])
337            } else {
338                name.clone()
339            };
340
341            let text: SvgTextElement = self
342                .document
343                .create_element_ns(Some(ns), "text")
344                .unwrap()
345                .dyn_into()
346                .unwrap();
347            text.set_attribute("x", &format!("{:.1}", x)).ok();
348            text.set_attribute("y", &format!("{:.1}", y + 36.0)).ok();
349            text.set_attribute("text-anchor", "middle").ok();
350            text.set_attribute("fill", "rgba(232,234,245,0.6)").ok();
351            text.set_attribute("font-size", "9").ok();
352            text.set_attribute("font-family", "Inter, sans-serif").ok();
353            text.set_text_content(Some(&label));
354            svg.append_child(&text).ok();
355
356            // Tooltip sutil (title attribute)
357            // El título del elemento svg funciona como tooltip nativo
358            let title_el = self
359                .document
360                .create_element("title")
361                .ok();
362            if let Some(title_el) = title_el {
363                title_el.set_text_content(Some(&format!(
364                    "{} — {}",
365                    name,
366                    node.camino.to_uppercase()
367                )));
368                svg.append_child(&title_el).ok(); // se lo ponemos al svg, no por nodo
369                // Mejor: ponemos title a cada círculo
370                circle.set_attribute("title", &format!("{} — {}", name, node.camino.to_uppercase())).ok();
371            }
372        }
373
374        self.container.append_child(&svg).ok();
375    }
376}
377
378// ─── Force-directed layout (Fruchterman-Reingold) ────────────────
379//
380// Implementación inline para no depender de petgraph. Layout 2D
381// con repulsión de Coulomb, atracción de resorte en aristas.
382
383fn force_layout(
384    nodes: &[NodeData],
385    edges: &[EdgeData],
386    w: f64,
387    h: f64,
388) -> Vec<(String, (f64, f64))> {
389    let n = nodes.len();
390    if n == 0 {
391        return vec![];
392    }
393
394    let area = w * h;
395    let k = (area / (n as f64)).sqrt();
396
397    // Inicializar posiciones en círculo
398    let cx = w / 2.0;
399    let cy = h / 2.0;
400    let radius = (w.min(h) * 0.35).max(50.0);
401    let mut positions: Vec<(f64, f64)> = nodes
402        .iter()
403        .enumerate()
404        .map(|(i, _)| {
405            let angle = 2.0 * std::f64::consts::PI * (i as f64) / (n as f64);
406            (cx + radius * angle.cos(), cy + radius * angle.sin())
407        })
408        .collect();
409
410    // Índice de nodo por id para lookup rápido de aristas
411    let id_to_idx: std::collections::HashMap<&str, usize> = nodes
412        .iter()
413        .enumerate()
414        .map(|(i, n)| (n.doc_id.as_deref().unwrap_or(""), i))
415        .filter(|(id, _)| !id.is_empty())
416        .collect();
417
418    // Construir adjacency: edge_ids
419    let mut adj: Vec<Vec<usize>> = vec![vec![]; n];
420    for e in edges {
421        if let (Some(&si), Some(&ti)) = (id_to_idx.get(e.source.as_str()), id_to_idx.get(e.target.as_str())) {
422            if !adj[si].contains(&ti) {
423                adj[si].push(ti);
424            }
425            if !adj[ti].contains(&si) {
426                adj[ti].push(si);
427            }
428        }
429    }
430
431    // Iteraciones
432    let iterations = 60;
433    let temp_init = w.max(h) / 8.0;
434
435    let mut disp: Vec<(f64, f64)> = vec![(0.0, 0.0); n];
436
437    for iter in 0..iterations {
438        let temp = temp_init * (1.0 - (iter as f64) / (iterations as f64));
439
440        // Reset displacements
441        for d in disp.iter_mut() {
442            *d = (0.0, 0.0);
443        }
444
445        // Repulsión: Coulomb entre todo par
446        for i in 0..n {
447            for j in (i + 1)..n {
448                let dx = positions[i].0 - positions[j].0;
449                let dy = positions[i].1 - positions[j].1;
450                let dist = (dx * dx + dy * dy).sqrt().max(1.0);
451                let force = k * k / dist;
452                let fx = force * dx / dist;
453                let fy = force * dy / dist;
454                disp[i].0 += fx;
455                disp[i].1 += fy;
456                disp[j].0 -= fx;
457                disp[j].1 -= fy;
458            }
459        }
460
461        // Atracción: Hooke en aristas
462        for i in 0..n {
463            for &j in &adj[i] {
464                let dx = positions[j].0 - positions[i].0;
465                let dy = positions[j].1 - positions[i].1;
466                let dist = (dx * dx + dy * dy).sqrt().max(1.0);
467                let force = dist * dist / k;
468                let fx = force * dx / dist;
469                let fy = force * dy / dist;
470                disp[i].0 += fx;
471                disp[i].1 += fy;
472                disp[j].0 -= fx;
473                disp[j].1 -= fy;
474            }
475        }
476
477        // Aplicar desplazamientos con temperatura
478        let margin = NODE_RADIUS + 8.0;
479        for i in 0..n {
480            let d = (disp[i].0 * disp[i].0 + disp[i].1 * disp[i].1)
481                .sqrt()
482                .max(0.001);
483            let step = disp[i].0.min(temp).max(-temp);
484            let step_y = disp[i].1.min(temp).max(-temp);
485            let new_x = (positions[i].0 + (step / d) * temp).clamp(margin, w - margin);
486            let new_y = (positions[i].1 + (step_y / d) * temp).clamp(margin, h - margin);
487            positions[i] = (new_x, new_y);
488        }
489    }
490
491    nodes
492        .iter()
493        .zip(positions.into_iter())
494        .map(|(n, pos)| (n.doc_id.clone().unwrap_or_default(), pos))
495        .collect()
496}