diff --git a/crates/apps/gioser-web/pkg/gioser_web.d.ts b/crates/apps/gioser-web/pkg/gioser_web.d.ts index 33788c5..d5a7121 100644 --- a/crates/apps/gioser-web/pkg/gioser_web.d.ts +++ b/crates/apps/gioser-web/pkg/gioser_web.d.ts @@ -8,13 +8,13 @@ export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembl export interface InitOutput { readonly memory: WebAssembly.Memory; readonly boot: () => 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_1396: (a: number, b: number, c: number, d: number) => void; - readonly __wasm_bindgen_func_elem_216: (a: number, b: number, c: number) => void; - readonly __wasm_bindgen_func_elem_216_3: (a: number, b: number, c: number) => void; - readonly __wasm_bindgen_func_elem_493: (a: number, b: number, c: number) => void; - readonly __wasm_bindgen_func_elem_592: (a: number, b: number, c: number) => void; - readonly __wasm_bindgen_func_elem_287: (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_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; readonly __wbindgen_export2: (a: number, b: number, c: number, d: number) => number; diff --git a/crates/apps/gioser-web/pkg/gioser_web.js b/crates/apps/gioser-web/pkg/gioser_web.js index 53aea11..5a39956 100644 --- a/crates/apps/gioser-web/pkg/gioser_web.js +++ b/crates/apps/gioser-web/pkg/gioser_web.js @@ -297,10 +297,10 @@ function __wbg_get_imports() { const ret = result; return ret; }, - __wbg_instanceof_SvgCircleElement_b8f3b45ab1053e3e: function(arg0) { + __wbg_instanceof_SvgElement_46537942d3e1376d: function(arg0) { let result; try { - result = getObject(arg0) instanceof SVGCircleElement; + result = getObject(arg0) instanceof SVGElement; } catch (_) { result = false; } @@ -317,6 +317,16 @@ function __wbg_get_imports() { const ret = result; return ret; }, + __wbg_instanceof_SvgRectElement_f5a06e74af743100: function(arg0) { + let result; + try { + result = getObject(arg0) instanceof SVGRectElement; + } catch (_) { + result = false; + } + const ret = result; + return ret; + }, __wbg_instanceof_SvgTextElement_06345cd3cc71c951: function(arg0) { let result; try { @@ -551,37 +561,37 @@ function __wbg_get_imports() { }, __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_1396); + const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_1398); 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_217); + const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_218); 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_216); + const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_217); 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_216_3); + const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_217_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_493); + const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_494); 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_592); + const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_593); return addHeapObject(ret); }, __wbindgen_cast_0000000000000007: function(arg0, arg1) { // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("PointerEvent")], shim_idx: 67, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. - const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_287); + const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_289); return addHeapObject(ret); }, __wbindgen_cast_0000000000000008: function(arg0, arg1) { @@ -617,30 +627,30 @@ function __wasm_bindgen_func_elem_288(arg0, arg1) { wasm.__wasm_bindgen_func_elem_288(arg0, arg1); } -function __wasm_bindgen_func_elem_216(arg0, arg1, arg2) { - wasm.__wasm_bindgen_func_elem_216(arg0, arg1, addHeapObject(arg2)); +function __wasm_bindgen_func_elem_217(arg0, arg1, arg2) { + wasm.__wasm_bindgen_func_elem_217(arg0, arg1, addHeapObject(arg2)); } -function __wasm_bindgen_func_elem_216_3(arg0, arg1, arg2) { - wasm.__wasm_bindgen_func_elem_216_3(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_493(arg0, arg1, arg2) { - wasm.__wasm_bindgen_func_elem_493(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_592(arg0, arg1, arg2) { - wasm.__wasm_bindgen_func_elem_592(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_287(arg0, arg1, arg2) { - wasm.__wasm_bindgen_func_elem_287(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_1396(arg0, arg1, arg2) { +function __wasm_bindgen_func_elem_1398(arg0, arg1, arg2) { try { const retptr = wasm.__wbindgen_add_to_stack_pointer(-16); - wasm.__wasm_bindgen_func_elem_1396(retptr, arg0, arg1, addHeapObject(arg2)); + wasm.__wasm_bindgen_func_elem_1398(retptr, arg0, arg1, addHeapObject(arg2)); var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true); var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true); if (r1) { @@ -651,8 +661,8 @@ function __wasm_bindgen_func_elem_1396(arg0, arg1, arg2) { } } -function __wasm_bindgen_func_elem_217(arg0, arg1, arg2) { - wasm.__wasm_bindgen_func_elem_217(arg0, arg1, arg2); +function __wasm_bindgen_func_elem_218(arg0, arg1, arg2) { + wasm.__wasm_bindgen_func_elem_218(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 7ec44a6..49ad85b 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 762f33e..af910e6 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,13 +2,13 @@ /* eslint-disable */ export const memory: WebAssembly.Memory; export const boot: () => 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_1396: (a: number, b: number, c: number, d: number) => void; -export const __wasm_bindgen_func_elem_216: (a: number, b: number, c: number) => void; -export const __wasm_bindgen_func_elem_216_3: (a: number, b: number, c: number) => void; -export const __wasm_bindgen_func_elem_493: (a: number, b: number, c: number) => void; -export const __wasm_bindgen_func_elem_592: (a: number, b: number, c: number) => void; -export const __wasm_bindgen_func_elem_287: (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_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; export const __wbindgen_export2: (a: number, b: number, c: number, d: number) => number; diff --git a/crates/apps/gioser-web/styles.css b/crates/apps/gioser-web/styles.css index c53ea2b..dd1fb29 100644 --- a/crates/apps/gioser-web/styles.css +++ b/crates/apps/gioser-web/styles.css @@ -237,21 +237,21 @@ body.deck-active-tierra .deck { --deck-glow: rgba(212, 152, 115, 0.24); } radial-gradient(circle at 18% 22%, rgba(208, 219, 255, 0.20), transparent 38%), radial-gradient(circle at 78% 68%, rgba(208, 219, 255, 0.14), transparent 40%), radial-gradient(circle at 45% 90%, rgba(180, 200, 255, 0.10), transparent 45%); - animation: aire-drift 28s ease-in-out infinite alternate; + animation: aire-drift 45s ease-in-out infinite alternate; } .deck-page[data-element="fuego"] .page-ambience { background: radial-gradient(circle at 50% 100%, rgba(245, 144, 86, 0.35), transparent 55%), radial-gradient(circle at 25% 80%, rgba(255, 90, 40, 0.18), transparent 35%), radial-gradient(circle at 80% 85%, rgba(255, 140, 60, 0.18), transparent 35%); - animation: fuego-flicker 5s ease-in-out infinite; + animation: fuego-flicker 12s ease-in-out infinite; } .deck-page[data-element="agua"] .page-ambience { background: radial-gradient(ellipse at 50% 95%, rgba(60, 160, 230, 0.30), transparent 60%), radial-gradient(ellipse at 20% 70%, rgba(108, 208, 243, 0.15), transparent 50%), radial-gradient(ellipse at 80% 75%, rgba(108, 208, 243, 0.12), transparent 50%); - animation: agua-tide 14s ease-in-out infinite alternate; + animation: agua-tide 30s ease-in-out infinite alternate; } .deck-page[data-element="tierra"] .page-ambience { background: @@ -259,18 +259,21 @@ body.deck-active-tierra .deck { --deck-glow: rgba(212, 152, 115, 0.24); } radial-gradient(ellipse at 22% 88%, rgba(180, 130, 80, 0.20), transparent 45%), radial-gradient(ellipse at 78% 88%, rgba(150, 100, 60, 0.22), transparent 45%); } +/* Animaciones sutiles — solo opacidad, sin desplazamiento visible. + El movimiento (transform) del fondo causaba un "respiro" molesto + al abrir el deck. */ @keyframes aire-drift { - from { transform: translate(-4%, -1%); } - to { transform: translate(4%, 2%); } + from { opacity: 0.60; } + to { opacity: 0.90; } } @keyframes fuego-flicker { - 0%, 100% { opacity: 0.85; transform: scaleY(1.00); } - 35% { opacity: 1.00; transform: scaleY(1.04); } - 60% { opacity: 0.92; transform: scaleY(0.98); } + 0%, 100% { opacity: 0.65; } + 35% { opacity: 0.90; } + 60% { opacity: 0.75; } } @keyframes agua-tide { - from { transform: translateY(0); } - to { transform: translateY(-3%); } + from { opacity: 0.55; } + to { opacity: 0.85; } } /* Head + controls */ diff --git a/crates/modules/gioser/gioser-graph-web/Cargo.toml b/crates/modules/gioser/gioser-graph-web/Cargo.toml index 081899e..7eac66b 100644 --- a/crates/modules/gioser/gioser-graph-web/Cargo.toml +++ b/crates/modules/gioser/gioser-graph-web/Cargo.toml @@ -27,6 +27,7 @@ features = [ "SvgTextElement", "SvgTextContentElement", "SvgGraphicsElement", + "SvgRectElement", "Node", "Response", "CssStyleDeclaration", diff --git a/crates/modules/gioser/gioser-graph-web/src/lib.rs b/crates/modules/gioser/gioser-graph-web/src/lib.rs index 36adf9c..b089b4d 100644 --- a/crates/modules/gioser/gioser-graph-web/src/lib.rs +++ b/crates/modules/gioser/gioser-graph-web/src/lib.rs @@ -2,44 +2,28 @@ //! //! 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 clicleables: al hacer clic en un nodo se navega a la -//! página correspondiente (o se pasa un callback). //! -//! ## Layout -//! -//! Usa un layout force-directed simple (Fruchterman-Reingold básico) -//! implementado en Rust/WASM. No requiere canvas WebGL ni librerías -//! externas. El SVG se renderiza inline y escala responsivamente. +//! 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 `
` contenedor y un callback `on_navigate(doc_id)`. //! El widget monta un `` dentro con viewBox fijo. -//! -//! ## Ejemplo -//! -//! ```ignore -//! let container = document.get_element_by_id("graph-container") -//! .unwrap().dyn_into::().unwrap(); -//! let graph = GraphWidget::new(container, api_url); -//! graph.load().await; -//! ``` use std::cell::RefCell; use std::rc::Rc; -use js_sys::Promise; use serde::Deserialize; use wasm_bindgen::prelude::*; use wasm_bindgen::JsCast; use wasm_bindgen_futures::JsFuture; use web_sys::{ - Document, HtmlElement, MouseEvent, Response, SvgCircleElement, SvgLineElement, + Document, HtmlElement, MouseEvent, Response, SvgLineElement, SvgRectElement, SvgsvgElement, SvgTextElement, }; -/// Helper para obtener el document desde web-sys. Se llama desde los métodos -/// de GraphWidget sin depender de la referencia pasada (aunque la tenemos). pub(crate) fn document() -> Option { web_sys::window().and_then(|w| w.document()) } @@ -66,6 +50,7 @@ struct NodeData { doc_id: Option, chunk: Option, tags: Option>, + #[allow(dead_code)] preview: Option, } @@ -95,19 +80,21 @@ struct GraphStats { type NavCallback = Rc>>>; const CANVAS_W: f64 = 600.0; -const CANVAS_H: f64 = 260.0; -const NODE_RADIUS: f64 = 20.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; -// Paleta por camino (misma convención que gioser-web CSS) const CAMINO_COLORS: &[(&str, &str)] = &[ - ("logos", "#d0dbff"), // aire - ("aire", "#d0dbff"), // aire (alias) - ("nomos", "#f59056"), // fuego - ("fuego", "#f59056"), // fuego (alias) - ("kay", "#d49873"), // tierra - ("tierra", "#d49873"), // tierra (alias) - ("uku", "#6cd0f3"), // agua - ("agua", "#6cd0f3"), // agua (alias) + ("logos", "#d0dbff"), + ("aire", "#d0dbff"), + ("nomos", "#f59056"), + ("fuego", "#f59056"), + ("kay", "#d49873"), + ("tierra", "#d49873"), + ("uku", "#6cd0f3"), + ("agua", "#6cd0f3"), ]; fn camino_color(camino: &str) -> &str { @@ -130,10 +117,6 @@ pub struct GraphWidget { } impl GraphWidget { - /// Crea un nuevo GraphWidget. `container` es el div donde se monta el SVG. - /// `api_url` es la URL base de la API de grafo (sin trailing slash). - /// `on_navigate` se llama cuando el usuario hace clic en un nodo, - /// pasando el `doc_id` del nodo. pub fn new( container: HtmlElement, api_url: &str, @@ -144,7 +127,6 @@ impl GraphWidget { .and_then(|w| w.document()) .expect("no document") }); - Self { container, api_url: api_url.to_string(), @@ -156,13 +138,12 @@ impl GraphWidget { } } - /// Fetchea `/graph` de la API, aplica layout force-directed y renderiza. 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: web_sys::Response = resp_value.dyn_into()?; + let resp: Response = resp_value.dyn_into()?; if !resp.ok() { return Err(JsValue::from_str(&format!("HTTP {}", resp.status()))); } @@ -172,7 +153,6 @@ impl GraphWidget { let graph: GraphResponse = serde_json::from_str(&body).map_err(|e| JsValue::from_str(&format!("JSON: {e}")))?; - // Solo nodos de nuestro corpus (que tengan doc_id) let nodes: Vec = graph .nodes .into_iter() @@ -184,20 +164,21 @@ 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(()) } - /// Renderiza el SVG con layout force-directed simple. fn render(&self) { - // Limpiar contenedor self.container.set_inner_html(""); if self.nodes.is_empty() { return; } - // Force-directed layout: Fruchterman-Reingold simple let positions = force_layout(&self.nodes, &self.edges, CANVAS_W, CANVAS_H); let ns = "http://www.w3.org/2000/svg"; @@ -207,31 +188,22 @@ impl GraphWidget { .unwrap() .dyn_into() .unwrap(); - svg.set_attribute("viewBox", &format!("0 0 {} {}", CANVAS_W, CANVAS_H)).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.style() - .set_property("display", "block") - .ok(); - svg.style() - .set_property("margin", "1.5rem auto 0") - .ok(); - svg.style() - .set_property("max-width", "100%") - .ok(); - - // Fondo sutil del SVG + 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("border-radius", "12px") - .ok(); + svg.style().set_property("border-radius", "12px").ok(); svg.style() .set_property("border", "1px solid rgba(216,168,93,0.15)") .ok(); - // Aristas + // ── Aristas con grosor proporcional al weight ── 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); @@ -246,135 +218,150 @@ impl GraphWidget { 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", "rgba(255,255,255,0.12)").ok(); - line.set_attribute("stroke-width", "1.0").ok(); - // Si hay weight, opacidad proporcional - if let Some(w) = edge.weight { - let alpha = ((w - 0.5) * 2.0).clamp(0.1, 0.8); - line.set_attribute("stroke-opacity", &format!("{:.2}", alpha)).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(); } } - // Nodos + // ── Nodos como rectángulos con texto dentro ── let on_nav = self.on_navigate.clone(); + let ns_local = ns; // copy for closure captures for (i, node) in self.nodes.iter().enumerate() { - let (x, y) = positions.get(i).map(|(_, p)| *p).unwrap_or((100.0, 100.0)); + let (cx, cy) = positions.get(i).map(|(_, p)| *p).unwrap_or((100.0, 100.0)); let color = camino_color(&node.camino).to_string(); + let label = if node.name.len() > 18 { + format!("{}…", &node.name[..16]) + } else { + node.name.clone() + }; + let camino_up = node.camino.to_uppercase(); - // Círculo - let circle: SvgCircleElement = self + // Grupo contenedor (para hover + click) + let g: web_sys::SvgElement = self .document - .create_element_ns(Some(ns), "circle") + .create_element_ns(Some(ns_local), "g") .unwrap() .dyn_into() .unwrap(); - circle.set_attribute("cx", &format!("{:.1}", x)).ok(); - circle.set_attribute("cy", &format!("{:.1}", y)).ok(); - circle.set_attribute("r", &format!("{:.1}", NODE_RADIUS)).ok(); - circle.set_attribute("fill", &color).ok(); - circle.set_attribute("fill-opacity", "0.35").ok(); - circle.set_attribute("stroke", &color).ok(); - circle.set_attribute("stroke-width", "2").ok(); - circle.set_attribute("cursor", "pointer").ok(); + g.style().set_property("cursor", "pointer").ok(); + g.set_attribute("title", &format!("{} — {}", node.name, camino_up)).ok(); - // Glow - circle.style() - .set_property("filter", "drop-shadow(0 0 6px rgba(255,255,255,0.1))") - .ok(); - circle.style() - .set_property("transition", "all 250ms ease") + // Rectángulo redondeado + let rect: SvgRectElement = self + .document + .create_element_ns(Some(ns_local), "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("fill", &color).ok(); + rect.set_attribute("fill-opacity", "0.25").ok(); + rect.set_attribute("stroke", &color).ok(); + rect.set_attribute("stroke-width", "1.5").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(); - // Hover + // Texto dentro del rectángulo + let text: SvgTextElement = self + .document + .create_element_ns(Some(ns_local), "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("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("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") + .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("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("font-family", "Inter, system-ui, sans-serif").ok(); + sub.set_attribute("letter-spacing", "0.3em").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 + let rect_clone = rect.clone(); + let color_c = color.clone(); + let enter = Closure::::new(move |_| { + rect_clone.set_attribute("fill-opacity", "0.50").ok(); + rect_clone + .style() + .set_property( + "filter", + &format!("drop-shadow(0 0 10px {})", color_c), + ) + .ok(); + }); + g.add_event_listener_with_callback("mouseenter", enter.as_ref().unchecked_ref()) + .ok(); + enter.forget(); + + let rect_clone2 = rect.clone(); + let leave = Closure::::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(); + }); + 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 preview = node.preview.clone().unwrap_or_default(); - let name = node.name.clone(); - let circle_clone = circle.clone(); - let on_nav_clone = on_nav.clone(); - - let mouseenter = Closure::::new(move |_| { - circle_clone - .set_attribute("fill-opacity", "0.6") - .ok(); - circle_clone.style() - .set_property("filter", &format!("drop-shadow(0 0 12px {})", color)) - .ok(); - }); - circle - .add_event_listener_with_callback("mouseenter", mouseenter.as_ref().unchecked_ref()) - .ok(); - mouseenter.forget(); - - let circle_clone2 = circle.clone(); - let mouseleave = Closure::::new(move |_| { - circle_clone2 - .set_attribute("fill-opacity", "0.35") - .ok(); - circle_clone2.style() - .set_property("filter", "drop-shadow(0 0 6px rgba(255,255,255,0.1))") - .ok(); - }); - circle - .add_event_listener_with_callback("mouseleave", mouseleave.as_ref().unchecked_ref()) - .ok(); - mouseleave.forget(); - - let circle_clone3 = circle.clone(); - let on_nav_clone2 = on_nav.clone(); - let doc_id_clone = doc_id.clone(); + let on_nav2 = on_nav.clone(); let click = Closure::::new(move |_| { - let mut cb = on_nav_clone2.borrow_mut(); + let mut cb = on_nav2.borrow_mut(); if let Some(ref mut f) = *cb { - f(doc_id_clone.clone()); + f(doc_id.clone()); } }); - circle - .add_event_listener_with_callback("click", click.as_ref().unchecked_ref()) + g.add_event_listener_with_callback("click", click.as_ref().unchecked_ref()) .ok(); click.forget(); - svg.append_child(&circle).ok(); - - // Título del nodo (abreviado si muy largo) - let label = if name.len() > 20 { - format!("{}…", &name[..18]) - } else { - name.clone() - }; - - let text: SvgTextElement = self - .document - .create_element_ns(Some(ns), "text") - .unwrap() - .dyn_into() - .unwrap(); - text.set_attribute("x", &format!("{:.1}", x)).ok(); - text.set_attribute("y", &format!("{:.1}", y + 36.0)).ok(); - text.set_attribute("text-anchor", "middle").ok(); - text.set_attribute("fill", "rgba(232,234,245,0.6)").ok(); - text.set_attribute("font-size", "9").ok(); - text.set_attribute("font-family", "Inter, sans-serif").ok(); - text.set_text_content(Some(&label)); - svg.append_child(&text).ok(); - - // Tooltip sutil (title attribute) - // El título del elemento svg funciona como tooltip nativo - let title_el = self - .document - .create_element("title") - .ok(); - if let Some(title_el) = title_el { - title_el.set_text_content(Some(&format!( - "{} — {}", - name, - node.camino.to_uppercase() - ))); - svg.append_child(&title_el).ok(); // se lo ponemos al svg, no por nodo - // Mejor: ponemos title a cada círculo - circle.set_attribute("title", &format!("{} — {}", name, node.camino.to_uppercase())).ok(); - } + svg.append_child(&g).ok(); } self.container.append_child(&svg).ok(); @@ -382,9 +369,6 @@ impl GraphWidget { } // ─── Force-directed layout (Fruchterman-Reingold) ──────────────── -// -// Implementación inline para no depender de petgraph. Layout 2D -// con repulsión de Coulomb, atracción de resorte en aristas. fn force_layout( nodes: &[NodeData], @@ -398,33 +382,33 @@ fn force_layout( } let area = w * h; - let k = (area / (n as f64)).sqrt(); + let k = (area / (n as f64)).sqrt() * 1.6; // más separación - // Inicializar posiciones en círculo let cx = w / 2.0; let cy = h / 2.0; - let radius = (w.min(h) * 0.35).max(50.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); + 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(); - // Índice de nodo por id para lookup rápido de aristas let id_to_idx: std::collections::HashMap<&str, usize> = nodes .iter() .enumerate() - .map(|(i, n)| (n.doc_id.as_deref().unwrap_or(""), i)) + .map(|(i, node)| (node.doc_id.as_deref().unwrap_or(""), i)) .filter(|(id, _)| !id.is_empty()) .collect(); - // Construir adjacency: edge_ids let mut adj: Vec> = 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 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); } @@ -434,62 +418,56 @@ fn force_layout( } } - // Iteraciones - let iterations = 60; - let temp_init = w.max(h) / 8.0; - + 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)); - // Reset displacements for d in disp.iter_mut() { *d = (0.0, 0.0); } - // Repulsión: Coulomb entre todo par + // 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; - let fx = force * dx / dist; - let fy = force * dy / dist; - disp[i].0 += fx; - disp[i].1 += fy; - disp[j].0 -= fx; - disp[j].1 -= fy; + 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: Hooke en aristas + // 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; - let fx = force * dx / dist; - let fy = force * dy / dist; - disp[i].0 += fx; - disp[i].1 += fy; - disp[j].0 -= fx; - disp[j].1 -= fy; + 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 desplazamientos con temperatura - let margin = NODE_RADIUS + 8.0; + // 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 = disp[i].0.min(temp).max(-temp); - let step_y = disp[i].1.min(temp).max(-temp); - let new_x = (positions[i].0 + (step / d) * temp).clamp(margin, w - margin); - let new_y = (positions[i].1 + (step_y / d) * temp).clamp(margin, h - margin); + 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); } }