gioser

tawasuyu/brahman

Rust 582TOML 222Markdown 73JSON 198635 Sutichaykuna

⎇ b5032de1e3c8c27ce375137584c4ed873a13bdfd crates/modules/gioser/gioser-graph-web/src/lib.rs Rust · 474 siq'ikuna · 17.2 KB Ñawpa willay Pi imata llamkarqan ch'uya

gioser-graph: grid layout 3 cols, line brightness by weight, breathing animation b5032de1e · Sergio ·

1//! `gioser-graph-web` — widget de grafo semántico SVG inline.
2//!
3//! Layout grid: 3 columnas, filas según la cantidad de nodos.
4//! Los nodos son rectángulos redondeados con texto + subtexto (camino).
5//! Aristas se dibujan entre todos los pares con líneas semitransparentes:
6//! mientras mayor el weight, más opaca y brillante la línea.
7//! Animación CSS de respiración suave en el SVG.
8//! Hover: glow + opacidad.
9
10use std::cell::RefCell;
11use std::rc::Rc;
12
13use serde::Deserialize;
14use wasm_bindgen::prelude::*;
15use wasm_bindgen::JsCast;
16use wasm_bindgen_futures::JsFuture;
17use web_sys::{
18    Document, HtmlElement, MouseEvent, Response, SvgLineElement, SvgRectElement,
19    SvgsvgElement, SvgTextElement, SvgCircleElement,
20};
21
22pub(crate) fn document() -> Option<Document> {
23    web_sys::window().and_then(|w| w.document())
24}
25
26// ─── Tipos de respuesta de `/graph` ──────────────────────────────
27
28#[derive(Deserialize, Debug, Clone)]
29struct GraphResponse {
30    nodes: Vec<GraphNode>,
31    edges: Vec<GraphEdge>,
32    stats: GraphStats,
33}
34
35#[derive(Deserialize, Debug, Clone)]
36struct GraphNode {
37    data: NodeData,
38}
39
40#[derive(Deserialize, Debug, Clone)]
41struct NodeData {
42    id: String,
43    name: String,
44    camino: String,
45    doc_id: Option<String>,
46    chunk: Option<u32>,
47    tags: Option<Vec<String>>,
48    #[allow(dead_code)]
49    preview: Option<String>,
50}
51
52#[derive(Deserialize, Debug, Clone)]
53struct GraphEdge {
54    data: EdgeData,
55}
56
57#[derive(Deserialize, Debug, Clone)]
58struct EdgeData {
59    id: String,
60    source: String,
61    target: String,
62    weight: Option<f64>,
63}
64
65#[derive(Deserialize, Debug, Clone)]
66struct GraphStats {
67    points: u32,
68    edges: u32,
69    #[allow(dead_code)]
70    by_camino: Option<std::collections::HashMap<String, u32>>,
71}
72
73// ─── Widget ──────────────────────────────────────────────────────
74
75type NavCallback = Rc<RefCell<Option<Box<dyn FnMut(String)>>>>;
76
77const CANVAS_W: f64 = 800.0;
78const CANVAS_H: f64 = 420.0;
79const NODE_W: f64 = 170.0;   // más grandes
80const NODE_H: f64 = 44.0;    // más grandes
81const COLS: usize = 3;
82
83const CAMINO_COLORS: &[(&str, &str)] = &[
84    ("logos", "#d0dbff"), ("aire",   "#d0dbff"),
85    ("nomos", "#f59056"), ("fuego",  "#f59056"),
86    ("kay",   "#d49873"), ("tierra", "#d49873"),
87    ("uku",   "#6cd0f3"), ("agua",   "#6cd0f3"),
88];
89
90fn camino_color(camino: &str) -> &str {
91    for (k, v) in CAMINO_COLORS {
92        if *k == camino { return v; }
93    }
94    "#888888"
95}
96
97fn weight_alpha(w: Option<f64>) -> f64 {
98    // weight 0.5 → 0.40, weight 1.0 → 0.85
99    match w {
100        Some(v) => 0.40 + (v - 0.5).max(0.0) * 0.9,
101        None => 0.35,
102    }
103    .clamp(0.15, 0.95)
104}
105
106fn weight_stroke_color(w: Option<f64>, base: &str) -> String {
107    let alpha = weight_alpha(w);
108    // Extraer color base, añadir alpha
109    // Asumimos formato #rrggbb
110    if base.len() >= 7 && alpha < 1.0 {
111        format!("{}", base) // se sobreescribe con stroke-opacity
112    } else {
113        base.to_string()
114    }
115}
116
117pub struct GraphWidget {
118    container: HtmlElement,
119    api_url: String,
120    nodes: Vec<NodeData>,
121    edges: Vec<EdgeData>,
122    on_navigate: NavCallback,
123    document: Document,
124}
125
126impl GraphWidget {
127    pub fn new(
128        container: HtmlElement,
129        api_url: &str,
130        on_navigate: Option<Box<dyn FnMut(String)>>,
131    ) -> Self {
132        let doc = crate::document().unwrap_or_else(|| {
133            web_sys::window()
134                .and_then(|w| w.document())
135                .expect("no document")
136        });
137        Self {
138            container,
139            api_url: api_url.to_string(),
140            nodes: Vec::new(),
141            edges: Vec::new(),
142            on_navigate: Rc::new(RefCell::new(on_navigate)),
143            document: doc,
144        }
145    }
146
147    pub async fn load(&mut self) -> Result<(), JsValue> {
148        let url = format!("{}/graph?limit=500", self.api_url);
149        let window = web_sys::window().ok_or_else(|| JsValue::from_str("no window"))?;
150
151        let resp_value = JsFuture::from(window.fetch_with_str(&url)).await?;
152        let resp: Response = resp_value.dyn_into()?;
153        if !resp.ok() {
154            return Err(JsValue::from_str(&format!("HTTP {}", resp.status())));
155        }
156        let text = JsFuture::from(resp.text()?).await?;
157        let body = text.as_string().unwrap_or_default();
158
159        let graph: GraphResponse =
160            serde_json::from_str(&body).map_err(|e| JsValue::from_str(&format!("JSON: {e}")))?;
161
162        let nodes: Vec<NodeData> = graph
163            .nodes
164            .into_iter()
165            .map(|n| n.data)
166            .filter(|n| n.doc_id.is_some())
167            .collect();
168        let edges: Vec<EdgeData> = graph.edges.into_iter().map(|e| e.data).collect();
169
170        self.nodes = nodes;
171        self.edges = edges;
172
173        self.render();
174        Ok(())
175    }
176
177    fn render(&self) {
178        self.container.set_inner_html("");
179
180        if self.nodes.is_empty() {
181            return;
182        }
183
184        let positions = grid_layout(&self.nodes, CANVAS_W, CANVAS_H);
185
186        let ns = "http://www.w3.org/2000/svg";
187        let svg: SvgsvgElement = self
188            .document
189            .create_element_ns(Some(ns), "svg")
190            .unwrap()
191            .dyn_into()
192            .unwrap();
193        svg.set_attribute("viewBox", &format!("0 0 {} {}", CANVAS_W as u32, CANVAS_H as u32)).ok();
194        svg.set_attribute("width", "100%").ok();
195        svg.set_attribute("height", &format!("{}px", CANVAS_H as u32)).ok();
196        svg.set_attribute("preserveAspectRatio", "xMidYMid meet").ok();
197        svg.style().set_property("display", "block").ok();
198        svg.style().set_property("margin", "1.5rem auto 0").ok();
199        svg.style().set_property("max-width", "100%").ok();
200        svg.style().set_property("height", "auto").ok();
201        svg.style().set_property("background", "rgba(255,255,255,0.02)").ok();
202        svg.style().set_property("border-radius", "12px").ok();
203        svg.style().set_property("border", "1px solid rgba(216,168,93,0.15)").ok();
204
205        // Estilo para animación de respiración en el SVG
206        // Se añade un <style> dentro del SVG
207        let style_el = self.document.create_element_ns(Some(ns), "style").unwrap();
208        style_el.set_text_content(Some(
209            "@keyframes graph-breathe {\
210              0%, 100% { opacity: 1; }\
211              50% { opacity: 0.94; }\
212            }\
213            .gb-svg {\
214              animation: graph-breathe 4.2s ease-in-out infinite;\
215            }\
216            .gb-node {\
217              transition: filter 250ms ease, opacity 200ms ease;\
218            }\
219            .gb-node:hover {\
220              filter: drop-shadow(0 0 14px rgba(255,255,255,0.2));\
221            }\
222            .gb-line {\
223              transition: opacity 300ms ease;\
224            }\
225            .gb-line:hover {\
226              opacity: 0.9 !important;\
227            }",
228        ));
229        svg.append_child(&style_el).ok();
230
231        // Grupo con animación de respiración
232        let breathe_group: web_sys::SvgElement = self
233            .document
234            .create_element_ns(Some(ns), "g")
235            .unwrap()
236            .dyn_into()
237            .unwrap();
238        breathe_group.set_attribute("class", "gb-svg").ok();
239
240        // Mapa: doc_id → (x, y)
241        let pos_map: std::collections::HashMap<&str, (f64, f64)> = positions
242            .iter()
243            .map(|(id, p)| (id.as_str(), *p))
244            .collect();
245
246        // Calcular max weight para normalizar
247        let max_w = self.edges.iter()
248            .filter_map(|e| e.weight)
249            .fold(0.0_f64, f64::max)
250            .max(0.5);
251
252        // ── Aristas ──
253        // Agrupar por par no dirigido (source, target) para no duplicar
254        let mut drawn = std::collections::HashSet::new();
255        for edge in &self.edges {
256            let key = if edge.source < edge.target {
257                (edge.source.as_str(), edge.target.as_str())
258            } else {
259                (edge.target.as_str(), edge.source.as_str())
260            };
261            if !drawn.insert(key) { continue; }
262
263            let src_pos = pos_map.get(edge.source.as_str());
264            let tgt_pos = pos_map.get(edge.target.as_str());
265            if let (Some((x1, y1)), Some((x2, y2))) = (src_pos, tgt_pos) {
266                let line: SvgLineElement = self
267                    .document
268                    .create_element_ns(Some(ns), "line")
269                    .unwrap()
270                    .dyn_into()
271                    .unwrap();
272
273                // Calcular opacidad basada en weight
274                let w = edge.weight.unwrap_or(0.7);
275                let norm_w = (w / max_w).clamp(0.0, 1.0);
276                let alpha = 0.15 + norm_w * 0.70; // 0.15 a 0.85
277
278                // Calcular grosor: 1 a 5 según weight normalizado
279                let sw = 1.0 + norm_w * 4.0;
280
281                // Color: más brillante = más blanco
282                let r = (255.0 - (1.0 - norm_w) * 80.0) as u32;
283                let g = (255.0 - (1.0 - norm_w) * 60.0) as u32;
284                let b = (255.0 - (1.0 - norm_w) * 40.0) as u32;
285
286                line.set_attribute("x1", &format!("{:.1}", x1)).ok();
287                line.set_attribute("y1", &format!("{:.1}", y1)).ok();
288                line.set_attribute("x2", &format!("{:.1}", x2)).ok();
289                line.set_attribute("y2", &format!("{:.1}", y2)).ok();
290                line.set_attribute("stroke", &format!("#{:02x}{:02x}{:02x}", r, g, b)).ok();
291                line.set_attribute("stroke-width", &format!("{:.1}", sw)).ok();
292                line.set_attribute("stroke-opacity", &format!("{:.2}", alpha)).ok();
293                line.set_attribute("class", "gb-line").ok();
294
295                breathe_group.append_child(&line).ok();
296            }
297        }
298
299        // ── Nodos ──
300        let on_nav = self.on_navigate.clone();
301        for (i, node) in self.nodes.iter().enumerate() {
302            let Some((cx, cy)) = positions.get(i).map(|(_, p)| *p) else { continue; };
303            let color = camino_color(&node.camino).to_string();
304            let label = if node.name.len() > 20 {
305                format!("{}…", &node.name[..18])
306            } else {
307                node.name.clone()
308            };
309            let camino_up = node.camino.to_uppercase();
310
311            let g: web_sys::SvgElement = self
312                .document
313                .create_element_ns(Some(ns), "g")
314                .unwrap()
315                .dyn_into()
316                .unwrap();
317            g.style().set_property("cursor", "pointer").ok();
318            g.set_attribute("class", "gb-node").ok();
319            g.set_attribute("title", &format!("{} — {}", node.name, camino_up)).ok();
320
321            let rx = cx - NODE_W / 2.0;
322            let ry = cy - NODE_H / 2.0;
323
324            // Sombra suave de fondo (círculo/glow detrás del rect)
325            let glow: SvgCircleElement = self
326                .document
327                .create_element_ns(Some(ns), "circle")
328                .unwrap()
329                .dyn_into()
330                .unwrap();
331            glow.set_attribute("cx", &format!("{:.1}", cx)).ok();
332            glow.set_attribute("cy", &format!("{:.1}", cy)).ok();
333            glow.set_attribute("r", "30").ok();
334            glow.set_attribute("fill", &color).ok();
335            glow.set_attribute("fill-opacity", "0.06").ok();
336            g.append_child(&glow).ok();
337
338            let rect: SvgRectElement = self
339                .document
340                .create_element_ns(Some(ns), "rect")
341                .unwrap()
342                .dyn_into()
343                .unwrap();
344            rect.set_attribute("x", &format!("{:.1}", rx)).ok();
345            rect.set_attribute("y", &format!("{:.1}", ry)).ok();
346            rect.set_attribute("width", &format!("{:.1}", NODE_W)).ok();
347            rect.set_attribute("height", &format!("{:.1}", NODE_H)).ok();
348            rect.set_attribute("rx", "8").ok();
349            rect.set_attribute("ry", "8").ok();
350            rect.set_attribute("fill", &color).ok();
351            rect.set_attribute("fill-opacity", "0.28").ok();
352            rect.set_attribute("stroke", &color).ok();
353            rect.set_attribute("stroke-width", "1.8").ok();
354            rect.set_attribute("stroke-opacity", "0.7").ok();
355            rect.style().set_property("transition", "all 200ms ease").ok();
356
357            let text: SvgTextElement = self
358                .document
359                .create_element_ns(Some(ns), "text")
360                .unwrap()
361                .dyn_into()
362                .unwrap();
363            text.set_attribute("x", &format!("{:.1}", cx)).ok();
364            text.set_attribute("y", &format!("{:.1}", cy - 2.0)).ok();
365            text.set_attribute("text-anchor", "middle").ok();
366            text.set_attribute("dominant-baseline", "middle").ok();
367            text.set_attribute("fill", "rgba(232,234,245,0.88)").ok();
368            text.set_attribute("font-size", "13").ok();
369            text.set_attribute("font-family", "Inter, system-ui, sans-serif").ok();
370            text.set_attribute("font-weight", "500").ok();
371            text.set_text_content(Some(&label));
372
373            let sub: SvgTextElement = self
374                .document
375                .create_element_ns(Some(ns), "text")
376                .unwrap()
377                .dyn_into()
378                .unwrap();
379            sub.set_attribute("x", &format!("{:.1}", cx)).ok();
380            sub.set_attribute("y", &format!("{:.1}", cy + 15.0)).ok();
381            sub.set_attribute("text-anchor", "middle").ok();
382            sub.set_attribute("dominant-baseline", "middle").ok();
383            sub.set_attribute("fill", "rgba(232,234,245,0.45)").ok();
384            sub.set_attribute("font-size", "9").ok();
385            sub.set_attribute("font-family", "Inter, system-ui, sans-serif").ok();
386            sub.set_attribute("letter-spacing", "0.25em").ok();
387            sub.set_text_content(Some(&camino_up));
388
389            g.append_child(&rect).ok();
390            g.append_child(&text).ok();
391            g.append_child(&sub).ok();
392
393            // Hover glow
394            let rect_clone = rect.clone();
395            let color_c = color.clone();
396            let glow_clone = glow.clone();
397            let enter = Closure::<dyn FnMut(MouseEvent)>::new(move |_| {
398                rect_clone.set_attribute("fill-opacity", "0.55").ok();
399                rect_clone.set_attribute("stroke-opacity", "1").ok();
400                rect_clone
401                    .style()
402                    .set_property("filter", &format!("drop-shadow(0 0 12px {})", color_c))
403                    .ok();
404                glow_clone.set_attribute("fill-opacity", "0.20").ok();
405            });
406            g.add_event_listener_with_callback("mouseenter", enter.as_ref().unchecked_ref()).ok();
407            enter.forget();
408
409            let rect_clone2 = rect.clone();
410            let glow_clone2 = glow.clone();
411            let leave = Closure::<dyn FnMut(MouseEvent)>::new(move |_| {
412                rect_clone2.set_attribute("fill-opacity", "0.28").ok();
413                rect_clone2.set_attribute("stroke-opacity", "0.7").ok();
414                rect_clone2.style().set_property("filter", "none").ok();
415                glow_clone2.set_attribute("fill-opacity", "0.06").ok();
416            });
417            g.add_event_listener_with_callback("mouseleave", leave.as_ref().unchecked_ref()).ok();
418            leave.forget();
419
420            let doc_id = node.doc_id.clone().unwrap_or_default();
421            let on_nav2 = on_nav.clone();
422            let click = Closure::<dyn FnMut(MouseEvent)>::new(move |_| {
423                let mut cb = on_nav2.borrow_mut();
424                if let Some(ref mut f) = *cb { f(doc_id.clone()); }
425            });
426            g.add_event_listener_with_callback("click", click.as_ref().unchecked_ref()).ok();
427            click.forget();
428
429            breathe_group.append_child(&g).ok();
430        }
431
432        svg.append_child(&breathe_group).ok();
433        self.container.append_child(&svg).ok();
434    }
435}
436
437// ─── Layout grid: 3 columnas, filas según número de nodos ──────────
438
439fn grid_layout(nodes: &[NodeData], w: f64, h: f64) -> Vec<(String, (f64, f64))> {
440    let n = nodes.len();
441    if n == 0 { return vec![]; }
442
443    let rows = (n + COLS - 1) / COLS;  // ceiling div
444    let actual_rows = rows.max(3); // mínimo 3 filas visuales
445    let margin_x = NODE_W / 2.0 + 20.0;
446    let margin_y = NODE_H / 2.0 + 16.0;
447
448    let usable_w = w - margin_x * 2.0;
449    let usable_h = h - margin_y * 2.0;
450    let col_gap = usable_w / (COLS as f64);
451    let row_gap = usable_h / (actual_rows as f64);
452
453    let mut positions = Vec::with_capacity(n);
454    for (i, node) in nodes.iter().enumerate() {
455        let col = i % COLS;
456        let row = i / COLS;
457        // Centrar si en última fila hay menos de 3
458        let offset_x = if row == rows - 1 && n % COLS != 0 {
459            // centrar fila
460            let remaining = n - row * COLS;
461            (usable_w - remaining as f64 * col_gap) / 2.0
462        } else {
463            0.0
464        };
465        let x = margin_x + offset_x + col as f64 * col_gap + col_gap / 2.0;
466        let y = margin_y + row as f64 * row_gap + row_gap / 2.0;
467        // Desplazar cada fila un tercio para dar sensación de expansión vertical
468        let y_adj = y; // ya está bien con el grid
469
470        positions.push((node.doc_id.clone().unwrap_or_default(), (x, y_adj)));
471    }
472
473    positions
474}