gioser-web: fix graph widget — rect nodes, edge weight, CSS anim, layout
- Switch from circles to horizontal rounded rectangles with text inside - Text size 12px body + 8px sublabel (camino), no overlaps - Edge stroke-width proportional to semantic weight - Fix 'Layout was forced' warning - Reduce CSS page-ambience animations: only opacity (no transform) to fix 'breathing background' visual glitch - Layout: more separation (k*1.6), 80 iterations
This commit is contained in:
+6
-6
@@ -8,13 +8,13 @@ export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembl
|
|||||||
export interface InitOutput {
|
export interface InitOutput {
|
||||||
readonly memory: WebAssembly.Memory;
|
readonly memory: WebAssembly.Memory;
|
||||||
readonly boot: () => void;
|
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_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_217_3: (a: number, b: number, c: number) => void;
|
||||||
readonly __wasm_bindgen_func_elem_216: (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_216_3: (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_493: (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_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_288: (a: number, b: number) => void;
|
readonly __wasm_bindgen_func_elem_288: (a: number, b: number) => void;
|
||||||
readonly __wbindgen_export: (a: number, b: number) => number;
|
readonly __wbindgen_export: (a: number, b: number) => number;
|
||||||
readonly __wbindgen_export2: (a: number, b: number, c: number, d: number) => number;
|
readonly __wbindgen_export2: (a: number, b: number, c: number, d: number) => number;
|
||||||
|
|||||||
@@ -297,10 +297,10 @@ function __wbg_get_imports() {
|
|||||||
const ret = result;
|
const ret = result;
|
||||||
return ret;
|
return ret;
|
||||||
},
|
},
|
||||||
__wbg_instanceof_SvgCircleElement_b8f3b45ab1053e3e: function(arg0) {
|
__wbg_instanceof_SvgElement_46537942d3e1376d: function(arg0) {
|
||||||
let result;
|
let result;
|
||||||
try {
|
try {
|
||||||
result = getObject(arg0) instanceof SVGCircleElement;
|
result = getObject(arg0) instanceof SVGElement;
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
result = false;
|
result = false;
|
||||||
}
|
}
|
||||||
@@ -317,6 +317,16 @@ function __wbg_get_imports() {
|
|||||||
const ret = result;
|
const ret = result;
|
||||||
return ret;
|
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) {
|
__wbg_instanceof_SvgTextElement_06345cd3cc71c951: function(arg0) {
|
||||||
let result;
|
let result;
|
||||||
try {
|
try {
|
||||||
@@ -551,37 +561,37 @@ function __wbg_get_imports() {
|
|||||||
},
|
},
|
||||||
__wbindgen_cast_0000000000000001: function(arg0, arg1) {
|
__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`.
|
// 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);
|
return addHeapObject(ret);
|
||||||
},
|
},
|
||||||
__wbindgen_cast_0000000000000002: function(arg0, arg1) {
|
__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`.
|
// 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);
|
return addHeapObject(ret);
|
||||||
},
|
},
|
||||||
__wbindgen_cast_0000000000000003: function(arg0, arg1) {
|
__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`.
|
// 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);
|
return addHeapObject(ret);
|
||||||
},
|
},
|
||||||
__wbindgen_cast_0000000000000004: function(arg0, arg1) {
|
__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`.
|
// 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);
|
return addHeapObject(ret);
|
||||||
},
|
},
|
||||||
__wbindgen_cast_0000000000000005: function(arg0, arg1) {
|
__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`.
|
// 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);
|
return addHeapObject(ret);
|
||||||
},
|
},
|
||||||
__wbindgen_cast_0000000000000006: function(arg0, arg1) {
|
__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`.
|
// 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);
|
return addHeapObject(ret);
|
||||||
},
|
},
|
||||||
__wbindgen_cast_0000000000000007: function(arg0, arg1) {
|
__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`.
|
// 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);
|
return addHeapObject(ret);
|
||||||
},
|
},
|
||||||
__wbindgen_cast_0000000000000008: function(arg0, arg1) {
|
__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);
|
wasm.__wasm_bindgen_func_elem_288(arg0, arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
function __wasm_bindgen_func_elem_216(arg0, arg1, arg2) {
|
function __wasm_bindgen_func_elem_217(arg0, arg1, arg2) {
|
||||||
wasm.__wasm_bindgen_func_elem_216(arg0, arg1, addHeapObject(arg2));
|
wasm.__wasm_bindgen_func_elem_217(arg0, arg1, addHeapObject(arg2));
|
||||||
}
|
}
|
||||||
|
|
||||||
function __wasm_bindgen_func_elem_216_3(arg0, arg1, arg2) {
|
function __wasm_bindgen_func_elem_217_3(arg0, arg1, arg2) {
|
||||||
wasm.__wasm_bindgen_func_elem_216_3(arg0, arg1, addHeapObject(arg2));
|
wasm.__wasm_bindgen_func_elem_217_3(arg0, arg1, addHeapObject(arg2));
|
||||||
}
|
}
|
||||||
|
|
||||||
function __wasm_bindgen_func_elem_493(arg0, arg1, arg2) {
|
function __wasm_bindgen_func_elem_494(arg0, arg1, arg2) {
|
||||||
wasm.__wasm_bindgen_func_elem_493(arg0, arg1, addHeapObject(arg2));
|
wasm.__wasm_bindgen_func_elem_494(arg0, arg1, addHeapObject(arg2));
|
||||||
}
|
}
|
||||||
|
|
||||||
function __wasm_bindgen_func_elem_592(arg0, arg1, arg2) {
|
function __wasm_bindgen_func_elem_593(arg0, arg1, arg2) {
|
||||||
wasm.__wasm_bindgen_func_elem_592(arg0, arg1, addHeapObject(arg2));
|
wasm.__wasm_bindgen_func_elem_593(arg0, arg1, addHeapObject(arg2));
|
||||||
}
|
}
|
||||||
|
|
||||||
function __wasm_bindgen_func_elem_287(arg0, arg1, arg2) {
|
function __wasm_bindgen_func_elem_289(arg0, arg1, arg2) {
|
||||||
wasm.__wasm_bindgen_func_elem_287(arg0, arg1, addHeapObject(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 {
|
try {
|
||||||
const retptr = wasm.__wbindgen_add_to_stack_pointer(-16);
|
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 r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true);
|
||||||
var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true);
|
var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true);
|
||||||
if (r1) {
|
if (r1) {
|
||||||
@@ -651,8 +661,8 @@ function __wasm_bindgen_func_elem_1396(arg0, arg1, arg2) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function __wasm_bindgen_func_elem_217(arg0, arg1, arg2) {
|
function __wasm_bindgen_func_elem_218(arg0, arg1, arg2) {
|
||||||
wasm.__wasm_bindgen_func_elem_217(arg0, arg1, arg2);
|
wasm.__wasm_bindgen_func_elem_218(arg0, arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
function addHeapObject(obj) {
|
function addHeapObject(obj) {
|
||||||
|
|||||||
Binary file not shown.
+6
-6
@@ -2,13 +2,13 @@
|
|||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
export const memory: WebAssembly.Memory;
|
export const memory: WebAssembly.Memory;
|
||||||
export const boot: () => void;
|
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_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_217_3: (a: number, b: number, c: number) => void;
|
||||||
export const __wasm_bindgen_func_elem_216: (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_216_3: (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_493: (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_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_288: (a: number, b: 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_export: (a: number, b: number) => number;
|
||||||
export const __wbindgen_export2: (a: number, b: number, c: number, d: number) => number;
|
export const __wbindgen_export2: (a: number, b: number, c: number, d: number) => number;
|
||||||
|
|||||||
@@ -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 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 78% 68%, rgba(208, 219, 255, 0.14), transparent 40%),
|
||||||
radial-gradient(circle at 45% 90%, rgba(180, 200, 255, 0.10), transparent 45%);
|
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 {
|
.deck-page[data-element="fuego"] .page-ambience {
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at 50% 100%, rgba(245, 144, 86, 0.35), transparent 55%),
|
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 25% 80%, rgba(255, 90, 40, 0.18), transparent 35%),
|
||||||
radial-gradient(circle at 80% 85%, rgba(255, 140, 60, 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 {
|
.deck-page[data-element="agua"] .page-ambience {
|
||||||
background:
|
background:
|
||||||
radial-gradient(ellipse at 50% 95%, rgba(60, 160, 230, 0.30), transparent 60%),
|
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 20% 70%, rgba(108, 208, 243, 0.15), transparent 50%),
|
||||||
radial-gradient(ellipse at 80% 75%, rgba(108, 208, 243, 0.12), 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 {
|
.deck-page[data-element="tierra"] .page-ambience {
|
||||||
background:
|
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 22% 88%, rgba(180, 130, 80, 0.20), transparent 45%),
|
||||||
radial-gradient(ellipse at 78% 88%, rgba(150, 100, 60, 0.22), 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 {
|
@keyframes aire-drift {
|
||||||
from { transform: translate(-4%, -1%); }
|
from { opacity: 0.60; }
|
||||||
to { transform: translate(4%, 2%); }
|
to { opacity: 0.90; }
|
||||||
}
|
}
|
||||||
@keyframes fuego-flicker {
|
@keyframes fuego-flicker {
|
||||||
0%, 100% { opacity: 0.85; transform: scaleY(1.00); }
|
0%, 100% { opacity: 0.65; }
|
||||||
35% { opacity: 1.00; transform: scaleY(1.04); }
|
35% { opacity: 0.90; }
|
||||||
60% { opacity: 0.92; transform: scaleY(0.98); }
|
60% { opacity: 0.75; }
|
||||||
}
|
}
|
||||||
@keyframes agua-tide {
|
@keyframes agua-tide {
|
||||||
from { transform: translateY(0); }
|
from { opacity: 0.55; }
|
||||||
to { transform: translateY(-3%); }
|
to { opacity: 0.85; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Head + controls */
|
/* Head + controls */
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ features = [
|
|||||||
"SvgTextElement",
|
"SvgTextElement",
|
||||||
"SvgTextContentElement",
|
"SvgTextContentElement",
|
||||||
"SvgGraphicsElement",
|
"SvgGraphicsElement",
|
||||||
|
"SvgRectElement",
|
||||||
"Node",
|
"Node",
|
||||||
"Response",
|
"Response",
|
||||||
"CssStyleDeclaration",
|
"CssStyleDeclaration",
|
||||||
|
|||||||
@@ -2,44 +2,28 @@
|
|||||||
//!
|
//!
|
||||||
//! Fetchea `GET /graph` de la API de gioser, parsea nodos + aristas,
|
//! Fetchea `GET /graph` de la API de gioser, parsea nodos + aristas,
|
||||||
//! y renderiza un grafo SVG interactivo dentro de un contenedor dado.
|
//! 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
|
//! Los nodos son **rectángulos redondeados** horizontales con el texto
|
||||||
//!
|
//! dentro (no círculos) para mejor legibilidad. Las aristas varían en
|
||||||
//! Usa un layout force-directed simple (Fruchterman-Reingold básico)
|
//! grosor según la intensidad semántica (k-NN weight).
|
||||||
//! implementado en Rust/WASM. No requiere canvas WebGL ni librerías
|
|
||||||
//! externas. El SVG se renderiza inline y escala responsivamente.
|
|
||||||
//!
|
//!
|
||||||
//! ## Contrato DOM
|
//! ## Contrato DOM
|
||||||
//!
|
//!
|
||||||
//! El caller pasa un `<div>` contenedor y un callback `on_navigate(doc_id)`.
|
//! El caller pasa un `<div>` contenedor y un callback `on_navigate(doc_id)`.
|
||||||
//! El widget monta un `<svg>` dentro con viewBox fijo.
|
//! El widget monta un `<svg>` dentro con viewBox fijo.
|
||||||
//!
|
|
||||||
//! ## Ejemplo
|
|
||||||
//!
|
|
||||||
//! ```ignore
|
|
||||||
//! let container = document.get_element_by_id("graph-container")
|
|
||||||
//! .unwrap().dyn_into::<HtmlElement>().unwrap();
|
|
||||||
//! let graph = GraphWidget::new(container, api_url);
|
|
||||||
//! graph.load().await;
|
|
||||||
//! ```
|
|
||||||
|
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
use js_sys::Promise;
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use wasm_bindgen::prelude::*;
|
use wasm_bindgen::prelude::*;
|
||||||
use wasm_bindgen::JsCast;
|
use wasm_bindgen::JsCast;
|
||||||
use wasm_bindgen_futures::JsFuture;
|
use wasm_bindgen_futures::JsFuture;
|
||||||
use web_sys::{
|
use web_sys::{
|
||||||
Document, HtmlElement, MouseEvent, Response, SvgCircleElement, SvgLineElement,
|
Document, HtmlElement, MouseEvent, Response, SvgLineElement, SvgRectElement,
|
||||||
SvgsvgElement, SvgTextElement,
|
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<Document> {
|
pub(crate) fn document() -> Option<Document> {
|
||||||
web_sys::window().and_then(|w| w.document())
|
web_sys::window().and_then(|w| w.document())
|
||||||
}
|
}
|
||||||
@@ -66,6 +50,7 @@ struct NodeData {
|
|||||||
doc_id: Option<String>,
|
doc_id: Option<String>,
|
||||||
chunk: Option<u32>,
|
chunk: Option<u32>,
|
||||||
tags: Option<Vec<String>>,
|
tags: Option<Vec<String>>,
|
||||||
|
#[allow(dead_code)]
|
||||||
preview: Option<String>,
|
preview: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,19 +80,21 @@ struct GraphStats {
|
|||||||
type NavCallback = Rc<RefCell<Option<Box<dyn FnMut(String)>>>>;
|
type NavCallback = Rc<RefCell<Option<Box<dyn FnMut(String)>>>>;
|
||||||
|
|
||||||
const CANVAS_W: f64 = 600.0;
|
const CANVAS_W: f64 = 600.0;
|
||||||
const CANVAS_H: f64 = 260.0;
|
const CANVAS_H: f64 = 270.0;
|
||||||
const NODE_RADIUS: f64 = 20.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)] = &[
|
const CAMINO_COLORS: &[(&str, &str)] = &[
|
||||||
("logos", "#d0dbff"), // aire
|
("logos", "#d0dbff"),
|
||||||
("aire", "#d0dbff"), // aire (alias)
|
("aire", "#d0dbff"),
|
||||||
("nomos", "#f59056"), // fuego
|
("nomos", "#f59056"),
|
||||||
("fuego", "#f59056"), // fuego (alias)
|
("fuego", "#f59056"),
|
||||||
("kay", "#d49873"), // tierra
|
("kay", "#d49873"),
|
||||||
("tierra", "#d49873"), // tierra (alias)
|
("tierra", "#d49873"),
|
||||||
("uku", "#6cd0f3"), // agua
|
("uku", "#6cd0f3"),
|
||||||
("agua", "#6cd0f3"), // agua (alias)
|
("agua", "#6cd0f3"),
|
||||||
];
|
];
|
||||||
|
|
||||||
fn camino_color(camino: &str) -> &str {
|
fn camino_color(camino: &str) -> &str {
|
||||||
@@ -130,10 +117,6 @@ pub struct GraphWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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(
|
pub fn new(
|
||||||
container: HtmlElement,
|
container: HtmlElement,
|
||||||
api_url: &str,
|
api_url: &str,
|
||||||
@@ -144,7 +127,6 @@ impl GraphWidget {
|
|||||||
.and_then(|w| w.document())
|
.and_then(|w| w.document())
|
||||||
.expect("no document")
|
.expect("no document")
|
||||||
});
|
});
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
container,
|
container,
|
||||||
api_url: api_url.to_string(),
|
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> {
|
pub async fn load(&mut self) -> Result<(), JsValue> {
|
||||||
let url = format!("{}/graph?limit=500", self.api_url);
|
let url = format!("{}/graph?limit=500", self.api_url);
|
||||||
let window = web_sys::window().ok_or_else(|| JsValue::from_str("no window"))?;
|
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_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() {
|
if !resp.ok() {
|
||||||
return Err(JsValue::from_str(&format!("HTTP {}", resp.status())));
|
return Err(JsValue::from_str(&format!("HTTP {}", resp.status())));
|
||||||
}
|
}
|
||||||
@@ -172,7 +153,6 @@ impl GraphWidget {
|
|||||||
let graph: GraphResponse =
|
let graph: GraphResponse =
|
||||||
serde_json::from_str(&body).map_err(|e| JsValue::from_str(&format!("JSON: {e}")))?;
|
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<NodeData> = graph
|
let nodes: Vec<NodeData> = graph
|
||||||
.nodes
|
.nodes
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -184,20 +164,21 @@ impl GraphWidget {
|
|||||||
self.nodes = nodes;
|
self.nodes = nodes;
|
||||||
self.edges = edges;
|
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();
|
self.render();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Renderiza el SVG con layout force-directed simple.
|
|
||||||
fn render(&self) {
|
fn render(&self) {
|
||||||
// Limpiar contenedor
|
|
||||||
self.container.set_inner_html("");
|
self.container.set_inner_html("");
|
||||||
|
|
||||||
if self.nodes.is_empty() {
|
if self.nodes.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Force-directed layout: Fruchterman-Reingold simple
|
|
||||||
let positions = force_layout(&self.nodes, &self.edges, CANVAS_W, CANVAS_H);
|
let positions = force_layout(&self.nodes, &self.edges, CANVAS_W, CANVAS_H);
|
||||||
|
|
||||||
let ns = "http://www.w3.org/2000/svg";
|
let ns = "http://www.w3.org/2000/svg";
|
||||||
@@ -207,31 +188,22 @@ impl GraphWidget {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.dyn_into()
|
.dyn_into()
|
||||||
.unwrap();
|
.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("width", "100%").ok();
|
||||||
svg.set_attribute("height", &format!("{}px", CANVAS_H as u32)).ok();
|
svg.set_attribute("height", &format!("{}px", CANVAS_H as u32)).ok();
|
||||||
svg.style()
|
svg.style().set_property("display", "block").ok();
|
||||||
.set_property("display", "block")
|
svg.style().set_property("margin", "1.5rem auto 0").ok();
|
||||||
.ok();
|
svg.style().set_property("max-width", "100%").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()
|
svg.style()
|
||||||
.set_property("background", "rgba(255,255,255,0.02)")
|
.set_property("background", "rgba(255,255,255,0.02)")
|
||||||
.ok();
|
.ok();
|
||||||
svg.style()
|
svg.style().set_property("border-radius", "12px").ok();
|
||||||
.set_property("border-radius", "12px")
|
|
||||||
.ok();
|
|
||||||
svg.style()
|
svg.style()
|
||||||
.set_property("border", "1px solid rgba(216,168,93,0.15)")
|
.set_property("border", "1px solid rgba(216,168,93,0.15)")
|
||||||
.ok();
|
.ok();
|
||||||
|
|
||||||
// Aristas
|
// ── Aristas con grosor proporcional al weight ──
|
||||||
for edge in &self.edges {
|
for edge in &self.edges {
|
||||||
let src_pos = positions.iter().find(|(id, _)| *id == edge.source);
|
let src_pos = positions.iter().find(|(id, _)| *id == edge.source);
|
||||||
let tgt_pos = positions.iter().find(|(id, _)| *id == edge.target);
|
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("y1", &format!("{:.1}", y1)).ok();
|
||||||
line.set_attribute("x2", &format!("{:.1}", x2)).ok();
|
line.set_attribute("x2", &format!("{:.1}", x2)).ok();
|
||||||
line.set_attribute("y2", &format!("{:.1}", y2)).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();
|
// Grosor según peso: 0.5→1, 1.0→4 (clamped)
|
||||||
// Si hay weight, opacidad proporcional
|
let sw = edge
|
||||||
if let Some(w) = edge.weight {
|
.weight
|
||||||
let alpha = ((w - 0.5) * 2.0).clamp(0.1, 0.8);
|
.map(|w| 0.5 + (w - 0.5) * 6.0)
|
||||||
line.set_attribute("stroke-opacity", &format!("{:.2}", alpha)).ok();
|
.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();
|
svg.append_child(&line).ok();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Nodos
|
// ── Nodos como rectángulos con texto dentro ──
|
||||||
let on_nav = self.on_navigate.clone();
|
let on_nav = self.on_navigate.clone();
|
||||||
|
let ns_local = ns; // copy for closure captures
|
||||||
for (i, node) in self.nodes.iter().enumerate() {
|
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 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
|
// Grupo contenedor (para hover + click)
|
||||||
let circle: SvgCircleElement = self
|
let g: web_sys::SvgElement = self
|
||||||
.document
|
.document
|
||||||
.create_element_ns(Some(ns), "circle")
|
.create_element_ns(Some(ns_local), "g")
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.dyn_into()
|
.dyn_into()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
circle.set_attribute("cx", &format!("{:.1}", x)).ok();
|
g.style().set_property("cursor", "pointer").ok();
|
||||||
circle.set_attribute("cy", &format!("{:.1}", y)).ok();
|
g.set_attribute("title", &format!("{} — {}", node.name, camino_up)).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();
|
|
||||||
|
|
||||||
// Glow
|
// Rectángulo redondeado
|
||||||
circle.style()
|
let rect: SvgRectElement = self
|
||||||
.set_property("filter", "drop-shadow(0 0 6px rgba(255,255,255,0.1))")
|
.document
|
||||||
.ok();
|
.create_element_ns(Some(ns_local), "rect")
|
||||||
circle.style()
|
.unwrap()
|
||||||
.set_property("transition", "all 250ms ease")
|
.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();
|
.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::<dyn FnMut(MouseEvent)>::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::<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();
|
||||||
|
});
|
||||||
|
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 doc_id = node.doc_id.clone().unwrap_or_default();
|
||||||
let preview = node.preview.clone().unwrap_or_default();
|
let on_nav2 = on_nav.clone();
|
||||||
let name = node.name.clone();
|
|
||||||
let circle_clone = circle.clone();
|
|
||||||
let on_nav_clone = on_nav.clone();
|
|
||||||
|
|
||||||
let mouseenter = Closure::<dyn FnMut(MouseEvent)>::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::<dyn FnMut(MouseEvent)>::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 click = Closure::<dyn FnMut(MouseEvent)>::new(move |_| {
|
let click = Closure::<dyn FnMut(MouseEvent)>::new(move |_| {
|
||||||
let mut cb = on_nav_clone2.borrow_mut();
|
let mut cb = on_nav2.borrow_mut();
|
||||||
if let Some(ref mut f) = *cb {
|
if let Some(ref mut f) = *cb {
|
||||||
f(doc_id_clone.clone());
|
f(doc_id.clone());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
circle
|
g.add_event_listener_with_callback("click", click.as_ref().unchecked_ref())
|
||||||
.add_event_listener_with_callback("click", click.as_ref().unchecked_ref())
|
|
||||||
.ok();
|
.ok();
|
||||||
click.forget();
|
click.forget();
|
||||||
|
|
||||||
svg.append_child(&circle).ok();
|
svg.append_child(&g).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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
self.container.append_child(&svg).ok();
|
self.container.append_child(&svg).ok();
|
||||||
@@ -382,9 +369,6 @@ impl GraphWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── Force-directed layout (Fruchterman-Reingold) ────────────────
|
// ─── 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(
|
fn force_layout(
|
||||||
nodes: &[NodeData],
|
nodes: &[NodeData],
|
||||||
@@ -398,33 +382,33 @@ fn force_layout(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let area = w * h;
|
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 cx = w / 2.0;
|
||||||
let cy = h / 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
|
let mut positions: Vec<(f64, f64)> = nodes
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(i, _)| {
|
.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())
|
(cx + radius * angle.cos(), cy + radius * angle.sin())
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Índice de nodo por id para lookup rápido de aristas
|
|
||||||
let id_to_idx: std::collections::HashMap<&str, usize> = nodes
|
let id_to_idx: std::collections::HashMap<&str, usize> = nodes
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.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())
|
.filter(|(id, _)| !id.is_empty())
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Construir adjacency: edge_ids
|
|
||||||
let mut adj: Vec<Vec<usize>> = vec![vec![]; n];
|
let mut adj: Vec<Vec<usize>> = vec![vec![]; n];
|
||||||
for e in edges {
|
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) {
|
if !adj[si].contains(&ti) {
|
||||||
adj[si].push(ti);
|
adj[si].push(ti);
|
||||||
}
|
}
|
||||||
@@ -434,62 +418,56 @@ fn force_layout(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Iteraciones
|
let iterations = 80;
|
||||||
let iterations = 60;
|
let temp_init = w.max(h) / 5.0;
|
||||||
let temp_init = w.max(h) / 8.0;
|
|
||||||
|
|
||||||
let mut disp: Vec<(f64, f64)> = vec![(0.0, 0.0); n];
|
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 {
|
for iter in 0..iterations {
|
||||||
let temp = temp_init * (1.0 - (iter as f64) / (iterations as f64));
|
let temp = temp_init * (1.0 - (iter as f64) / (iterations as f64));
|
||||||
|
|
||||||
// Reset displacements
|
|
||||||
for d in disp.iter_mut() {
|
for d in disp.iter_mut() {
|
||||||
*d = (0.0, 0.0);
|
*d = (0.0, 0.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Repulsión: Coulomb entre todo par
|
// Repulsión
|
||||||
for i in 0..n {
|
for i in 0..n {
|
||||||
for j in (i + 1)..n {
|
for j in (i + 1)..n {
|
||||||
let dx = positions[i].0 - positions[j].0;
|
let dx = positions[i].0 - positions[j].0;
|
||||||
let dy = positions[i].1 - positions[j].1;
|
let dy = positions[i].1 - positions[j].1;
|
||||||
let dist = (dx * dx + dy * dy).sqrt().max(1.0);
|
let dist = (dx * dx + dy * dy).sqrt().max(1.0);
|
||||||
let force = k * k / dist;
|
let force = k * k / dist;
|
||||||
let fx = force * dx / dist;
|
disp[i].0 += force * dx / dist;
|
||||||
let fy = force * dy / dist;
|
disp[i].1 += force * dy / dist;
|
||||||
disp[i].0 += fx;
|
disp[j].0 -= force * dx / dist;
|
||||||
disp[i].1 += fy;
|
disp[j].1 -= force * dy / dist;
|
||||||
disp[j].0 -= fx;
|
|
||||||
disp[j].1 -= fy;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Atracción: Hooke en aristas
|
// Atracción en aristas
|
||||||
for i in 0..n {
|
for i in 0..n {
|
||||||
for &j in &adj[i] {
|
for &j in &adj[i] {
|
||||||
let dx = positions[j].0 - positions[i].0;
|
let dx = positions[j].0 - positions[i].0;
|
||||||
let dy = positions[j].1 - positions[i].1;
|
let dy = positions[j].1 - positions[i].1;
|
||||||
let dist = (dx * dx + dy * dy).sqrt().max(1.0);
|
let dist = (dx * dx + dy * dy).sqrt().max(1.0);
|
||||||
let force = dist * dist / k;
|
let force = dist * dist / k;
|
||||||
let fx = force * dx / dist;
|
disp[i].0 += force * dx / dist;
|
||||||
let fy = force * dy / dist;
|
disp[i].1 += force * dy / dist;
|
||||||
disp[i].0 += fx;
|
disp[j].0 -= force * dx / dist;
|
||||||
disp[i].1 += fy;
|
disp[j].1 -= force * dy / dist;
|
||||||
disp[j].0 -= fx;
|
|
||||||
disp[j].1 -= fy;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Aplicar desplazamientos con temperatura
|
// Aplicar
|
||||||
let margin = NODE_RADIUS + 8.0;
|
|
||||||
for i in 0..n {
|
for i in 0..n {
|
||||||
let d = (disp[i].0 * disp[i].0 + disp[i].1 * disp[i].1)
|
let d = (disp[i].0 * disp[i].0 + disp[i].1 * disp[i].1)
|
||||||
.sqrt()
|
.sqrt()
|
||||||
.max(0.001);
|
.max(0.001);
|
||||||
let step = disp[i].0.min(temp).max(-temp);
|
let step_x = (disp[i].0 / d * temp).clamp(-temp, temp);
|
||||||
let step_y = disp[i].1.min(temp).max(-temp);
|
let step_y = (disp[i].1 / d * temp).clamp(-temp, temp);
|
||||||
let new_x = (positions[i].0 + (step / d) * temp).clamp(margin, w - margin);
|
let new_x = (positions[i].0 + step_x).clamp(half_w, w - half_w);
|
||||||
let new_y = (positions[i].1 + (step_y / d) * temp).clamp(margin, h - margin);
|
let new_y = (positions[i].1 + step_y).clamp(half_h, h - half_h);
|
||||||
positions[i] = (new_x, new_y);
|
positions[i] = (new_x, new_y);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user