⎇ 794d3e932e3dbb37742ff2d5454c9efcd360b1d8
crates/modules/gioser/gioser-graph-web/src/lib.rs
History
Who touched what
raw
expandir a 9 páginas: +cuerpo, sombra, cosmos, practica, olvido
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, PointerEvent, 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 = 480.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 ("cuerpo", "#e07a5f"),
87 ("sombra", "#6a6a7a"),
88 ("cosmos", "#d4a843"),
89 ("practica", "#2d936c"),
90 ("olvido", "#b0b8c0"),
91];
92
93fn camino_color(camino: &str) -> &str {
94 for (k, v) in CAMINO_COLORS {
95 if *k == camino { return v; }
96 }
97 "#888888"
98}
99
100fn parse_hex(hex: &str) -> (u8, u8, u8) {
101 let h = hex.trim_start_matches('#');
102 if h.len() == 6 {
103 let r = u8::from_str_radix(&h[0..2], 16).unwrap_or(128);
104 let g = u8::from_str_radix(&h[2..4], 16).unwrap_or(128);
105 let b = u8::from_str_radix(&h[4..6], 16).unwrap_or(128);
106 (r, g, b)
107 } else {
108 (136, 136, 136)
109 }
110}
111
112fn blend_colors(c1: &str, c2: &str, t: f64) -> String {
113 let (r1, g1, b1) = parse_hex(c1);
114 let (r2, g2, b2) = parse_hex(c2);
115 let r = (r1 as f64 * (1.0 - t) + r2 as f64 * t) as u32;
116 let g = (g1 as f64 * (1.0 - t) + g2 as f64 * t) as u32;
117 let b = (b1 as f64 * (1.0 - t) + b2 as f64 * t) as u32;
118 format!("#{:02x}{:02x}{:02x}", r, g, b)
119}
120
121pub struct GraphWidget {
122 container: HtmlElement,
123 api_url: String,
124 nodes: Vec<NodeData>,
125 edges: Vec<EdgeData>,
126 on_navigate: NavCallback,
127 document: Document,
128}
129
130impl GraphWidget {
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 Self {
142 container,
143 api_url: api_url.to_string(),
144 nodes: Vec::new(),
145 edges: Vec::new(),
146 on_navigate: Rc::new(RefCell::new(on_navigate)),
147 document: doc,
148 }
149 }
150
151 pub async fn load(&mut self) -> Result<(), JsValue> {
152 let url = format!("{}/graph?limit=500", self.api_url);
153 let window = web_sys::window().ok_or_else(|| JsValue::from_str("no window"))?;
154 let resp_value = JsFuture::from(window.fetch_with_str(&url)).await?;
155 let resp: Response = resp_value.dyn_into()?;
156 if !resp.ok() {
157 return Err(JsValue::from_str(&format!("HTTP {}", resp.status())));
158 }
159 let text = JsFuture::from(resp.text()?).await?;
160 let body = text.as_string().unwrap_or_default();
161
162 let graph: GraphResponse =
163 serde_json::from_str(&body).map_err(|e| JsValue::from_str(&format!("JSON: {e}")))?;
164
165 let nodes: Vec<NodeData> = graph.nodes.into_iter().map(|n| n.data).collect();
166 let edges: Vec<EdgeData> = graph.edges.into_iter().map(|e| e.data).collect();
167
168 self.nodes = nodes;
169 self.edges = edges;
170 self.render();
171 Ok(())
172 }
173
174 fn render(&self) {
175 self.container.set_inner_html("");
176
177 if self.nodes.is_empty() {
178 return;
179 }
180
181 let positions = grid_layout(&self.nodes, CANVAS_W, CANVAS_H);
182
183 let ns = "http://www.w3.org/2000/svg";
184 let svg: SvgsvgElement = self
185 .document
186 .create_element_ns(Some(ns), "svg")
187 .unwrap()
188 .dyn_into()
189 .unwrap();
190 svg.set_attribute("viewBox", &format!("0 0 {} {}", CANVAS_W as u32, CANVAS_H as u32)).ok();
191 svg.set_attribute("width", "100%").ok();
192 svg.set_attribute("preserveAspectRatio", "xMidYMid meet").ok();
193 svg.style().set_property("display", "block").ok();
194 svg.style().set_property("margin", "1.5rem auto 0").ok();
195 svg.style().set_property("max-width", "100%").ok();
196 svg.style().set_property("height", "auto").ok();
197 svg.style().set_property("background", "rgba(255,255,255,0.02)").ok();
198 svg.style().set_property("border-radius", "12px").ok();
199 svg.style().set_property("border", "1px solid rgba(216,168,93,0.15)").ok();
200
201 // Estilo inline en SVG: respiración y transiciones
202 let style_el = self.document.create_element_ns(Some(ns), "style").unwrap();
203 style_el.set_text_content(Some(
204 "@keyframes graph-breathe {\
205 0%, 100% { opacity: 1; }\
206 50% { opacity: 0.92; }\
207 }\
208 .gb-svg { animation: graph-breathe 5s ease-in-out infinite; }\
209 .gb-node { transition: filter 250ms ease, opacity 200ms ease; }\
210 .gb-node:hover {\
211 filter: drop-shadow(0 0 14px rgba(255,255,255,0.2));\
212 }\
213 .gb-edge-group { pointer-events: none; }\
214 .gb-line { transition: opacity 400ms ease; }",
215 ));
216 svg.append_child(&style_el).ok();
217
218 // Grupo para aristas (sin animación de respiración, están detrás)
219 let edges_group: web_sys::SvgElement = self
220 .document
221 .create_element_ns(Some(ns), "g")
222 .unwrap()
223 .dyn_into()
224 .unwrap();
225 edges_group.set_attribute("class", "gb-edge-group").ok();
226
227 // Grupo para nodos (con respiración, encima)
228 let nodes_group: web_sys::SvgElement = self
229 .document
230 .create_element_ns(Some(ns), "g")
231 .unwrap()
232 .dyn_into()
233 .unwrap();
234 nodes_group.set_attribute("class", "gb-svg").ok();
235
236 // Mapas: UUID → posición y color
237 let pos_map: std::collections::HashMap<&str, (f64, f64)> = positions
238 .iter()
239 .map(|(id, p)| (id.as_str(), *p))
240 .collect();
241 let color_map: std::collections::HashMap<&str, &str> = self.nodes
242 .iter()
243 .map(|n| (n.id.as_str(), camino_color(&n.camino)))
244 .collect();
245
246 // ── Aristas: mezcla de colores de los nodos que conecta ──
247 let mut drawn = std::collections::HashSet::new();
248 for edge in &self.edges {
249 let key = if edge.source < edge.target {
250 (edge.source.as_str(), edge.target.as_str())
251 } else {
252 (edge.target.as_str(), edge.source.as_str())
253 };
254 if !drawn.insert(key) { continue; }
255
256 let Some((x1, y1)) = pos_map.get(edge.source.as_str()) else { continue; };
257 let Some((x2, y2)) = pos_map.get(edge.target.as_str()) else { continue; };
258
259 let c1 = color_map.get(edge.source.as_str()).copied().unwrap_or("#888");
260 let c2 = color_map.get(edge.target.as_str()).copied().unwrap_or("#888");
261
262 let w = edge.weight.unwrap_or(0.7);
263
264 // Min-max scaling: expandir el rango real (0.83-0.93) a 0.0-1.0
265 // Con un mínimo global de 0.80 para capturar todo
266 let norm = ((w - 0.80) / 0.15).clamp(0.0, 1.0);
267 // Curva cuadrática para exagerar diferencias: norm²
268 let expanded = norm * norm;
269
270 // Grosor sutil: 0.5px a 3.5px (no grueso, diferenciado)
271 let thick = 0.8 + expanded * 3.0;
272
273 // Color mezclado
274 let blended = blend_colors(c1, c2, 0.5);
275 let (br, bg, bb) = parse_hex(&blended);
276 // Brillo: sin expander, normal 0-1, sútil
277 let bright_factor = norm * 0.5;
278 let r = (br as f64 + (255.0 - br as f64) * bright_factor).min(255.0) as u32;
279 let g = (bg as f64 + (255.0 - bg as f64) * bright_factor).min(255.0) as u32;
280 let b = (bb as f64 + (255.0 - bb as f64) * bright_factor).min(255.0) as u32;
281
282 // Opacidad baja y sutil: 15% a 40%
283 let alpha = 0.12 + expanded * 0.30;
284
285 let line: SvgLineElement = self
286 .document
287 .create_element_ns(Some(ns), "line")
288 .unwrap()
289 .dyn_into()
290 .unwrap();
291 line.set_attribute("x1", &format!("{:.1}", x1)).ok();
292 line.set_attribute("y1", &format!("{:.1}", y1)).ok();
293 line.set_attribute("x2", &format!("{:.1}", x2)).ok();
294 line.set_attribute("y2", &format!("{:.1}", y2)).ok();
295 line.set_attribute("stroke", &format!("#{:02x}{:02x}{:02x}", r, g, b)).ok();
296 line.set_attribute("stroke-width", &format!("{:.1}", thick)).ok();
297 line.set_attribute("stroke-opacity", &format!("{:.2}", alpha)).ok();
298 line.set_attribute("class", "gb-line").ok();
299 edges_group.append_child(&line).ok();
300 }
301
302 // ── Nodos ──
303 let on_nav = self.on_navigate.clone();
304 for (i, node) in self.nodes.iter().enumerate() {
305 let Some((cx, cy)) = positions.get(i).map(|(_, p)| *p) else { continue; };
306 let color = camino_color(&node.camino).to_string();
307 let label = if node.name.chars().count() > 20 {
308 let cutoff: String = node.name.chars().take(18).collect();
309 format!("{}…", cutoff)
310 } else {
311 node.name.clone()
312 };
313 let camino_up = node.camino.to_uppercase();
314
315 let g: web_sys::SvgElement = self
316 .document
317 .create_element_ns(Some(ns), "g")
318 .unwrap()
319 .dyn_into()
320 .unwrap();
321 g.style().set_property("cursor", "pointer").ok();
322 g.set_attribute("class", "gb-node").ok();
323 g.set_attribute("title", &format!("{} — {}", node.name, camino_up)).ok();
324
325 let rx = cx - NODE_W / 2.0;
326 let ry = cy - NODE_H / 2.0;
327
328 let glow: SvgCircleElement = self
329 .document
330 .create_element_ns(Some(ns), "circle")
331 .unwrap()
332 .dyn_into()
333 .unwrap();
334 glow.set_attribute("cx", &format!("{:.1}", cx)).ok();
335 glow.set_attribute("cy", &format!("{:.1}", cy)).ok();
336 glow.set_attribute("r", "32").ok();
337 glow.set_attribute("fill", &color).ok();
338 glow.set_attribute("fill-opacity", "0.05").ok();
339 g.append_child(&glow).ok();
340
341 let rect: SvgRectElement = self
342 .document
343 .create_element_ns(Some(ns), "rect")
344 .unwrap()
345 .dyn_into()
346 .unwrap();
347 rect.set_attribute("x", &format!("{:.1}", rx)).ok();
348 rect.set_attribute("y", &format!("{:.1}", ry)).ok();
349 rect.set_attribute("width", &format!("{:.1}", NODE_W)).ok();
350 rect.set_attribute("height", &format!("{:.1}", NODE_H)).ok();
351 rect.set_attribute("rx", "8").ok();
352 rect.set_attribute("ry", "8").ok();
353 rect.set_attribute("fill", &color).ok();
354 rect.set_attribute("fill-opacity", "0.28").ok();
355 rect.set_attribute("stroke", &color).ok();
356 rect.set_attribute("stroke-width", "1.8").ok();
357 rect.set_attribute("stroke-opacity", "0.7").ok();
358 rect.style().set_property("transition", "all 200ms ease").ok();
359
360 let text: SvgTextElement = self
361 .document
362 .create_element_ns(Some(ns), "text")
363 .unwrap()
364 .dyn_into()
365 .unwrap();
366 text.set_attribute("x", &format!("{:.1}", cx)).ok();
367 text.set_attribute("y", &format!("{:.1}", cy - 2.0)).ok();
368 text.set_attribute("text-anchor", "middle").ok();
369 text.set_attribute("dominant-baseline", "middle").ok();
370 text.set_attribute("fill", "rgba(232,234,245,0.88)").ok();
371 text.set_attribute("font-size", "13").ok();
372 text.set_attribute("font-family", "Inter, system-ui, sans-serif").ok();
373 text.set_attribute("font-weight", "500").ok();
374 text.set_text_content(Some(&label));
375
376 let sub: SvgTextElement = self
377 .document
378 .create_element_ns(Some(ns), "text")
379 .unwrap()
380 .dyn_into()
381 .unwrap();
382 sub.set_attribute("x", &format!("{:.1}", cx)).ok();
383 sub.set_attribute("y", &format!("{:.1}", cy + 15.0)).ok();
384 sub.set_attribute("text-anchor", "middle").ok();
385 sub.set_attribute("dominant-baseline", "middle").ok();
386 sub.set_attribute("fill", "rgba(232,234,245,0.45)").ok();
387 sub.set_attribute("font-size", "9").ok();
388 sub.set_attribute("font-family", "Inter, system-ui, sans-serif").ok();
389 sub.set_attribute("letter-spacing", "0.25em").ok();
390 sub.set_text_content(Some(&camino_up));
391
392 g.append_child(&rect).ok();
393 g.append_child(&text).ok();
394 g.append_child(&sub).ok();
395
396 // Hover + drag
397 let rect_h = rect.clone();
398 let col_h = color.clone();
399 let glow_h = glow.clone();
400 let g_hover = g.clone();
401 let g_clone_for_drag = g.clone();
402
403 let enter = Closure::<dyn FnMut(MouseEvent)>::new(move |_| {
404 rect_h.set_attribute("fill-opacity", "0.55").ok();
405 rect_h.set_attribute("stroke-opacity", "1").ok();
406 rect_h.style()
407 .set_property("filter", &format!("drop-shadow(0 0 12px {})", col_h))
408 .ok();
409 glow_h.set_attribute("fill-opacity", "0.20").ok();
410 });
411 g_hover.add_event_listener_with_callback("mouseenter", enter.as_ref().unchecked_ref()).ok();
412 enter.forget();
413
414 let rect_l = rect.clone();
415 let glow_l = glow.clone();
416 let leave = Closure::<dyn FnMut(MouseEvent)>::new(move |_| {
417 rect_l.set_attribute("fill-opacity", "0.28").ok();
418 rect_l.set_attribute("stroke-opacity", "0.7").ok();
419 rect_l.style().set_property("filter", "none").ok();
420 glow_l.set_attribute("fill-opacity", "0.05").ok();
421 });
422 g.add_event_listener_with_callback("mouseleave", leave.as_ref().unchecked_ref()).ok();
423 leave.forget();
424
425 // Click → navegar
426 let nav_target = node.camino.clone();
427 let on_nav2 = on_nav.clone();
428 let click = Closure::<dyn FnMut(MouseEvent)>::new(move |_| {
429 let mut cb = on_nav2.borrow_mut();
430 if let Some(ref mut f) = *cb { f(nav_target.clone()); }
431 });
432 g.add_event_listener_with_callback("click", click.as_ref().unchecked_ref()).ok();
433 click.forget();
434
435 // Drag: pointerdown → move → up (reacomodar al soltar)
436 let g_drag = g.clone();
437 let g_move = g.clone();
438 let g_up = g.clone();
439 let drag_start = Rc::new(RefCell::new(None::<(f64, f64, f64, f64)>));
440 let drag_start2 = drag_start.clone();
441 let drag_start3 = drag_start.clone();
442
443 let pdown = Closure::<dyn FnMut(PointerEvent)>::new(move |e: PointerEvent| {
444 e.prevent_default();
445 let g_rect = g_drag.get_bounding_client_rect();
446 *drag_start.borrow_mut() = Some((
447 e.client_x() as f64,
448 e.client_y() as f64,
449 g_rect.left() + g_rect.width() / 2.0,
450 g_rect.top() + g_rect.height() / 2.0,
451 ));
452 g_drag.set_pointer_capture(e.pointer_id()).ok();
453 });
454 g.add_event_listener_with_callback("pointerdown", pdown.as_ref().unchecked_ref()).ok();
455 pdown.forget();
456
457 let pmove = Closure::<dyn FnMut(PointerEvent)>::new(move |e: PointerEvent| {
458 if let Some((start_cx, start_cy, _, _)) = *drag_start2.borrow() {
459 let dx = e.client_x() as f64 - start_cx;
460 let dy = e.client_y() as f64 - start_cy;
461 g_move.set_attribute(
462 "transform",
463 &format!("translate({:.1},{:.1})", dx, dy),
464 ).ok();
465 }
466 });
467 g.add_event_listener_with_callback("pointermove", pmove.as_ref().unchecked_ref()).ok();
468 pmove.forget();
469
470 let pup = Closure::<dyn FnMut(PointerEvent)>::new(move |_e: PointerEvent| {
471 *drag_start3.borrow_mut() = None;
472 g_up.set_attribute("transform", "translate(0,0)").ok();
473 });
474 g.add_event_listener_with_callback("pointerup", pup.as_ref().unchecked_ref()).ok();
475 pup.forget();
476
477 g.style().set_property("transition", "transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1)").ok();
478
479 nodes_group.append_child(&g).ok();
480 }
481
482 svg.append_child(&edges_group).ok();
483 svg.append_child(&nodes_group).ok();
484 self.container.append_child(&svg).ok();
485 }
486}
487
488// ─── Layout grid: 3 columnas ─────────────────────────────────────
489
490fn grid_layout(nodes: &[NodeData], w: f64, h: f64) -> Vec<(String, (f64, f64))> {
491 let n = nodes.len();
492 if n == 0 { return vec![]; }
493
494 let rows = (n + COLS - 1) / COLS;
495 let actual_rows = rows.max(3);
496 let margin_x = NODE_W / 2.0 + 20.0;
497 let margin_y = NODE_H / 2.0 + 20.0;
498 let usable_w = w - margin_x * 2.0;
499 let usable_h = h - margin_y * 2.0;
500 let col_gap = usable_w / (COLS as f64);
501 let row_gap = usable_h / (actual_rows as f64);
502
503 let mut out = Vec::with_capacity(n);
504 for (i, node) in nodes.iter().enumerate() {
505 let col = i % COLS;
506 let row = i / COLS;
507 let offset_x = if row == rows - 1 && n % COLS != 0 {
508 let remaining = n - row * COLS;
509 (usable_w - remaining as f64 * col_gap) / 2.0
510 } else { 0.0 };
511 let x = margin_x + offset_x + col as f64 * col_gap + col_gap / 2.0;
512 let y = margin_y + row as f64 * row_gap + row_gap / 2.0;
513 out.push((node.id.clone(), (x, y)));
514 }
515 out
516}