tawasuyu

tawasuyu/brahman

Rust 332TOML 127JSON 17Markdown 154710 Sutichaykuna

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

- Replaced force-directed layout with explicit grid: 3 columns wide,
  rows based on node count (minimum 3 visual rows)
- Nodes are larger: 170x44px with 8px radius
- Lines: color brightness + stroke opacity based on normalized weight
  (weight 1.0 → white #ffff, 0.5 → dimmer rgb)
- CSS animation 'graph-breathe': opacity pulses slowly (4.2s ease-in-out)
- Hover: drop-shadow glow + fill-opacity increase
- Glow circle behind each node (subtle)

b5032de1e3c8c27ce375137584c4ed873a13bdfd · ruwaq Sergio · (5 killa ñawpaq) · tayta 7f7ba1fef · kay commitpi sach'ata qhaway

Semántico sayk'uy · 26

crates/apps/gioser-web/pkg/gioser_web.d.ts

crates/modules/gioser/gioser-graph-web/src/lib.rs

5 qillqakuna · +250 −246

diff --git a/crates/apps/gioser-web/pkg/gioser_web.d.ts b/crates/apps/gioser-web/pkg/gioser_web.d.ts
index d5a7121..d37c8a2 100644
--- a/crates/apps/gioser-web/pkg/gioser_web.d.ts
+++ b/crates/apps/gioser-web/pkg/gioser_web.d.ts
@@ -8,12 +8,12 @@ export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembl
 export interface InitOutput {
     readonly memory: WebAssembly.Memory;
     readonly boot: () => void;
+    readonly __wasm_bindgen_func_elem_219: (a: number, b: number, c: number) => void;
+    readonly __wasm_bindgen_func_elem_1408: (a: number, b: number, c: number, d: number) => void;
     readonly __wasm_bindgen_func_elem_218: (a: number, b: number, c: number) => void;
-    readonly __wasm_bindgen_func_elem_1398: (a: number, b: number, c: number, d: number) => void;
-    readonly __wasm_bindgen_func_elem_217: (a: number, b: number, c: number) => void;
-    readonly __wasm_bindgen_func_elem_217_3: (a: number, b: number, c: number) => void;
-    readonly __wasm_bindgen_func_elem_494: (a: number, b: number, c: number) => void;
-    readonly __wasm_bindgen_func_elem_593: (a: number, b: number, c: number) => void;
+    readonly __wasm_bindgen_func_elem_218_3: (a: number, b: number, c: number) => void;
+    readonly __wasm_bindgen_func_elem_496: (a: number, b: number, c: number) => void;
+    readonly __wasm_bindgen_func_elem_602: (a: number, b: number, c: number) => void;
     readonly __wasm_bindgen_func_elem_289: (a: number, b: number, c: number) => void;
     readonly __wasm_bindgen_func_elem_288: (a: number, b: number) => void;
     readonly __wbindgen_export: (a: number, b: number) => number;
diff --git a/crates/apps/gioser-web/pkg/gioser_web.js b/crates/apps/gioser-web/pkg/gioser_web.js
index 5a39956..df3c8bd 100644
--- a/crates/apps/gioser-web/pkg/gioser_web.js
+++ b/crates/apps/gioser-web/pkg/gioser_web.js
@@ -297,6 +297,16 @@ function __wbg_get_imports() {
             const ret = result;
             return ret;
         },
+        __wbg_instanceof_SvgCircleElement_b8f3b45ab1053e3e: function(arg0) {
+            let result;
+            try {
+                result = getObject(arg0) instanceof SVGCircleElement;
+            } catch (_) {
+                result = false;
+            }
+            const ret = result;
+            return ret;
+        },
         __wbg_instanceof_SvgElement_46537942d3e1376d: function(arg0) {
             let result;
             try {
@@ -560,33 +570,33 @@ function __wbg_get_imports() {
             return ret;
         },
         __wbindgen_cast_0000000000000001: function(arg0, arg1) {
-            // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 176, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
-            const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_1398);
+            // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 179, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
+            const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_1408);
             return addHeapObject(ret);
         },
         __wbindgen_cast_0000000000000002: function(arg0, arg1) {
             // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [F64], shim_idx: 2, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
-            const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_218);
+            const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_219);
             return addHeapObject(ret);
         },
         __wbindgen_cast_0000000000000003: function(arg0, arg1) {
             // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 6, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
-            const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_217);
+            const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_218);
             return addHeapObject(ret);
         },
         __wbindgen_cast_0000000000000004: function(arg0, arg1) {
             // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("KeyboardEvent")], shim_idx: 6, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
-            const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_217_3);
+            const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_218_3);
             return addHeapObject(ret);
         },
         __wbindgen_cast_0000000000000005: function(arg0, arg1) {
             // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("MouseEvent")], shim_idx: 137, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
-            const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_494);
+            const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_496);
             return addHeapObject(ret);
         },
         __wbindgen_cast_0000000000000006: function(arg0, arg1) {
-            // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("MouseEvent")], shim_idx: 170, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
-            const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_593);
+            // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("MouseEvent")], shim_idx: 173, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
+            const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_602);
             return addHeapObject(ret);
         },
         __wbindgen_cast_0000000000000007: function(arg0, arg1) {
@@ -627,30 +637,30 @@ function __wasm_bindgen_func_elem_288(arg0, arg1) {
     wasm.__wasm_bindgen_func_elem_288(arg0, arg1);
 }
 
-function __wasm_bindgen_func_elem_217(arg0, arg1, arg2) {
-    wasm.__wasm_bindgen_func_elem_217(arg0, arg1, addHeapObject(arg2));
+function __wasm_bindgen_func_elem_218(arg0, arg1, arg2) {
+    wasm.__wasm_bindgen_func_elem_218(arg0, arg1, addHeapObject(arg2));
 }
 
-function __wasm_bindgen_func_elem_217_3(arg0, arg1, arg2) {
-    wasm.__wasm_bindgen_func_elem_217_3(arg0, arg1, addHeapObject(arg2));
+function __wasm_bindgen_func_elem_218_3(arg0, arg1, arg2) {
+    wasm.__wasm_bindgen_func_elem_218_3(arg0, arg1, addHeapObject(arg2));
 }
 
-function __wasm_bindgen_func_elem_494(arg0, arg1, arg2) {
-    wasm.__wasm_bindgen_func_elem_494(arg0, arg1, addHeapObject(arg2));
+function __wasm_bindgen_func_elem_496(arg0, arg1, arg2) {
+    wasm.__wasm_bindgen_func_elem_496(arg0, arg1, addHeapObject(arg2));
 }
 
-function __wasm_bindgen_func_elem_593(arg0, arg1, arg2) {
-    wasm.__wasm_bindgen_func_elem_593(arg0, arg1, addHeapObject(arg2));
+function __wasm_bindgen_func_elem_602(arg0, arg1, arg2) {
+    wasm.__wasm_bindgen_func_elem_602(arg0, arg1, addHeapObject(arg2));
 }
 
 function __wasm_bindgen_func_elem_289(arg0, arg1, arg2) {
     wasm.__wasm_bindgen_func_elem_289(arg0, arg1, addHeapObject(arg2));
 }
 
-function __wasm_bindgen_func_elem_1398(arg0, arg1, arg2) {
+function __wasm_bindgen_func_elem_1408(arg0, arg1, arg2) {
     try {
         const retptr = wasm.__wbindgen_add_to_stack_pointer(-16);
-        wasm.__wasm_bindgen_func_elem_1398(retptr, arg0, arg1, addHeapObject(arg2));
+        wasm.__wasm_bindgen_func_elem_1408(retptr, arg0, arg1, addHeapObject(arg2));
         var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true);
         var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true);
         if (r1) {
@@ -661,8 +671,8 @@ function __wasm_bindgen_func_elem_1398(arg0, arg1, arg2) {
     }
 }
 
-function __wasm_bindgen_func_elem_218(arg0, arg1, arg2) {
-    wasm.__wasm_bindgen_func_elem_218(arg0, arg1, arg2);
+function __wasm_bindgen_func_elem_219(arg0, arg1, arg2) {
+    wasm.__wasm_bindgen_func_elem_219(arg0, arg1, arg2);
 }
 
 function addHeapObject(obj) {
diff --git a/crates/apps/gioser-web/pkg/gioser_web_bg.wasm b/crates/apps/gioser-web/pkg/gioser_web_bg.wasm
index 49ad85b..310d68e 100644
Binary files a/crates/apps/gioser-web/pkg/gioser_web_bg.wasm and b/crates/apps/gioser-web/pkg/gioser_web_bg.wasm differ
diff --git a/crates/apps/gioser-web/pkg/gioser_web_bg.wasm.d.ts b/crates/apps/gioser-web/pkg/gioser_web_bg.wasm.d.ts
index af910e6..ea09953 100644
--- a/crates/apps/gioser-web/pkg/gioser_web_bg.wasm.d.ts
+++ b/crates/apps/gioser-web/pkg/gioser_web_bg.wasm.d.ts
@@ -2,12 +2,12 @@
 /* eslint-disable */
 export const memory: WebAssembly.Memory;
 export const boot: () => void;
+export const __wasm_bindgen_func_elem_219: (a: number, b: number, c: number) => void;
+export const __wasm_bindgen_func_elem_1408: (a: number, b: number, c: number, d: number) => void;
 export const __wasm_bindgen_func_elem_218: (a: number, b: number, c: number) => void;
-export const __wasm_bindgen_func_elem_1398: (a: number, b: number, c: number, d: number) => void;
-export const __wasm_bindgen_func_elem_217: (a: number, b: number, c: number) => void;
-export const __wasm_bindgen_func_elem_217_3: (a: number, b: number, c: number) => void;
-export const __wasm_bindgen_func_elem_494: (a: number, b: number, c: number) => void;
-export const __wasm_bindgen_func_elem_593: (a: number, b: number, c: number) => void;
+export const __wasm_bindgen_func_elem_218_3: (a: number, b: number, c: number) => void;
+export const __wasm_bindgen_func_elem_496: (a: number, b: number, c: number) => void;
+export const __wasm_bindgen_func_elem_602: (a: number, b: number, c: number) => void;
 export const __wasm_bindgen_func_elem_289: (a: number, b: number, c: number) => void;
 export const __wasm_bindgen_func_elem_288: (a: number, b: number) => void;
 export const __wbindgen_export: (a: number, b: number) => number;
diff --git a/crates/modules/gioser/gioser-graph-web/src/lib.rs b/crates/modules/gioser/gioser-graph-web/src/lib.rs
index b089b4d..aad4d8c 100644
--- a/crates/modules/gioser/gioser-graph-web/src/lib.rs
+++ b/crates/modules/gioser/gioser-graph-web/src/lib.rs
@@ -1,16 +1,11 @@
 //! `gioser-graph-web` — widget de grafo semántico SVG inline.
 //!
-//! Fetchea `GET /graph` de la API de gioser, parsea nodos + aristas,
-//! y renderiza un grafo SVG interactivo dentro de un contenedor dado.
-//!
-//! Los nodos son **rectángulos redondeados** horizontales con el texto
-//! dentro (no círculos) para mejor legibilidad. Las aristas varían en
-//! grosor según la intensidad semántica (k-NN weight).
-//!
-//! ## Contrato DOM
-//!
-//! El caller pasa un `<div>` contenedor y un callback `on_navigate(doc_id)`.
-//! El widget monta un `<svg>` dentro con viewBox fijo.
+//! 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;
@@ -21,7 +16,7 @@ use wasm_bindgen::JsCast;
 use wasm_bindgen_futures::JsFuture;
 use web_sys::{
     Document, HtmlElement, MouseEvent, Response, SvgLineElement, SvgRectElement,
-    SvgsvgElement, SvgTextElement,
+    SvgsvgElement, SvgTextElement, SvgCircleElement,
 };
 
 pub(crate) fn document() -> Option<Document> {
@@ -79,37 +74,49 @@ struct GraphStats {
 
 type NavCallback = Rc<RefCell<Option<Box<dyn FnMut(String)>>>>;
 
-const CANVAS_W: f64 = 600.0;
-const CANVAS_H: f64 = 270.0;
-/// Ancho del rectángulo nodo (horizontal para texto largo).
-const NODE_W: f64 = 120.0;
-/// Alto del rectángulo nodo.
-const NODE_H: f64 = 28.0;
+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"),
+    ("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;
-        }
+        if *k == camino { return v; }
     }
     "#888888"
 }
 
+fn weight_alpha(w: Option<f64>) -> 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<f64>, 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,
-    svg: Option<SvgsvgElement>,
     nodes: Vec<NodeData>,
     edges: Vec<EdgeData>,
     on_navigate: NavCallback,
@@ -130,7 +137,6 @@ impl GraphWidget {
         Self {
             container,
             api_url: api_url.to_string(),
-            svg: None,
             nodes: Vec::new(),
             edges: Vec::new(),
             on_navigate: Rc::new(RefCell::new(on_navigate)),
@@ -164,10 +170,6 @@ impl GraphWidget {
         self.nodes = nodes;
         self.edges = edges;
 
-        // Pequeño delay para evitar "Layout was forced before fully loaded"
-        let _ = js_sys::Promise::resolve(&JsValue::NULL);
-        let mut_self = &*self as *const GraphWidget;
-        // Render síncrono, el delay no es necesario pero mantenemos la deferencia.
         self.render();
         Ok(())
     }
@@ -179,7 +181,7 @@ impl GraphWidget {
             return;
         }
 
-        let positions = force_layout(&self.nodes, &self.edges, CANVAS_W, CANVAS_H);
+        let positions = grid_layout(&self.nodes, CANVAS_W, CANVAS_H);
 
         let ns = "http://www.w3.org/2000/svg";
         let svg: SvgsvgElement = self
@@ -188,293 +190,285 @@ impl GraphWidget {
             .unwrap()
             .dyn_into()
             .unwrap();
-        svg.set_attribute("viewBox", &format!("0 0 {} {}", CANVAS_W as u32, CANVAS_H as u32))
-            .ok();
+        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("background", "rgba(255,255,255,0.02)")
-            .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();
+        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 <style> dentro del SVG
+        let style_el = self.document.create_element_ns(Some(ns), "style").unwrap();
+        style_el.set_text_content(Some(
+            "@keyframes graph-breathe {\
+              0%, 100% { opacity: 1; }\
+              50% { opacity: 0.94; }\
+            }\
+            .gb-svg {\
+              animation: graph-breathe 4.2s ease-in-out infinite;\
+            }\
+            .gb-node {\
+              transition: filter 250ms ease, opacity 200ms ease;\
+            }\
+            .gb-node:hover {\
+              filter: drop-shadow(0 0 14px rgba(255,255,255,0.2));\
+            }\
+            .gb-line {\
+              transition: opacity 300ms ease;\
+            }\
+            .gb-line:hover {\
+              opacity: 0.9 !important;\
+            }",
+        ));
+        svg.append_child(&style_el).ok();
+
+        // Grupo con animación de respiración
+        let breathe_group: web_sys::SvgElement = self
+            .document
+            .create_element_ns(Some(ns), "g")
+            .unwrap()
+            .dyn_into()
+            .unwrap();
+        breathe_group.set_attribute("class", "gb-svg").ok();
 
-        // ── Aristas con grosor proporcional al weight ──
+        // Mapa: doc_id → (x, y)
+        let pos_map: std::collections::HashMap<&str, (f64, f64)> = positions
+            .iter()
+            .map(|(id, p)| (id.as_str(), *p))
+            .collect();
+
+        // Calcular max weight para normalizar
+        let max_w = self.edges.iter()
+            .filter_map(|e| e.weight)
+            .fold(0.0_f64, f64::max)
+            .max(0.5);
+
+        // ── Aristas ──
+        // Agrupar por par no dirigido (source, target) para no duplicar
+        let mut drawn = std::collections::HashSet::new();
         for edge in &self.edges {
-            let src_pos = positions.iter().find(|(id, _)| *id == edge.source);
-            let tgt_pos = positions.iter().find(|(id, _)| *id == edge.target);
-            if let (Some((_, (x1, y1))), Some((_, (x2, y2)))) = (src_pos, tgt_pos) {
+            let key = if edge.source < edge.target {
+                (edge.source.as_str(), edge.target.as_str())
+            } else {
+                (edge.target.as_str(), edge.source.as_str())
+            };
+            if !drawn.insert(key) { continue; }
+
+            let src_pos = pos_map.get(edge.source.as_str());
+            let tgt_pos = pos_map.get(edge.target.as_str());
+            if let (Some((x1, y1)), Some((x2, y2))) = (src_pos, tgt_pos) {
                 let line: SvgLineElement = self
                     .document
                     .create_element_ns(Some(ns), "line")
                     .unwrap()
                     .dyn_into()
                     .unwrap();
+
+                // Calcular opacidad basada en weight
+                let w = edge.weight.unwrap_or(0.7);
+                let norm_w = (w / max_w).clamp(0.0, 1.0);
+                let alpha = 0.15 + norm_w * 0.70; // 0.15 a 0.85
+
+                // Calcular grosor: 1 a 5 según weight normalizado
+                let sw = 1.0 + norm_w * 4.0;
+
+                // Color: más brillante = más blanco
+                let r = (255.0 - (1.0 - norm_w) * 80.0) as u32;
+                let g = (255.0 - (1.0 - norm_w) * 60.0) as u32;
+                let b = (255.0 - (1.0 - norm_w) * 40.0) as u32;
+
                 line.set_attribute("x1", &format!("{:.1}", x1)).ok();
                 line.set_attribute("y1", &format!("{:.1}", y1)).ok();
                 line.set_attribute("x2", &format!("{:.1}", x2)).ok();
                 line.set_attribute("y2", &format!("{:.1}", y2)).ok();
+                line.set_attribute("stroke", &format!("#{:02x}{:02x}{:02x}", r, g, b)).ok();
+                line.set_attribute("stroke-width", &format!("{:.1}", sw)).ok();
+                line.set_attribute("stroke-opacity", &format!("{:.2}", alpha)).ok();
+                line.set_attribute("class", "gb-line").ok();
 
-                // Grosor según peso: 0.5→1, 1.0→4 (clamped)
-                let sw = edge
-                    .weight
-                    .map(|w| 0.5 + (w - 0.5) * 6.0)
-                    .unwrap_or(1.0);
-                line.set_attribute("stroke", "rgba(255,255,255,0.20)").ok();
-                line.set_attribute("stroke-width", &format!("{:.1}", sw.clamp(0.5, 5.0)))
-                    .ok();
-
-                svg.append_child(&line).ok();
+                breathe_group.append_child(&line).ok();
             }
         }
 
-        // ── Nodos como rectángulos con texto dentro ──
+        // ── Nodos ──
         let on_nav = self.on_navigate.clone();
-        let ns_local = ns; // copy for closure captures
         for (i, node) in self.nodes.iter().enumerate() {
-            let (cx, cy) = positions.get(i).map(|(_, p)| *p).unwrap_or((100.0, 100.0));
+            let Some((cx, cy)) = positions.get(i).map(|(_, p)| *p) else { continue; };
             let color = camino_color(&node.camino).to_string();
-            let label = if node.name.len() > 18 {
-                format!("{}…", &node.name[..16])
+            let label = if node.name.len() > 20 {
+                format!("{}…", &node.name[..18])
             } else {
                 node.name.clone()
             };
             let camino_up = node.camino.to_uppercase();
 
-            // Grupo contenedor (para hover + click)
             let g: web_sys::SvgElement = self
                 .document
-                .create_element_ns(Some(ns_local), "g")
+                .create_element_ns(Some(ns), "g")
                 .unwrap()
                 .dyn_into()
                 .unwrap();
             g.style().set_property("cursor", "pointer").ok();
+            g.set_attribute("class", "gb-node").ok();
             g.set_attribute("title", &format!("{} — {}", node.name, camino_up)).ok();
 
-            // Rectángulo redondeado
+            let rx = cx - NODE_W / 2.0;
+            let ry = cy - NODE_H / 2.0;
+
+            // Sombra suave de fondo (círculo/glow detrás del rect)
+            let glow: SvgCircleElement = self
+                .document
+                .create_element_ns(Some(ns), "circle")
+                .unwrap()
+                .dyn_into()
+                .unwrap();
+            glow.set_attribute("cx", &format!("{:.1}", cx)).ok();
+            glow.set_attribute("cy", &format!("{:.1}", cy)).ok();
+            glow.set_attribute("r", "30").ok();
+            glow.set_attribute("fill", &color).ok();
+            glow.set_attribute("fill-opacity", "0.06").ok();
+            g.append_child(&glow).ok();
+
             let rect: SvgRectElement = self
                 .document
-                .create_element_ns(Some(ns_local), "rect")
+                .create_element_ns(Some(ns), "rect")
                 .unwrap()
                 .dyn_into()
                 .unwrap();
-            let rx = cx - NODE_W / 2.0;
-            let ry = cy - NODE_H / 2.0;
             rect.set_attribute("x", &format!("{:.1}", rx)).ok();
             rect.set_attribute("y", &format!("{:.1}", ry)).ok();
             rect.set_attribute("width", &format!("{:.1}", NODE_W)).ok();
             rect.set_attribute("height", &format!("{:.1}", NODE_H)).ok();
-            rect.set_attribute("rx", "6").ok();
-            rect.set_attribute("ry", "6").ok();
+            rect.set_attribute("rx", "8").ok();
+            rect.set_attribute("ry", "8").ok();
             rect.set_attribute("fill", &color).ok();
-            rect.set_attribute("fill-opacity", "0.25").ok();
+            rect.set_attribute("fill-opacity", "0.28").ok();
             rect.set_attribute("stroke", &color).ok();
-            rect.set_attribute("stroke-width", "1.5").ok();
+            rect.set_attribute("stroke-width", "1.8").ok();
+            rect.set_attribute("stroke-opacity", "0.7").ok();
             rect.style().set_property("transition", "all 200ms ease").ok();
-            rect.style()
-                .set_property("filter", "drop-shadow(0 0 4px rgba(255,255,255,0.06))")
-                .ok();
 
-            // Texto dentro del rectángulo
             let text: SvgTextElement = self
                 .document
-                .create_element_ns(Some(ns_local), "text")
+                .create_element_ns(Some(ns), "text")
                 .unwrap()
                 .dyn_into()
                 .unwrap();
             text.set_attribute("x", &format!("{:.1}", cx)).ok();
-            text.set_attribute("y", &format!("{:.1}", cy + 5.0)).ok();
+            text.set_attribute("y", &format!("{:.1}", cy - 2.0)).ok();
             text.set_attribute("text-anchor", "middle").ok();
             text.set_attribute("dominant-baseline", "middle").ok();
-            text.set_attribute("fill", "rgba(232,234,245,0.85)").ok();
-            text.set_attribute("font-size", "12").ok();
+            text.set_attribute("fill", "rgba(232,234,245,0.88)").ok();
+            text.set_attribute("font-size", "13").ok();
             text.set_attribute("font-family", "Inter, system-ui, sans-serif").ok();
             text.set_attribute("font-weight", "500").ok();
             text.set_text_content(Some(&label));
 
-            // Subtexto (camino) más pequeño debajo
             let sub: SvgTextElement = self
                 .document
-                .create_element_ns(Some(ns_local), "text")
+                .create_element_ns(Some(ns), "text")
                 .unwrap()
                 .dyn_into()
                 .unwrap();
             sub.set_attribute("x", &format!("{:.1}", cx)).ok();
-            sub.set_attribute("y", &format!("{:.1}", cy + 19.0)).ok();
+            sub.set_attribute("y", &format!("{:.1}", cy + 15.0)).ok();
             sub.set_attribute("text-anchor", "middle").ok();
             sub.set_attribute("dominant-baseline", "middle").ok();
-            sub.set_attribute("fill", "rgba(232,234,245,0.40)").ok();
-            sub.set_attribute("font-size", "8").ok();
+            sub.set_attribute("fill", "rgba(232,234,245,0.45)").ok();
+            sub.set_attribute("font-size", "9").ok();
             sub.set_attribute("font-family", "Inter, system-ui, sans-serif").ok();
-            sub.set_attribute("letter-spacing", "0.3em").ok();
+            sub.set_attribute("letter-spacing", "0.25em").ok();
             sub.set_text_content(Some(&camino_up));
 
             g.append_child(&rect).ok();
             g.append_child(&text).ok();
             g.append_child(&sub).ok();
 
-            // Hover: opacidad más alta
+            // Hover glow
             let rect_clone = rect.clone();
             let color_c = color.clone();
+            let glow_clone = glow.clone();
             let enter = Closure::<dyn FnMut(MouseEvent)>::new(move |_| {
-                rect_clone.set_attribute("fill-opacity", "0.50").ok();
+                rect_clone.set_attribute("fill-opacity", "0.55").ok();
+                rect_clone.set_attribute("stroke-opacity", "1").ok();
                 rect_clone
                     .style()
-                    .set_property(
-                        "filter",
-                        &format!("drop-shadow(0 0 10px {})", color_c),
-                    )
+                    .set_property("filter", &format!("drop-shadow(0 0 12px {})", color_c))
                     .ok();
+                glow_clone.set_attribute("fill-opacity", "0.20").ok();
             });
-            g.add_event_listener_with_callback("mouseenter", enter.as_ref().unchecked_ref())
-                .ok();
+            g.add_event_listener_with_callback("mouseenter", enter.as_ref().unchecked_ref()).ok();
             enter.forget();
 
             let rect_clone2 = rect.clone();
+            let glow_clone2 = glow.clone();
             let leave = Closure::<dyn FnMut(MouseEvent)>::new(move |_| {
-                rect_clone2
-                    .set_attribute("fill-opacity", "0.25")
-                    .ok();
-                rect_clone2
-                    .style()
-                    .set_property("filter", "drop-shadow(0 0 4px rgba(255,255,255,0.06))")
-                    .ok();
+                rect_clone2.set_attribute("fill-opacity", "0.28").ok();
+                rect_clone2.set_attribute("stroke-opacity", "0.7").ok();
+                rect_clone2.style().set_property("filter", "none").ok();
+                glow_clone2.set_attribute("fill-opacity", "0.06").ok();
             });
-            g.add_event_listener_with_callback("mouseleave", leave.as_ref().unchecked_ref())
-                .ok();
+            g.add_event_listener_with_callback("mouseleave", leave.as_ref().unchecked_ref()).ok();
             leave.forget();
 
-            // Click
             let doc_id = node.doc_id.clone().unwrap_or_default();
             let on_nav2 = on_nav.clone();
             let click = Closure::<dyn FnMut(MouseEvent)>::new(move |_| {
                 let mut cb = on_nav2.borrow_mut();
-                if let Some(ref mut f) = *cb {
-                    f(doc_id.clone());
-                }
+                if let Some(ref mut f) = *cb { f(doc_id.clone()); }
             });
-            g.add_event_listener_with_callback("click", click.as_ref().unchecked_ref())
-                .ok();
+            g.add_event_listener_with_callback("click", click.as_ref().unchecked_ref()).ok();
             click.forget();
 
-            svg.append_child(&g).ok();
+            breathe_group.append_child(&g).ok();
         }
 
+        svg.append_child(&breathe_group).ok();
         self.container.append_child(&svg).ok();
     }
 }
 
-// ─── Force-directed layout (Fruchterman-Reingold) ────────────────
+// ─── Layout grid: 3 columnas, filas según número de nodos ──────────
 
-fn force_layout(
-    nodes: &[NodeData],
-    edges: &[EdgeData],
-    w: f64,
-    h: f64,
-) -> Vec<(String, (f64, f64))> {
+fn grid_layout(nodes: &[NodeData], w: f64, h: f64) -> Vec<(String, (f64, f64))> {
     let n = nodes.len();
-    if n == 0 {
-        return vec![];
-    }
-
-    let area = w * h;
-    let k = (area / (n as f64)).sqrt() * 1.6; // más separación
-
-    let cx = w / 2.0;
-    let cy = h / 2.0;
-    let radius = (w.min(h) * 0.30).max(60.0);
-    let mut positions: Vec<(f64, f64)> = nodes
-        .iter()
-        .enumerate()
-        .map(|(i, _)| {
-            let angle = 2.0 * std::f64::consts::PI * (i as f64) / (n as f64)
-                - std::f64::consts::PI / 2.0;
-            (cx + radius * angle.cos(), cy + radius * angle.sin())
-        })
-        .collect();
-
-    let id_to_idx: std::collections::HashMap<&str, usize> = nodes
-        .iter()
-        .enumerate()
-        .map(|(i, node)| (node.doc_id.as_deref().unwrap_or(""), i))
-        .filter(|(id, _)| !id.is_empty())
-        .collect();
-
-    let mut adj: Vec<Vec<usize>> = vec![vec![]; n];
-    for e in edges {
-        if let (Some(&si), Some(&ti)) =
-            (id_to_idx.get(e.source.as_str()), id_to_idx.get(e.target.as_str()))
-        {
-            if !adj[si].contains(&ti) {
-                adj[si].push(ti);
-            }
-            if !adj[ti].contains(&si) {
-                adj[ti].push(si);
-            }
-        }
-    }
-
-    let iterations = 80;
-    let temp_init = w.max(h) / 5.0;
-    let mut disp: Vec<(f64, f64)> = vec![(0.0, 0.0); n];
-    let half_w = NODE_W / 2.0 + 6.0;
-    let half_h = NODE_H / 2.0 + 4.0;
-
-    for iter in 0..iterations {
-        let temp = temp_init * (1.0 - (iter as f64) / (iterations as f64));
-
-        for d in disp.iter_mut() {
-            *d = (0.0, 0.0);
-        }
-
-        // Repulsión
-        for i in 0..n {
-            for j in (i + 1)..n {
-                let dx = positions[i].0 - positions[j].0;
-                let dy = positions[i].1 - positions[j].1;
-                let dist = (dx * dx + dy * dy).sqrt().max(1.0);
-                let force = k * k / dist;
-                disp[i].0 += force * dx / dist;
-                disp[i].1 += force * dy / dist;
-                disp[j].0 -= force * dx / dist;
-                disp[j].1 -= force * dy / dist;
-            }
-        }
-
-        // Atracción en aristas
-        for i in 0..n {
-            for &j in &adj[i] {
-                let dx = positions[j].0 - positions[i].0;
-                let dy = positions[j].1 - positions[i].1;
-                let dist = (dx * dx + dy * dy).sqrt().max(1.0);
-                let force = dist * dist / k;
-                disp[i].0 += force * dx / dist;
-                disp[i].1 += force * dy / dist;
-                disp[j].0 -= force * dx / dist;
-                disp[j].1 -= force * dy / dist;
-            }
-        }
-
-        // Aplicar
-        for i in 0..n {
-            let d = (disp[i].0 * disp[i].0 + disp[i].1 * disp[i].1)
-                .sqrt()
-                .max(0.001);
-            let step_x = (disp[i].0 / d * temp).clamp(-temp, temp);
-            let step_y = (disp[i].1 / d * temp).clamp(-temp, temp);
-            let new_x = (positions[i].0 + step_x).clamp(half_w, w - half_w);
-            let new_y = (positions[i].1 + step_y).clamp(half_h, h - half_h);
-            positions[i] = (new_x, new_y);
-        }
+    if n == 0 { return vec![]; }
+
+    let rows = (n + COLS - 1) / COLS;  // ceiling div
+    let actual_rows = rows.max(3); // mínimo 3 filas visuales
+    let margin_x = NODE_W / 2.0 + 20.0;
+    let margin_y = NODE_H / 2.0 + 16.0;
+
+    let usable_w = w - margin_x * 2.0;
+    let usable_h = h - margin_y * 2.0;
+    let col_gap = usable_w / (COLS as f64);
+    let row_gap = usable_h / (actual_rows as f64);
+
+    let mut positions = Vec::with_capacity(n);
+    for (i, node) in nodes.iter().enumerate() {
+        let col = i % COLS;
+        let row = i / COLS;
+        // Centrar si en última fila hay menos de 3
+        let offset_x = if row == rows - 1 && n % COLS != 0 {
+            // centrar fila
+            let remaining = n - row * COLS;
+            (usable_w - remaining as f64 * col_gap) / 2.0
+        } else {
+            0.0
+        };
+        let x = margin_x + offset_x + col as f64 * col_gap + col_gap / 2.0;
+        let y = margin_y + row as f64 * row_gap + row_gap / 2.0;
+        // Desplazar cada fila un tercio para dar sensación de expansión vertical
+        let y_adj = y; // ya está bien con el grid
+
+        positions.push((node.doc_id.clone().unwrap_or_default(), (x, y_adj)));
     }
 
-    nodes
-        .iter()
-        .zip(positions.into_iter())
-        .map(|(n, pos)| (n.doc_id.clone().unwrap_or_default(), pos))
-        .collect()
+    positions
 }