tawasuyu

tawasuyu/brahman

Rust 575TOML 218Markdown 68JSON 198402 Definiciones

⎇ 7f7ba1fef93dc16c1596b26866abeceb3dc482f0 crates/modules/gioser/gioser-graph-web/src/lib.rs Rust · 480 líneas · 16.5 KB Historia Quién tocó qué crudo

gioser-web: fix graph widget — rect nodes, edge weight, CSS anim, layout 529287f01 · 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//!
6//! Los nodos son **rectángulos redondeados** horizontales con el texto
7//! dentro (no círculos) para mejor legibilidad. Las aristas varían en
8//! grosor según la intensidad semántica (k-NN weight).
9//!
10//! ## Contrato DOM
11//!
12//! El caller pasa un `<div>` contenedor y un callback `on_navigate(doc_id)`.
13//! El widget monta un `<svg>` dentro con viewBox fijo.
14
15use std::cell::RefCell;
16use std::rc::Rc;
17
18use serde::Deserialize;
19use wasm_bindgen::prelude::*;
20use wasm_bindgen::JsCast;
21use wasm_bindgen_futures::JsFuture;
22use web_sys::{
23    Document, HtmlElement, MouseEvent, Response, SvgLineElement, SvgRectElement,
24    SvgsvgElement, SvgTextElement,
25};
26
27pub(crate) fn document() -> Option<Document> {
28    web_sys::window().and_then(|w| w.document())
29}
30
31// ─── Tipos de respuesta de `/graph` ──────────────────────────────
32
33#[derive(Deserialize, Debug, Clone)]
34struct GraphResponse {
35    nodes: Vec<GraphNode>,
36    edges: Vec<GraphEdge>,
37    stats: GraphStats,
38}
39
40#[derive(Deserialize, Debug, Clone)]
41struct GraphNode {
42    data: NodeData,
43}
44
45#[derive(Deserialize, Debug, Clone)]
46struct NodeData {
47    id: String,
48    name: String,
49    camino: String,
50    doc_id: Option<String>,
51    chunk: Option<u32>,
52    tags: Option<Vec<String>>,
53    #[allow(dead_code)]
54    preview: Option<String>,
55}
56
57#[derive(Deserialize, Debug, Clone)]
58struct GraphEdge {
59    data: EdgeData,
60}
61
62#[derive(Deserialize, Debug, Clone)]
63struct EdgeData {
64    id: String,
65    source: String,
66    target: String,
67    weight: Option<f64>,
68}
69
70#[derive(Deserialize, Debug, Clone)]
71struct GraphStats {
72    points: u32,
73    edges: u32,
74    #[allow(dead_code)]
75    by_camino: Option<std::collections::HashMap<String, u32>>,
76}
77
78// ─── Widget ──────────────────────────────────────────────────────
79
80type NavCallback = Rc<RefCell<Option<Box<dyn FnMut(String)>>>>;
81
82const CANVAS_W: f64 = 600.0;
83const CANVAS_H: f64 = 270.0;
84/// Ancho del rectángulo nodo (horizontal para texto largo).
85const NODE_W: f64 = 120.0;
86/// Alto del rectángulo nodo.
87const NODE_H: f64 = 28.0;
88
89const CAMINO_COLORS: &[(&str, &str)] = &[
90    ("logos", "#d0dbff"),
91    ("aire", "#d0dbff"),
92    ("nomos", "#f59056"),
93    ("fuego", "#f59056"),
94    ("kay", "#d49873"),
95    ("tierra", "#d49873"),
96    ("uku", "#6cd0f3"),
97    ("agua", "#6cd0f3"),
98];
99
100fn camino_color(camino: &str) -> &str {
101    for (k, v) in CAMINO_COLORS {
102        if *k == camino {
103            return v;
104        }
105    }
106    "#888888"
107}
108
109pub struct GraphWidget {
110    container: HtmlElement,
111    api_url: String,
112    svg: Option<SvgsvgElement>,
113    nodes: Vec<NodeData>,
114    edges: Vec<EdgeData>,
115    on_navigate: NavCallback,
116    document: Document,
117}
118
119impl GraphWidget {
120    pub fn new(
121        container: HtmlElement,
122        api_url: &str,
123        on_navigate: Option<Box<dyn FnMut(String)>>,
124    ) -> Self {
125        let doc = crate::document().unwrap_or_else(|| {
126            web_sys::window()
127                .and_then(|w| w.document())
128                .expect("no document")
129        });
130        Self {
131            container,
132            api_url: api_url.to_string(),
133            svg: None,
134            nodes: Vec::new(),
135            edges: Vec::new(),
136            on_navigate: Rc::new(RefCell::new(on_navigate)),
137            document: doc,
138        }
139    }
140
141    pub async fn load(&mut self) -> Result<(), JsValue> {
142        let url = format!("{}/graph?limit=500", self.api_url);
143        let window = web_sys::window().ok_or_else(|| JsValue::from_str("no window"))?;
144
145        let resp_value = JsFuture::from(window.fetch_with_str(&url)).await?;
146        let resp: Response = resp_value.dyn_into()?;
147        if !resp.ok() {
148            return Err(JsValue::from_str(&format!("HTTP {}", resp.status())));
149        }
150        let text = JsFuture::from(resp.text()?).await?;
151        let body = text.as_string().unwrap_or_default();
152
153        let graph: GraphResponse =
154            serde_json::from_str(&body).map_err(|e| JsValue::from_str(&format!("JSON: {e}")))?;
155
156        let nodes: Vec<NodeData> = graph
157            .nodes
158            .into_iter()
159            .map(|n| n.data)
160            .filter(|n| n.doc_id.is_some())
161            .collect();
162        let edges: Vec<EdgeData> = graph.edges.into_iter().map(|e| e.data).collect();
163
164        self.nodes = nodes;
165        self.edges = edges;
166
167        // Pequeño delay para evitar "Layout was forced before fully loaded"
168        let _ = js_sys::Promise::resolve(&JsValue::NULL);
169        let mut_self = &*self as *const GraphWidget;
170        // Render síncrono, el delay no es necesario pero mantenemos la deferencia.
171        self.render();
172        Ok(())
173    }
174
175    fn render(&self) {
176        self.container.set_inner_html("");
177
178        if self.nodes.is_empty() {
179            return;
180        }
181
182        let positions = force_layout(&self.nodes, &self.edges, CANVAS_W, CANVAS_H);
183
184        let ns = "http://www.w3.org/2000/svg";
185        let svg: SvgsvgElement = self
186            .document
187            .create_element_ns(Some(ns), "svg")
188            .unwrap()
189            .dyn_into()
190            .unwrap();
191        svg.set_attribute("viewBox", &format!("0 0 {} {}", CANVAS_W as u32, CANVAS_H as u32))
192            .ok();
193        svg.set_attribute("width", "100%").ok();
194        svg.set_attribute("height", &format!("{}px", CANVAS_H as u32)).ok();
195        svg.style().set_property("display", "block").ok();
196        svg.style().set_property("margin", "1.5rem auto 0").ok();
197        svg.style().set_property("max-width", "100%").ok();
198        svg.style()
199            .set_property("background", "rgba(255,255,255,0.02)")
200            .ok();
201        svg.style().set_property("border-radius", "12px").ok();
202        svg.style()
203            .set_property("border", "1px solid rgba(216,168,93,0.15)")
204            .ok();
205
206        // ── Aristas con grosor proporcional al weight ──
207        for edge in &self.edges {
208            let src_pos = positions.iter().find(|(id, _)| *id == edge.source);
209            let tgt_pos = positions.iter().find(|(id, _)| *id == edge.target);
210            if let (Some((_, (x1, y1))), Some((_, (x2, y2)))) = (src_pos, tgt_pos) {
211                let line: SvgLineElement = self
212                    .document
213                    .create_element_ns(Some(ns), "line")
214                    .unwrap()
215                    .dyn_into()
216                    .unwrap();
217                line.set_attribute("x1", &format!("{:.1}", x1)).ok();
218                line.set_attribute("y1", &format!("{:.1}", y1)).ok();
219                line.set_attribute("x2", &format!("{:.1}", x2)).ok();
220                line.set_attribute("y2", &format!("{:.1}", y2)).ok();
221
222                // Grosor según peso: 0.5→1, 1.0→4 (clamped)
223                let sw = edge
224                    .weight
225                    .map(|w| 0.5 + (w - 0.5) * 6.0)
226                    .unwrap_or(1.0);
227                line.set_attribute("stroke", "rgba(255,255,255,0.20)").ok();
228                line.set_attribute("stroke-width", &format!("{:.1}", sw.clamp(0.5, 5.0)))
229                    .ok();
230
231                svg.append_child(&line).ok();
232            }
233        }
234
235        // ── Nodos como rectángulos con texto dentro ──
236        let on_nav = self.on_navigate.clone();
237        let ns_local = ns; // copy for closure captures
238        for (i, node) in self.nodes.iter().enumerate() {
239            let (cx, cy) = positions.get(i).map(|(_, p)| *p).unwrap_or((100.0, 100.0));
240            let color = camino_color(&node.camino).to_string();
241            let label = if node.name.len() > 18 {
242                format!("{}…", &node.name[..16])
243            } else {
244                node.name.clone()
245            };
246            let camino_up = node.camino.to_uppercase();
247
248            // Grupo contenedor (para hover + click)
249            let g: web_sys::SvgElement = self
250                .document
251                .create_element_ns(Some(ns_local), "g")
252                .unwrap()
253                .dyn_into()
254                .unwrap();
255            g.style().set_property("cursor", "pointer").ok();
256            g.set_attribute("title", &format!("{} — {}", node.name, camino_up)).ok();
257
258            // Rectángulo redondeado
259            let rect: SvgRectElement = self
260                .document
261                .create_element_ns(Some(ns_local), "rect")
262                .unwrap()
263                .dyn_into()
264                .unwrap();
265            let rx = cx - NODE_W / 2.0;
266            let ry = cy - NODE_H / 2.0;
267            rect.set_attribute("x", &format!("{:.1}", rx)).ok();
268            rect.set_attribute("y", &format!("{:.1}", ry)).ok();
269            rect.set_attribute("width", &format!("{:.1}", NODE_W)).ok();
270            rect.set_attribute("height", &format!("{:.1}", NODE_H)).ok();
271            rect.set_attribute("rx", "6").ok();
272            rect.set_attribute("ry", "6").ok();
273            rect.set_attribute("fill", &color).ok();
274            rect.set_attribute("fill-opacity", "0.25").ok();
275            rect.set_attribute("stroke", &color).ok();
276            rect.set_attribute("stroke-width", "1.5").ok();
277            rect.style().set_property("transition", "all 200ms ease").ok();
278            rect.style()
279                .set_property("filter", "drop-shadow(0 0 4px rgba(255,255,255,0.06))")
280                .ok();
281
282            // Texto dentro del rectángulo
283            let text: SvgTextElement = self
284                .document
285                .create_element_ns(Some(ns_local), "text")
286                .unwrap()
287                .dyn_into()
288                .unwrap();
289            text.set_attribute("x", &format!("{:.1}", cx)).ok();
290            text.set_attribute("y", &format!("{:.1}", cy + 5.0)).ok();
291            text.set_attribute("text-anchor", "middle").ok();
292            text.set_attribute("dominant-baseline", "middle").ok();
293            text.set_attribute("fill", "rgba(232,234,245,0.85)").ok();
294            text.set_attribute("font-size", "12").ok();
295            text.set_attribute("font-family", "Inter, system-ui, sans-serif").ok();
296            text.set_attribute("font-weight", "500").ok();
297            text.set_text_content(Some(&label));
298
299            // Subtexto (camino) más pequeño debajo
300            let sub: SvgTextElement = self
301                .document
302                .create_element_ns(Some(ns_local), "text")
303                .unwrap()
304                .dyn_into()
305                .unwrap();
306            sub.set_attribute("x", &format!("{:.1}", cx)).ok();
307            sub.set_attribute("y", &format!("{:.1}", cy + 19.0)).ok();
308            sub.set_attribute("text-anchor", "middle").ok();
309            sub.set_attribute("dominant-baseline", "middle").ok();
310            sub.set_attribute("fill", "rgba(232,234,245,0.40)").ok();
311            sub.set_attribute("font-size", "8").ok();
312            sub.set_attribute("font-family", "Inter, system-ui, sans-serif").ok();
313            sub.set_attribute("letter-spacing", "0.3em").ok();
314            sub.set_text_content(Some(&camino_up));
315
316            g.append_child(&rect).ok();
317            g.append_child(&text).ok();
318            g.append_child(&sub).ok();
319
320            // Hover: opacidad más alta
321            let rect_clone = rect.clone();
322            let color_c = color.clone();
323            let enter = Closure::<dyn FnMut(MouseEvent)>::new(move |_| {
324                rect_clone.set_attribute("fill-opacity", "0.50").ok();
325                rect_clone
326                    .style()
327                    .set_property(
328                        "filter",
329                        &format!("drop-shadow(0 0 10px {})", color_c),
330                    )
331                    .ok();
332            });
333            g.add_event_listener_with_callback("mouseenter", enter.as_ref().unchecked_ref())
334                .ok();
335            enter.forget();
336
337            let rect_clone2 = rect.clone();
338            let leave = Closure::<dyn FnMut(MouseEvent)>::new(move |_| {
339                rect_clone2
340                    .set_attribute("fill-opacity", "0.25")
341                    .ok();
342                rect_clone2
343                    .style()
344                    .set_property("filter", "drop-shadow(0 0 4px rgba(255,255,255,0.06))")
345                    .ok();
346            });
347            g.add_event_listener_with_callback("mouseleave", leave.as_ref().unchecked_ref())
348                .ok();
349            leave.forget();
350
351            // Click
352            let doc_id = node.doc_id.clone().unwrap_or_default();
353            let on_nav2 = on_nav.clone();
354            let click = Closure::<dyn FnMut(MouseEvent)>::new(move |_| {
355                let mut cb = on_nav2.borrow_mut();
356                if let Some(ref mut f) = *cb {
357                    f(doc_id.clone());
358                }
359            });
360            g.add_event_listener_with_callback("click", click.as_ref().unchecked_ref())
361                .ok();
362            click.forget();
363
364            svg.append_child(&g).ok();
365        }
366
367        self.container.append_child(&svg).ok();
368    }
369}
370
371// ─── Force-directed layout (Fruchterman-Reingold) ────────────────
372
373fn force_layout(
374    nodes: &[NodeData],
375    edges: &[EdgeData],
376    w: f64,
377    h: f64,
378) -> Vec<(String, (f64, f64))> {
379    let n = nodes.len();
380    if n == 0 {
381        return vec![];
382    }
383
384    let area = w * h;
385    let k = (area / (n as f64)).sqrt() * 1.6; // más separación
386
387    let cx = w / 2.0;
388    let cy = h / 2.0;
389    let radius = (w.min(h) * 0.30).max(60.0);
390    let mut positions: Vec<(f64, f64)> = nodes
391        .iter()
392        .enumerate()
393        .map(|(i, _)| {
394            let angle = 2.0 * std::f64::consts::PI * (i as f64) / (n as f64)
395                - std::f64::consts::PI / 2.0;
396            (cx + radius * angle.cos(), cy + radius * angle.sin())
397        })
398        .collect();
399
400    let id_to_idx: std::collections::HashMap<&str, usize> = nodes
401        .iter()
402        .enumerate()
403        .map(|(i, node)| (node.doc_id.as_deref().unwrap_or(""), i))
404        .filter(|(id, _)| !id.is_empty())
405        .collect();
406
407    let mut adj: Vec<Vec<usize>> = vec![vec![]; n];
408    for e in edges {
409        if let (Some(&si), Some(&ti)) =
410            (id_to_idx.get(e.source.as_str()), id_to_idx.get(e.target.as_str()))
411        {
412            if !adj[si].contains(&ti) {
413                adj[si].push(ti);
414            }
415            if !adj[ti].contains(&si) {
416                adj[ti].push(si);
417            }
418        }
419    }
420
421    let iterations = 80;
422    let temp_init = w.max(h) / 5.0;
423    let mut disp: Vec<(f64, f64)> = vec![(0.0, 0.0); n];
424    let half_w = NODE_W / 2.0 + 6.0;
425    let half_h = NODE_H / 2.0 + 4.0;
426
427    for iter in 0..iterations {
428        let temp = temp_init * (1.0 - (iter as f64) / (iterations as f64));
429
430        for d in disp.iter_mut() {
431            *d = (0.0, 0.0);
432        }
433
434        // Repulsión
435        for i in 0..n {
436            for j in (i + 1)..n {
437                let dx = positions[i].0 - positions[j].0;
438                let dy = positions[i].1 - positions[j].1;
439                let dist = (dx * dx + dy * dy).sqrt().max(1.0);
440                let force = k * k / dist;
441                disp[i].0 += force * dx / dist;
442                disp[i].1 += force * dy / dist;
443                disp[j].0 -= force * dx / dist;
444                disp[j].1 -= force * dy / dist;
445            }
446        }
447
448        // Atracción en aristas
449        for i in 0..n {
450            for &j in &adj[i] {
451                let dx = positions[j].0 - positions[i].0;
452                let dy = positions[j].1 - positions[i].1;
453                let dist = (dx * dx + dy * dy).sqrt().max(1.0);
454                let force = dist * dist / k;
455                disp[i].0 += force * dx / dist;
456                disp[i].1 += force * dy / dist;
457                disp[j].0 -= force * dx / dist;
458                disp[j].1 -= force * dy / dist;
459            }
460        }
461
462        // Aplicar
463        for i in 0..n {
464            let d = (disp[i].0 * disp[i].0 + disp[i].1 * disp[i].1)
465                .sqrt()
466                .max(0.001);
467            let step_x = (disp[i].0 / d * temp).clamp(-temp, temp);
468            let step_y = (disp[i].1 / d * temp).clamp(-temp, temp);
469            let new_x = (positions[i].0 + step_x).clamp(half_w, w - half_w);
470            let new_y = (positions[i].1 + step_y).clamp(half_h, h - half_h);
471            positions[i] = (new_x, new_y);
472        }
473    }
474
475    nodes
476        .iter()
477        .zip(positions.into_iter())
478        .map(|(n, pos)| (n.doc_id.clone().unwrap_or_default(), pos))
479        .collect()
480}