tawasuyu

tawasuyu/brahman

Rust 572TOML 217Markdown 68JSON 198326 Definitions

⎇ ef3d698c4b4d0c0ab88da4d59c37d57fef894ce8 crates/modules/gioser/gioser-graph-web/src/lib.rs Rust · 446 lines · 16.8 KB History Who touched what raw

gioser-graph: edge colors blend from connected node caminos ef3d698c4 · Sergio ·

1//! `gioser-graph-web` — grafo semántico SVG inline.
2//!
3//! Layout grid: 3 columnas, filas según cantidad de nodos.
4//! Nodos: rectángulos redondeados 170×44px con texto + subtexto (camino).
5//! Aristas: opacidad/brillo según weight (más peso = más blanca y opaca).
6//! Respiración CSS suave en el SVG (opacity oscila perpetua).
7//! Hover: glow + opacidad.
8//! Las aristas conectan por ID (UUID), no por doc_id.
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    #[allow(dead_code)]
47    preview: Option<String>,
48}
49
50#[derive(Deserialize, Debug, Clone)]
51struct GraphEdge {
52    data: EdgeData,
53}
54
55#[derive(Deserialize, Debug, Clone)]
56struct EdgeData {
57    id: String,
58    source: String,
59    target: String,
60    weight: Option<f64>,
61}
62
63#[derive(Deserialize, Debug, Clone)]
64struct GraphStats {
65    points: u32,
66    edges: u32,
67    #[allow(dead_code)]
68    by_camino: Option<std::collections::HashMap<String, u32>>,
69}
70
71// ─── Widget ──────────────────────────────────────────────────────
72
73type NavCallback = Rc<RefCell<Option<Box<dyn FnMut(String)>>>>;
74
75const CANVAS_W: f64 = 800.0;
76const CANVAS_H: f64 = 420.0;
77const NODE_W: f64 = 170.0;
78const NODE_H: f64 = 44.0;
79const COLS: usize = 3;
80
81const CAMINO_COLORS: &[(&str, &str)] = &[
82    ("logos", "#d0dbff"), ("aire",   "#d0dbff"),
83    ("nomos", "#f59056"), ("fuego",  "#f59056"),
84    ("kay",   "#d49873"), ("tierra", "#d49873"),
85    ("uku",   "#6cd0f3"), ("agua",   "#6cd0f3"),
86];
87
88fn camino_color(camino: &str) -> &str {
89    for (k, v) in CAMINO_COLORS {
90        if *k == camino { return v; }
91    }
92    "#888888"
93}
94
95fn parse_hex(hex: &str) -> (u8, u8, u8) {
96    let h = hex.trim_start_matches('#');
97    if h.len() == 6 {
98        let r = u8::from_str_radix(&h[0..2], 16).unwrap_or(128);
99        let g = u8::from_str_radix(&h[2..4], 16).unwrap_or(128);
100        let b = u8::from_str_radix(&h[4..6], 16).unwrap_or(128);
101        (r, g, b)
102    } else {
103        (136, 136, 136)
104    }
105}
106
107fn blend_colors(c1: &str, c2: &str, t: f64) -> String {
108    let (r1, g1, b1) = parse_hex(c1);
109    let (r2, g2, b2) = parse_hex(c2);
110    let r = (r1 as f64 * (1.0 - t) + r2 as f64 * t) as u32;
111    let g = (g1 as f64 * (1.0 - t) + g2 as f64 * t) as u32;
112    let b = (b1 as f64 * (1.0 - t) + b2 as f64 * t) as u32;
113    format!("#{:02x}{:02x}{:02x}", r, g, b)
114}
115
116pub struct GraphWidget {
117    container: HtmlElement,
118    api_url: String,
119    nodes: Vec<NodeData>,
120    edges: Vec<EdgeData>,
121    on_navigate: NavCallback,
122    document: Document,
123}
124
125impl GraphWidget {
126    pub fn new(
127        container: HtmlElement,
128        api_url: &str,
129        on_navigate: Option<Box<dyn FnMut(String)>>,
130    ) -> Self {
131        let doc = crate::document().unwrap_or_else(|| {
132            web_sys::window()
133                .and_then(|w| w.document())
134                .expect("no document")
135        });
136        Self {
137            container,
138            api_url: api_url.to_string(),
139            nodes: Vec::new(),
140            edges: Vec::new(),
141            on_navigate: Rc::new(RefCell::new(on_navigate)),
142            document: doc,
143        }
144    }
145
146    pub async fn load(&mut self) -> Result<(), JsValue> {
147        let url = format!("{}/graph?limit=500", self.api_url);
148        let window = web_sys::window().ok_or_else(|| JsValue::from_str("no window"))?;
149        let resp_value = JsFuture::from(window.fetch_with_str(&url)).await?;
150        let resp: Response = resp_value.dyn_into()?;
151        if !resp.ok() {
152            return Err(JsValue::from_str(&format!("HTTP {}", resp.status())));
153        }
154        let text = JsFuture::from(resp.text()?).await?;
155        let body = text.as_string().unwrap_or_default();
156
157        let graph: GraphResponse =
158            serde_json::from_str(&body).map_err(|e| JsValue::from_str(&format!("JSON: {e}")))?;
159
160        let nodes: Vec<NodeData> = graph.nodes.into_iter().map(|n| n.data).collect();
161        let edges: Vec<EdgeData> = graph.edges.into_iter().map(|e| e.data).collect();
162
163        self.nodes = nodes;
164        self.edges = edges;
165        self.render();
166        Ok(())
167    }
168
169    fn render(&self) {
170        self.container.set_inner_html("");
171
172        if self.nodes.is_empty() {
173            return;
174        }
175
176        let positions = grid_layout(&self.nodes, CANVAS_W, CANVAS_H);
177
178        let ns = "http://www.w3.org/2000/svg";
179        let svg: SvgsvgElement = self
180            .document
181            .create_element_ns(Some(ns), "svg")
182            .unwrap()
183            .dyn_into()
184            .unwrap();
185        svg.set_attribute("viewBox", &format!("0 0 {} {}", CANVAS_W as u32, CANVAS_H as u32)).ok();
186        svg.set_attribute("width", "100%").ok();
187        svg.set_attribute("preserveAspectRatio", "xMidYMid meet").ok();
188        svg.style().set_property("display", "block").ok();
189        svg.style().set_property("margin", "1.5rem auto 0").ok();
190        svg.style().set_property("max-width", "100%").ok();
191        svg.style().set_property("height", "auto").ok();
192        svg.style().set_property("background", "rgba(255,255,255,0.02)").ok();
193        svg.style().set_property("border-radius", "12px").ok();
194        svg.style().set_property("border", "1px solid rgba(216,168,93,0.15)").ok();
195
196        // Estilo inline en SVG: respiración y transiciones
197        let style_el = self.document.create_element_ns(Some(ns), "style").unwrap();
198        style_el.set_text_content(Some(
199            "@keyframes graph-breathe {\
200              0%, 100% { opacity: 1; }\
201              50% { opacity: 0.92; }\
202            }\
203            .gb-svg { animation: graph-breathe 5s ease-in-out infinite; }\
204            .gb-node { transition: filter 250ms ease, opacity 200ms ease; }\
205            .gb-node:hover { filter: drop-shadow(0 0 14px rgba(255,255,255,0.2)); }\
206            .gb-line { transition: opacity 400ms ease; }",
207        ));
208        svg.append_child(&style_el).ok();
209
210        let breathe_group: web_sys::SvgElement = self
211            .document
212            .create_element_ns(Some(ns), "g")
213            .unwrap()
214            .dyn_into()
215            .unwrap();
216        breathe_group.set_attribute("class", "gb-svg").ok();
217
218        // Mapas: UUID → posición y color
219        let pos_map: std::collections::HashMap<&str, (f64, f64)> = positions
220            .iter()
221            .map(|(id, p)| (id.as_str(), *p))
222            .collect();
223        let color_map: std::collections::HashMap<&str, &str> = self.nodes
224            .iter()
225            .map(|n| (n.id.as_str(), camino_color(&n.camino)))
226            .collect();
227
228        let max_w = self.edges.iter()
229            .filter_map(|e| e.weight)
230            .fold(0.0_f64, f64::max)
231            .max(0.5);
232
233        // ── Aristas: mezcla de colores de los nodos que conecta ──
234        let mut drawn = std::collections::HashSet::new();
235        for edge in &self.edges {
236            let key = if edge.source < edge.target {
237                (edge.source.as_str(), edge.target.as_str())
238            } else {
239                (edge.target.as_str(), edge.source.as_str())
240            };
241            if !drawn.insert(key) { continue; }
242
243            let Some((x1, y1)) = pos_map.get(edge.source.as_str()) else { continue; };
244            let Some((x2, y2)) = pos_map.get(edge.target.as_str()) else { continue; };
245
246            let c1 = color_map.get(edge.source.as_str()).copied().unwrap_or("#888");
247            let c2 = color_map.get(edge.target.as_str()).copied().unwrap_or("#888");
248
249            let w = edge.weight.unwrap_or(0.7);
250            let norm_w = (w / max_w).clamp(0.0, 1.0);
251
252            // Mezclar colores: 50/50 + brillo según weight
253            let blended = blend_colors(c1, c2, 0.5);
254
255            // Brillo extra según el peso (acercar a blanco)
256            let (br, bg, bb) = parse_hex(&blended);
257            let r = (br as f64 + (255.0 - br as f64) * norm_w * 0.4) as u32;
258            let g = (bg as f64 + (255.0 - bg as f64) * norm_w * 0.4) as u32;
259            let b = (bb as f64 + (255.0 - bb as f64) * norm_w * 0.4) as u32;
260
261            let alpha = 0.20 + norm_w * 0.65;
262            let sw = 1.0 + norm_w * 4.5;
263
264            let line: SvgLineElement = self
265                .document
266                .create_element_ns(Some(ns), "line")
267                .unwrap()
268                .dyn_into()
269                .unwrap();
270            line.set_attribute("x1", &format!("{:.1}", x1)).ok();
271            line.set_attribute("y1", &format!("{:.1}", y1)).ok();
272            line.set_attribute("x2", &format!("{:.1}", x2)).ok();
273            line.set_attribute("y2", &format!("{:.1}", y2)).ok();
274            line.set_attribute("stroke", &format!("#{:02x}{:02x}{:02x}", r, g, b)).ok();
275            line.set_attribute("stroke-width", &format!("{:.1}", sw)).ok();
276            line.set_attribute("stroke-opacity", &format!("{:.2}", alpha)).ok();
277            line.set_attribute("class", "gb-line").ok();
278            breathe_group.append_child(&line).ok();
279        }
280
281        // ── Nodos ──
282        let on_nav = self.on_navigate.clone();
283        for (i, node) in self.nodes.iter().enumerate() {
284            let Some((cx, cy)) = positions.get(i).map(|(_, p)| *p) else { continue; };
285            let color = camino_color(&node.camino).to_string();
286            let label = if node.name.chars().count() > 20 {
287                let cutoff: String = node.name.chars().take(18).collect();
288                format!("{}…", cutoff)
289            } else {
290                node.name.clone()
291            };
292            let camino_up = node.camino.to_uppercase();
293
294            let g: web_sys::SvgElement = self
295                .document
296                .create_element_ns(Some(ns), "g")
297                .unwrap()
298                .dyn_into()
299                .unwrap();
300            g.style().set_property("cursor", "pointer").ok();
301            g.set_attribute("class", "gb-node").ok();
302            g.set_attribute("title", &format!("{} — {}", node.name, camino_up)).ok();
303
304            let rx = cx - NODE_W / 2.0;
305            let ry = cy - NODE_H / 2.0;
306
307            let glow: SvgCircleElement = self
308                .document
309                .create_element_ns(Some(ns), "circle")
310                .unwrap()
311                .dyn_into()
312                .unwrap();
313            glow.set_attribute("cx", &format!("{:.1}", cx)).ok();
314            glow.set_attribute("cy", &format!("{:.1}", cy)).ok();
315            glow.set_attribute("r", "32").ok();
316            glow.set_attribute("fill", &color).ok();
317            glow.set_attribute("fill-opacity", "0.05").ok();
318            g.append_child(&glow).ok();
319
320            let rect: SvgRectElement = self
321                .document
322                .create_element_ns(Some(ns), "rect")
323                .unwrap()
324                .dyn_into()
325                .unwrap();
326            rect.set_attribute("x", &format!("{:.1}", rx)).ok();
327            rect.set_attribute("y", &format!("{:.1}", ry)).ok();
328            rect.set_attribute("width", &format!("{:.1}", NODE_W)).ok();
329            rect.set_attribute("height", &format!("{:.1}", NODE_H)).ok();
330            rect.set_attribute("rx", "8").ok();
331            rect.set_attribute("ry", "8").ok();
332            rect.set_attribute("fill", &color).ok();
333            rect.set_attribute("fill-opacity", "0.28").ok();
334            rect.set_attribute("stroke", &color).ok();
335            rect.set_attribute("stroke-width", "1.8").ok();
336            rect.set_attribute("stroke-opacity", "0.7").ok();
337            rect.style().set_property("transition", "all 200ms ease").ok();
338
339            let text: SvgTextElement = self
340                .document
341                .create_element_ns(Some(ns), "text")
342                .unwrap()
343                .dyn_into()
344                .unwrap();
345            text.set_attribute("x", &format!("{:.1}", cx)).ok();
346            text.set_attribute("y", &format!("{:.1}", cy - 2.0)).ok();
347            text.set_attribute("text-anchor", "middle").ok();
348            text.set_attribute("dominant-baseline", "middle").ok();
349            text.set_attribute("fill", "rgba(232,234,245,0.88)").ok();
350            text.set_attribute("font-size", "13").ok();
351            text.set_attribute("font-family", "Inter, system-ui, sans-serif").ok();
352            text.set_attribute("font-weight", "500").ok();
353            text.set_text_content(Some(&label));
354
355            let sub: SvgTextElement = self
356                .document
357                .create_element_ns(Some(ns), "text")
358                .unwrap()
359                .dyn_into()
360                .unwrap();
361            sub.set_attribute("x", &format!("{:.1}", cx)).ok();
362            sub.set_attribute("y", &format!("{:.1}", cy + 15.0)).ok();
363            sub.set_attribute("text-anchor", "middle").ok();
364            sub.set_attribute("dominant-baseline", "middle").ok();
365            sub.set_attribute("fill", "rgba(232,234,245,0.45)").ok();
366            sub.set_attribute("font-size", "9").ok();
367            sub.set_attribute("font-family", "Inter, system-ui, sans-serif").ok();
368            sub.set_attribute("letter-spacing", "0.25em").ok();
369            sub.set_text_content(Some(&camino_up));
370
371            g.append_child(&rect).ok();
372            g.append_child(&text).ok();
373            g.append_child(&sub).ok();
374
375            // Hover
376            let rect_clone = rect.clone();
377            let color_c = color.clone();
378            let glow_clone = glow.clone();
379            let enter = Closure::<dyn FnMut(MouseEvent)>::new(move |_| {
380                rect_clone.set_attribute("fill-opacity", "0.55").ok();
381                rect_clone.set_attribute("stroke-opacity", "1").ok();
382                rect_clone.style()
383                    .set_property("filter", &format!("drop-shadow(0 0 12px {})", color_c))
384                    .ok();
385                glow_clone.set_attribute("fill-opacity", "0.20").ok();
386            });
387            g.add_event_listener_with_callback("mouseenter", enter.as_ref().unchecked_ref()).ok();
388            enter.forget();
389
390            let rect_clone2 = rect.clone();
391            let glow_clone2 = glow.clone();
392            let leave = Closure::<dyn FnMut(MouseEvent)>::new(move |_| {
393                rect_clone2.set_attribute("fill-opacity", "0.28").ok();
394                rect_clone2.set_attribute("stroke-opacity", "0.7").ok();
395                rect_clone2.style().set_property("filter", "none").ok();
396                glow_clone2.set_attribute("fill-opacity", "0.05").ok();
397            });
398            g.add_event_listener_with_callback("mouseleave", leave.as_ref().unchecked_ref()).ok();
399            leave.forget();
400
401            let doc_id = node.doc_id.clone().unwrap_or_default();
402            let on_nav2 = on_nav.clone();
403            let click = Closure::<dyn FnMut(MouseEvent)>::new(move |_| {
404                let mut cb = on_nav2.borrow_mut();
405                if let Some(ref mut f) = *cb { f(doc_id.clone()); }
406            });
407            g.add_event_listener_with_callback("click", click.as_ref().unchecked_ref()).ok();
408            click.forget();
409
410            breathe_group.append_child(&g).ok();
411        }
412
413        svg.append_child(&breathe_group).ok();
414        self.container.append_child(&svg).ok();
415    }
416}
417
418// ─── Layout grid: 3 columnas ─────────────────────────────────────
419
420fn grid_layout(nodes: &[NodeData], w: f64, h: f64) -> Vec<(String, (f64, f64))> {
421    let n = nodes.len();
422    if n == 0 { return vec![]; }
423
424    let rows = (n + COLS - 1) / COLS;
425    let actual_rows = rows.max(3);
426    let margin_x = NODE_W / 2.0 + 20.0;
427    let margin_y = NODE_H / 2.0 + 20.0;
428    let usable_w = w - margin_x * 2.0;
429    let usable_h = h - margin_y * 2.0;
430    let col_gap = usable_w / (COLS as f64);
431    let row_gap = usable_h / (actual_rows as f64);
432
433    let mut out = Vec::with_capacity(n);
434    for (i, node) in nodes.iter().enumerate() {
435        let col = i % COLS;
436        let row = i / COLS;
437        let offset_x = if row == rows - 1 && n % COLS != 0 {
438            let remaining = n - row * COLS;
439            (usable_w - remaining as f64 * col_gap) / 2.0
440        } else { 0.0 };
441        let x = margin_x + offset_x + col as f64 * col_gap + col_gap / 2.0;
442        let y = margin_y + row as f64 * row_gap + row_gap / 2.0;
443        out.push((node.id.clone(), (x, y)));
444    }
445    out
446}