⎇ fa2bedf851ba9a99c472e968805f0c214c04b94a
crates/modules/gioser/gioser-graph-web/src/lib.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
gioser-graph: fix edge UUID mapping, index 4 docs, unify page bg
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
95pub struct GraphWidget {
96 container: HtmlElement,
97 api_url: String,
98 nodes: Vec<NodeData>,
99 edges: Vec<EdgeData>,
100 on_navigate: NavCallback,
101 document: Document,
102}
103
104impl GraphWidget {
105 pub fn new(
106 container: HtmlElement,
107 api_url: &str,
108 on_navigate: Option<Box<dyn FnMut(String)>>,
109 ) -> Self {
110 let doc = crate::document().unwrap_or_else(|| {
111 web_sys::window()
112 .and_then(|w| w.document())
113 .expect("no document")
114 });
115 Self {
116 container,
117 api_url: api_url.to_string(),
118 nodes: Vec::new(),
119 edges: Vec::new(),
120 on_navigate: Rc::new(RefCell::new(on_navigate)),
121 document: doc,
122 }
123 }
124
125 pub async fn load(&mut self) -> Result<(), JsValue> {
126 let url = format!("{}/graph?limit=500", self.api_url);
127 let window = web_sys::window().ok_or_else(|| JsValue::from_str("no window"))?;
128 let resp_value = JsFuture::from(window.fetch_with_str(&url)).await?;
129 let resp: Response = resp_value.dyn_into()?;
130 if !resp.ok() {
131 return Err(JsValue::from_str(&format!("HTTP {}", resp.status())));
132 }
133 let text = JsFuture::from(resp.text()?).await?;
134 let body = text.as_string().unwrap_or_default();
135
136 let graph: GraphResponse =
137 serde_json::from_str(&body).map_err(|e| JsValue::from_str(&format!("JSON: {e}")))?;
138
139 let nodes: Vec<NodeData> = graph.nodes.into_iter().map(|n| n.data).collect();
140 let edges: Vec<EdgeData> = graph.edges.into_iter().map(|e| e.data).collect();
141
142 self.nodes = nodes;
143 self.edges = edges;
144 self.render();
145 Ok(())
146 }
147
148 fn render(&self) {
149 self.container.set_inner_html("");
150
151 if self.nodes.is_empty() {
152 return;
153 }
154
155 let positions = grid_layout(&self.nodes, CANVAS_W, CANVAS_H);
156
157 let ns = "http://www.w3.org/2000/svg";
158 let svg: SvgsvgElement = self
159 .document
160 .create_element_ns(Some(ns), "svg")
161 .unwrap()
162 .dyn_into()
163 .unwrap();
164 svg.set_attribute("viewBox", &format!("0 0 {} {}", CANVAS_W as u32, CANVAS_H as u32)).ok();
165 svg.set_attribute("width", "100%").ok();
166 svg.set_attribute("preserveAspectRatio", "xMidYMid meet").ok();
167 svg.style().set_property("display", "block").ok();
168 svg.style().set_property("margin", "1.5rem auto 0").ok();
169 svg.style().set_property("max-width", "100%").ok();
170 svg.style().set_property("height", "auto").ok();
171 svg.style().set_property("background", "rgba(255,255,255,0.02)").ok();
172 svg.style().set_property("border-radius", "12px").ok();
173 svg.style().set_property("border", "1px solid rgba(216,168,93,0.15)").ok();
174
175 // Estilo inline en SVG: respiración y transiciones
176 let style_el = self.document.create_element_ns(Some(ns), "style").unwrap();
177 style_el.set_text_content(Some(
178 "@keyframes graph-breathe {\
179 0%, 100% { opacity: 1; }\
180 50% { opacity: 0.92; }\
181 }\
182 .gb-svg { animation: graph-breathe 5s ease-in-out infinite; }\
183 .gb-node { transition: filter 250ms ease, opacity 200ms ease; }\
184 .gb-node:hover { filter: drop-shadow(0 0 14px rgba(255,255,255,0.2)); }\
185 .gb-line { transition: opacity 400ms ease; }",
186 ));
187 svg.append_child(&style_el).ok();
188
189 let breathe_group: web_sys::SvgElement = self
190 .document
191 .create_element_ns(Some(ns), "g")
192 .unwrap()
193 .dyn_into()
194 .unwrap();
195 breathe_group.set_attribute("class", "gb-svg").ok();
196
197 // Mapa: node.id → (x, y) — usamos UUID, no doc_id
198 let pos_map: std::collections::HashMap<&str, (f64, f64)> = positions
199 .iter()
200 .map(|(id, p)| (id.as_str(), *p))
201 .collect();
202
203 let max_w = self.edges.iter()
204 .filter_map(|e| e.weight)
205 .fold(0.0_f64, f64::max)
206 .max(0.5);
207
208 // ── Aristas ──
209 let mut drawn = std::collections::HashSet::new();
210 for edge in &self.edges {
211 let key = if edge.source < edge.target {
212 (edge.source.as_str(), edge.target.as_str())
213 } else {
214 (edge.target.as_str(), edge.source.as_str())
215 };
216 if !drawn.insert(key) { continue; }
217
218 let Some((x1, y1)) = pos_map.get(edge.source.as_str()) else { continue; };
219 let Some((x2, y2)) = pos_map.get(edge.target.as_str()) else { continue; };
220
221 let w = edge.weight.unwrap_or(0.7);
222 let norm_w = (w / max_w).clamp(0.0, 1.0);
223 let alpha = 0.15 + norm_w * 0.70;
224 let sw = 1.0 + norm_w * 4.0;
225 let r = (255.0 - (1.0 - norm_w) * 80.0) as u32;
226 let g = (255.0 - (1.0 - norm_w) * 60.0) as u32;
227 let b = (255.0 - (1.0 - norm_w) * 40.0) as u32;
228
229 let line: SvgLineElement = self
230 .document
231 .create_element_ns(Some(ns), "line")
232 .unwrap()
233 .dyn_into()
234 .unwrap();
235 line.set_attribute("x1", &format!("{:.1}", x1)).ok();
236 line.set_attribute("y1", &format!("{:.1}", y1)).ok();
237 line.set_attribute("x2", &format!("{:.1}", x2)).ok();
238 line.set_attribute("y2", &format!("{:.1}", y2)).ok();
239 line.set_attribute("stroke", &format!("#{:02x}{:02x}{:02x}", r, g, b)).ok();
240 line.set_attribute("stroke-width", &format!("{:.1}", sw)).ok();
241 line.set_attribute("stroke-opacity", &format!("{:.2}", alpha)).ok();
242 line.set_attribute("class", "gb-line").ok();
243 breathe_group.append_child(&line).ok();
244 }
245
246 // ── Nodos ──
247 let on_nav = self.on_navigate.clone();
248 for (i, node) in self.nodes.iter().enumerate() {
249 let Some((cx, cy)) = positions.get(i).map(|(_, p)| *p) else { continue; };
250 let color = camino_color(&node.camino).to_string();
251 let label = if node.name.len() > 20 {
252 format!("{}…", &node.name[..18])
253 } else {
254 node.name.clone()
255 };
256 let camino_up = node.camino.to_uppercase();
257
258 let g: web_sys::SvgElement = self
259 .document
260 .create_element_ns(Some(ns), "g")
261 .unwrap()
262 .dyn_into()
263 .unwrap();
264 g.style().set_property("cursor", "pointer").ok();
265 g.set_attribute("class", "gb-node").ok();
266 g.set_attribute("title", &format!("{} — {}", node.name, camino_up)).ok();
267
268 let rx = cx - NODE_W / 2.0;
269 let ry = cy - NODE_H / 2.0;
270
271 let glow: SvgCircleElement = self
272 .document
273 .create_element_ns(Some(ns), "circle")
274 .unwrap()
275 .dyn_into()
276 .unwrap();
277 glow.set_attribute("cx", &format!("{:.1}", cx)).ok();
278 glow.set_attribute("cy", &format!("{:.1}", cy)).ok();
279 glow.set_attribute("r", "32").ok();
280 glow.set_attribute("fill", &color).ok();
281 glow.set_attribute("fill-opacity", "0.05").ok();
282 g.append_child(&glow).ok();
283
284 let rect: SvgRectElement = self
285 .document
286 .create_element_ns(Some(ns), "rect")
287 .unwrap()
288 .dyn_into()
289 .unwrap();
290 rect.set_attribute("x", &format!("{:.1}", rx)).ok();
291 rect.set_attribute("y", &format!("{:.1}", ry)).ok();
292 rect.set_attribute("width", &format!("{:.1}", NODE_W)).ok();
293 rect.set_attribute("height", &format!("{:.1}", NODE_H)).ok();
294 rect.set_attribute("rx", "8").ok();
295 rect.set_attribute("ry", "8").ok();
296 rect.set_attribute("fill", &color).ok();
297 rect.set_attribute("fill-opacity", "0.28").ok();
298 rect.set_attribute("stroke", &color).ok();
299 rect.set_attribute("stroke-width", "1.8").ok();
300 rect.set_attribute("stroke-opacity", "0.7").ok();
301 rect.style().set_property("transition", "all 200ms ease").ok();
302
303 let text: SvgTextElement = self
304 .document
305 .create_element_ns(Some(ns), "text")
306 .unwrap()
307 .dyn_into()
308 .unwrap();
309 text.set_attribute("x", &format!("{:.1}", cx)).ok();
310 text.set_attribute("y", &format!("{:.1}", cy - 2.0)).ok();
311 text.set_attribute("text-anchor", "middle").ok();
312 text.set_attribute("dominant-baseline", "middle").ok();
313 text.set_attribute("fill", "rgba(232,234,245,0.88)").ok();
314 text.set_attribute("font-size", "13").ok();
315 text.set_attribute("font-family", "Inter, system-ui, sans-serif").ok();
316 text.set_attribute("font-weight", "500").ok();
317 text.set_text_content(Some(&label));
318
319 let sub: SvgTextElement = self
320 .document
321 .create_element_ns(Some(ns), "text")
322 .unwrap()
323 .dyn_into()
324 .unwrap();
325 sub.set_attribute("x", &format!("{:.1}", cx)).ok();
326 sub.set_attribute("y", &format!("{:.1}", cy + 15.0)).ok();
327 sub.set_attribute("text-anchor", "middle").ok();
328 sub.set_attribute("dominant-baseline", "middle").ok();
329 sub.set_attribute("fill", "rgba(232,234,245,0.45)").ok();
330 sub.set_attribute("font-size", "9").ok();
331 sub.set_attribute("font-family", "Inter, system-ui, sans-serif").ok();
332 sub.set_attribute("letter-spacing", "0.25em").ok();
333 sub.set_text_content(Some(&camino_up));
334
335 g.append_child(&rect).ok();
336 g.append_child(&text).ok();
337 g.append_child(&sub).ok();
338
339 // Hover
340 let rect_clone = rect.clone();
341 let color_c = color.clone();
342 let glow_clone = glow.clone();
343 let enter = Closure::<dyn FnMut(MouseEvent)>::new(move |_| {
344 rect_clone.set_attribute("fill-opacity", "0.55").ok();
345 rect_clone.set_attribute("stroke-opacity", "1").ok();
346 rect_clone.style()
347 .set_property("filter", &format!("drop-shadow(0 0 12px {})", color_c))
348 .ok();
349 glow_clone.set_attribute("fill-opacity", "0.20").ok();
350 });
351 g.add_event_listener_with_callback("mouseenter", enter.as_ref().unchecked_ref()).ok();
352 enter.forget();
353
354 let rect_clone2 = rect.clone();
355 let glow_clone2 = glow.clone();
356 let leave = Closure::<dyn FnMut(MouseEvent)>::new(move |_| {
357 rect_clone2.set_attribute("fill-opacity", "0.28").ok();
358 rect_clone2.set_attribute("stroke-opacity", "0.7").ok();
359 rect_clone2.style().set_property("filter", "none").ok();
360 glow_clone2.set_attribute("fill-opacity", "0.05").ok();
361 });
362 g.add_event_listener_with_callback("mouseleave", leave.as_ref().unchecked_ref()).ok();
363 leave.forget();
364
365 let doc_id = node.doc_id.clone().unwrap_or_default();
366 let on_nav2 = on_nav.clone();
367 let click = Closure::<dyn FnMut(MouseEvent)>::new(move |_| {
368 let mut cb = on_nav2.borrow_mut();
369 if let Some(ref mut f) = *cb { f(doc_id.clone()); }
370 });
371 g.add_event_listener_with_callback("click", click.as_ref().unchecked_ref()).ok();
372 click.forget();
373
374 breathe_group.append_child(&g).ok();
375 }
376
377 svg.append_child(&breathe_group).ok();
378 self.container.append_child(&svg).ok();
379 }
380}
381
382// ─── Layout grid: 3 columnas ─────────────────────────────────────
383
384fn grid_layout(nodes: &[NodeData], w: f64, h: f64) -> Vec<(String, (f64, f64))> {
385 let n = nodes.len();
386 if n == 0 { return vec![]; }
387
388 let rows = (n + COLS - 1) / COLS;
389 let actual_rows = rows.max(3);
390 let margin_x = NODE_W / 2.0 + 20.0;
391 let margin_y = NODE_H / 2.0 + 20.0;
392 let usable_w = w - margin_x * 2.0;
393 let usable_h = h - margin_y * 2.0;
394 let col_gap = usable_w / (COLS as f64);
395 let row_gap = usable_h / (actual_rows as f64);
396
397 let mut out = Vec::with_capacity(n);
398 for (i, node) in nodes.iter().enumerate() {
399 let col = i % COLS;
400 let row = i / COLS;
401 let offset_x = if row == rows - 1 && n % COLS != 0 {
402 let remaining = n - row * COLS;
403 (usable_w - remaining as f64 * col_gap) / 2.0
404 } else { 0.0 };
405 let x = margin_x + offset_x + col as f64 * col_gap + col_gap / 2.0;
406 let y = margin_y + row as f64 * row_gap + row_gap / 2.0;
407 out.push((node.id.clone(), (x, y)));
408 }
409 out
410}