//! `gioser-graph-web` — widget de grafo semántico SVG inline. //! //! Layout grid: 3 columnas, filas según la cantidad de nodos. //! Los nodos son rectángulos redondeados con texto + subtexto (camino). //! Aristas se dibujan entre todos los pares con líneas semitransparentes: //! mientras mayor el weight, más opaca y brillante la línea. //! Animación CSS de respiración suave en el SVG. //! Hover: glow + opacidad. use std::cell::RefCell; use std::rc::Rc; use serde::Deserialize; use wasm_bindgen::prelude::*; use wasm_bindgen::JsCast; use wasm_bindgen_futures::JsFuture; use web_sys::{ Document, HtmlElement, MouseEvent, Response, SvgLineElement, SvgRectElement, SvgsvgElement, SvgTextElement, SvgCircleElement, }; pub(crate) fn document() -> Option { web_sys::window().and_then(|w| w.document()) } // ─── Tipos de respuesta de `/graph` ────────────────────────────── #[derive(Deserialize, Debug, Clone)] struct GraphResponse { nodes: Vec, edges: Vec, stats: GraphStats, } #[derive(Deserialize, Debug, Clone)] struct GraphNode { data: NodeData, } #[derive(Deserialize, Debug, Clone)] struct NodeData { id: String, name: String, camino: String, doc_id: Option, chunk: Option, tags: Option>, #[allow(dead_code)] preview: Option, } #[derive(Deserialize, Debug, Clone)] struct GraphEdge { data: EdgeData, } #[derive(Deserialize, Debug, Clone)] struct EdgeData { id: String, source: String, target: String, weight: Option, } #[derive(Deserialize, Debug, Clone)] struct GraphStats { points: u32, edges: u32, #[allow(dead_code)] by_camino: Option>, } // ─── Widget ────────────────────────────────────────────────────── type NavCallback = Rc>>>; const CANVAS_W: f64 = 800.0; const CANVAS_H: f64 = 420.0; const NODE_W: f64 = 170.0; // más grandes const NODE_H: f64 = 44.0; // más grandes const COLS: usize = 3; const CAMINO_COLORS: &[(&str, &str)] = &[ ("logos", "#d0dbff"), ("aire", "#d0dbff"), ("nomos", "#f59056"), ("fuego", "#f59056"), ("kay", "#d49873"), ("tierra", "#d49873"), ("uku", "#6cd0f3"), ("agua", "#6cd0f3"), ]; fn camino_color(camino: &str) -> &str { for (k, v) in CAMINO_COLORS { if *k == camino { return v; } } "#888888" } fn weight_alpha(w: Option) -> f64 { // weight 0.5 → 0.40, weight 1.0 → 0.85 match w { Some(v) => 0.40 + (v - 0.5).max(0.0) * 0.9, None => 0.35, } .clamp(0.15, 0.95) } fn weight_stroke_color(w: Option, base: &str) -> String { let alpha = weight_alpha(w); // Extraer color base, añadir alpha // Asumimos formato #rrggbb if base.len() >= 7 && alpha < 1.0 { format!("{}", base) // se sobreescribe con stroke-opacity } else { base.to_string() } } pub struct GraphWidget { container: HtmlElement, api_url: String, nodes: Vec, edges: Vec, on_navigate: NavCallback, document: Document, } impl GraphWidget { pub fn new( container: HtmlElement, api_url: &str, on_navigate: Option>, ) -> Self { let doc = crate::document().unwrap_or_else(|| { web_sys::window() .and_then(|w| w.document()) .expect("no document") }); Self { container, api_url: api_url.to_string(), nodes: Vec::new(), edges: Vec::new(), on_navigate: Rc::new(RefCell::new(on_navigate)), document: doc, } } pub async fn load(&mut self) -> Result<(), JsValue> { let url = format!("{}/graph?limit=500", self.api_url); let window = web_sys::window().ok_or_else(|| JsValue::from_str("no window"))?; let resp_value = JsFuture::from(window.fetch_with_str(&url)).await?; let resp: Response = resp_value.dyn_into()?; if !resp.ok() { return Err(JsValue::from_str(&format!("HTTP {}", resp.status()))); } let text = JsFuture::from(resp.text()?).await?; let body = text.as_string().unwrap_or_default(); let graph: GraphResponse = serde_json::from_str(&body).map_err(|e| JsValue::from_str(&format!("JSON: {e}")))?; let nodes: Vec = graph .nodes .into_iter() .map(|n| n.data) .filter(|n| n.doc_id.is_some()) .collect(); let edges: Vec = graph.edges.into_iter().map(|e| e.data).collect(); self.nodes = nodes; self.edges = edges; self.render(); Ok(()) } fn render(&self) { self.container.set_inner_html(""); if self.nodes.is_empty() { return; } let positions = grid_layout(&self.nodes, CANVAS_W, CANVAS_H); let ns = "http://www.w3.org/2000/svg"; let svg: SvgsvgElement = self .document .create_element_ns(Some(ns), "svg") .unwrap() .dyn_into() .unwrap(); svg.set_attribute("viewBox", &format!("0 0 {} {}", CANVAS_W as u32, CANVAS_H as u32)).ok(); svg.set_attribute("width", "100%").ok(); svg.set_attribute("height", &format!("{}px", CANVAS_H as u32)).ok(); svg.set_attribute("preserveAspectRatio", "xMidYMid meet").ok(); svg.style().set_property("display", "block").ok(); svg.style().set_property("margin", "1.5rem auto 0").ok(); svg.style().set_property("max-width", "100%").ok(); svg.style().set_property("height", "auto").ok(); svg.style().set_property("background", "rgba(255,255,255,0.02)").ok(); svg.style().set_property("border-radius", "12px").ok(); svg.style().set_property("border", "1px solid rgba(216,168,93,0.15)").ok(); // Estilo para animación de respiración en el SVG // Se añade un