diff --git a/cpp/apps/primitives_gallery/CMakeLists.txt b/cpp/apps/primitives_gallery/CMakeLists.txt index 9291c81d..a029e980 100644 --- a/cpp/apps/primitives_gallery/CMakeLists.txt +++ b/cpp/apps/primitives_gallery/CMakeLists.txt @@ -70,7 +70,9 @@ add_imgui_app(primitives_gallery ${CMAKE_SOURCE_DIR}/functions/viz/graph_icons.cpp ${CMAKE_SOURCE_DIR}/functions/viz/graph_force_layout.cpp ${CMAKE_SOURCE_DIR}/functions/viz/graph_force_layout_gpu.cpp + ${CMAKE_SOURCE_DIR}/functions/viz/graph_layouts.cpp ${CMAKE_SOURCE_DIR}/functions/viz/graph_viewport.cpp + ${CMAKE_SOURCE_DIR}/functions/viz/graph_viewport_selection.cpp ${CMAKE_SOURCE_DIR}/functions/core/graph_spatial_hash.cpp # GL loader (Linux no-op, Windows wglGetProcAddress) ${CMAKE_SOURCE_DIR}/functions/gfx/gl_loader.cpp diff --git a/cpp/apps/primitives_gallery/demos_graph.cpp b/cpp/apps/primitives_gallery/demos_graph.cpp index 98f9f5d8..b4f8e454 100644 --- a/cpp/apps/primitives_gallery/demos_graph.cpp +++ b/cpp/apps/primitives_gallery/demos_graph.cpp @@ -5,6 +5,7 @@ #include "viz/graph_viewport.h" #include "viz/graph_force_layout.h" #include "viz/graph_force_layout_gpu.h" +#include "viz/graph_layouts.h" #include "core/button.h" #include "core/tokens.h" @@ -148,6 +149,14 @@ void demo_graph() { static ForceLayoutGPU* s_gpu_ctx = nullptr; static bool s_gpu_dirty = true; // re-upload tras regen / cambio + // Layout estatico activo (issue 0049i). 0=force (iterativo), 1=grid, + // 2=circular, 3=radial, 4=hierarchical, 5=fixed. + static int s_layout_mode = 0; + const char* k_layout_names[] = { + "force", "grid", "circular", "radial", "hierarchical", "fixed" + }; + static int s_apply_layout = 0; // se incrementa cuando hay que reaplicar + if (s_needs_regen) { init_demo_types(); generate_synthetic_graph(s_n_nodes, s_n_clusters, @@ -213,6 +222,22 @@ void demo_graph() { if (s_use_gpu != prev_gpu) { s_gpu_dirty = true; // re-upload al cambiar de modo } + + // Selector de layout (issue 0049i). + ImGui::PushItemWidth(140); + int prev_mode = s_layout_mode; + if (ImGui::Combo("Layout", &s_layout_mode, + k_layout_names, IM_ARRAYSIZE(k_layout_names))) { + // Cambio de modo: reaplicar instantaneamente + s_apply_layout++; + } + if (prev_mode != s_layout_mode) { + // En "force" volvemos a animar; en cualquier estatico paramos. + s_state.layout_running = (s_layout_mode == 0); + } + ImGui::PopItemWidth(); + ImGui::SameLine(); + if (button("Apply layout", ButtonVariant::Subtle)) s_apply_layout++; } section("Stats"); @@ -242,7 +267,25 @@ void demo_graph() { ImGui::PopStyleColor(); } - section("Viewport (drag = pan, wheel = zoom, click = select)"); + // Aplicar layout estatico cuando se solicita (cambio de modo / boton). + static int s_last_apply = -1; + if (s_apply_layout != s_last_apply) { + s_last_apply = s_apply_layout; + switch (s_layout_mode) { + case 1: graph::layout_grid (s_graph, 25.0f); break; + case 2: graph::layout_circular (s_graph, 200.0f); break; + case 3: graph::layout_radial (s_graph, 0, 80.0f); break; + case 4: graph::layout_hierarchical(s_graph, 0, 120.0f, 50.0f); break; + case 5: graph::layout_fixed (s_graph); break; + case 0: default: + // force: dejar las posiciones actuales; el bucle lo refinara + break; + } + s_gpu_dirty = true; + if (s_layout_mode != 0) graph_viewport_fit(s_graph, s_state); + } + + section("Viewport (drag=pan, wheel=zoom, click=select, shift+drag=lasso, ctrl+click=toggle)"); if (s_initialized) { // Avanzamos 1 paso de force layout cada frame mientras layout_running. // Auto-pause: si la energia por nodo cae bajo el umbral durante N @@ -252,7 +295,7 @@ void demo_graph() { static int s_low_energy_frames = 0; const int k_pause_after_frames = 30; const float k_pause_per_node = 0.001f; // umbral de energia/nodo - if (s_state.layout_running) { + if (s_state.layout_running && s_layout_mode == 0) { ForceLayoutConfig cfg; cfg.repulsion = s_repulsion; cfg.attraction = s_attraction; @@ -294,7 +337,52 @@ void demo_graph() { } else { s_low_energy_frames = 0; } - graph_viewport("##graph_demo", s_graph, s_state, ImVec2(0, 460)); + // Callbacks (issue 0049i): right-click abre popup contextual, + // double-click loguea el indice. Los callbacks corren dentro del + // frame ImGui — el caller puede usar OpenPopup directamente. + static int s_ctx_node = -1; + static bool s_ctx_open = false; + struct Cb { + static void on_ctx(int idx, ImVec2 /*pos*/, void* user) { + int* slot = (int*)user; + *slot = idx; + ImGui::OpenPopup("##graph_node_ctx"); + } + static void on_dbl(int idx, void* /*user*/) { + std::printf("[graph] dbl-click on node %d\n", idx); + } + }; + GraphViewportCallbacks cb; + cb.on_context_menu = &Cb::on_ctx; + cb.on_double_click = &Cb::on_dbl; + cb.user = &s_ctx_node; + + graph_viewport("##graph_demo", s_graph, s_state, ImVec2(0, 460), cb); + + if (ImGui::BeginPopup("##graph_node_ctx")) { + ImGui::Text("Node #%d", s_ctx_node); + ImGui::Separator(); + if (ImGui::MenuItem("Pin")) { + if (s_ctx_node >= 0 && s_ctx_node < s_graph.node_count) + s_graph.nodes[s_ctx_node].flags |= NF_PINNED; + } + if (ImGui::MenuItem("Unpin")) { + if (s_ctx_node >= 0 && s_ctx_node < s_graph.node_count) + s_graph.nodes[s_ctx_node].flags &= ~NF_PINNED; + } + if (ImGui::MenuItem("Add to selection")) { + graph_viewport_add_to_selection(s_graph, s_state, s_ctx_node); + } + ImGui::EndPopup(); + } + + // Overlay con count seleccionados (lasso/multi-select feedback). + if (!s_state.selection.empty()) { + ImGui::SameLine(); + ImGui::PushStyleColor(ImGuiCol_Text, fn_tokens::colors::text); + ImGui::Text("[%zu selected]", s_state.selection.size()); + ImGui::PopStyleColor(); + } } code_block( diff --git a/cpp/functions/viz/graph_force_layout.cpp b/cpp/functions/viz/graph_force_layout.cpp index 67db16c7..9bee204f 100644 --- a/cpp/functions/viz/graph_force_layout.cpp +++ b/cpp/functions/viz/graph_force_layout.cpp @@ -1,4 +1,5 @@ #include "viz/graph_force_layout.h" +#include "viz/graph_layouts.h" #include "viz/graph_types.h" #include @@ -342,19 +343,9 @@ void graph_force_layout_reset(GraphData& graph, float spread) { graph.update_bounds(); } +// Wrappers deprecados — delegan en graph::layout_* (graph_layouts.h). void graph_layout_circular(GraphData& graph, float radius) { - if (graph.node_count <= 0) return; - const float two_pi = 6.28318530718f; - for (int i = 0; i < graph.node_count; ++i) { - GraphNode& n = graph.nodes[i]; - if (n.flags & NF_PINNED) continue; - float angle = two_pi * (float)i / (float)graph.node_count; - n.x = radius * std::cos(angle); - n.y = radius * std::sin(angle); - n.vx = 0.0f; - n.vy = 0.0f; - } - graph.update_bounds(); + graph::layout_circular(graph, radius); } bool graph_force_layout_should_pause(int consecutive_low_frames, int min_consecutive) { @@ -363,20 +354,5 @@ bool graph_force_layout_should_pause(int consecutive_low_frames, int min_consecu } void graph_layout_grid(GraphData& graph, float spacing) { - if (graph.node_count <= 0) return; - int cols = (int)std::ceil(std::sqrt((float)graph.node_count)); - int rows = (graph.node_count + cols - 1) / cols; - float ox = -0.5f * (cols - 1) * spacing; - float oy = -0.5f * (rows - 1) * spacing; - for (int i = 0; i < graph.node_count; ++i) { - GraphNode& n = graph.nodes[i]; - if (n.flags & NF_PINNED) continue; - int col = i % cols; - int row = i / cols; - n.x = ox + col * spacing; - n.y = oy + row * spacing; - n.vx = 0.0f; - n.vy = 0.0f; - } - graph.update_bounds(); + graph::layout_grid(graph, spacing); } diff --git a/cpp/functions/viz/graph_force_layout.h b/cpp/functions/viz/graph_force_layout.h index c8b5f17e..91084b6e 100644 --- a/cpp/functions/viz/graph_force_layout.h +++ b/cpp/functions/viz/graph_force_layout.h @@ -22,7 +22,9 @@ float graph_force_layout_step(GraphData& graph, const ForceLayoutConfig& config // Reset: randomize positions within [-spread, spread], zero velocities. void graph_force_layout_reset(GraphData& graph, float spread = 200.0f); -// Preset layouts (non-iterative, instant positioning) +// Preset layouts (non-iterative). Wrappers deprecados que delegan en +// graph_layouts.h. Se mantienen por compat — usar graph::layout_circular / +// graph::layout_grid directamente en codigo nuevo. void graph_layout_circular(GraphData& graph, float radius = 100.0f); void graph_layout_grid(GraphData& graph, float spacing = 20.0f); diff --git a/cpp/functions/viz/graph_layouts.cpp b/cpp/functions/viz/graph_layouts.cpp new file mode 100644 index 00000000..8e56da01 --- /dev/null +++ b/cpp/functions/viz/graph_layouts.cpp @@ -0,0 +1,281 @@ +#include "viz/graph_layouts.h" +#include "viz/graph_types.h" + +#include +#include +#include +#include +#include + +namespace graph { + +// --------------------------------------------------------------------------- +// layout_grid +// --------------------------------------------------------------------------- +void layout_grid(GraphData& g, float spacing) { + if (g.node_count <= 0) return; + int cols = (int)std::ceil(std::sqrt((float)g.node_count)); + if (cols < 1) cols = 1; + int rows = (g.node_count + cols - 1) / cols; + float ox = -0.5f * (cols - 1) * spacing; + float oy = -0.5f * (rows - 1) * spacing; + for (int i = 0; i < g.node_count; ++i) { + GraphNode& n = g.nodes[i]; + if (n.flags & NF_PINNED) continue; + int col = i % cols; + int row = i / cols; + n.x = ox + col * spacing; + n.y = oy + row * spacing; + n.vx = 0.0f; + n.vy = 0.0f; + } + g.update_bounds(); +} + +// --------------------------------------------------------------------------- +// layout_circular +// --------------------------------------------------------------------------- +void layout_circular(GraphData& g, float radius) { + if (g.node_count <= 0) return; + const float two_pi = 6.28318530718f; + for (int i = 0; i < g.node_count; ++i) { + GraphNode& n = g.nodes[i]; + if (n.flags & NF_PINNED) continue; + float angle = two_pi * (float)i / (float)g.node_count; + n.x = radius * std::cos(angle); + n.y = radius * std::sin(angle); + n.vx = 0.0f; + n.vy = 0.0f; + } + g.update_bounds(); +} + +// --------------------------------------------------------------------------- +// layout_random +// --------------------------------------------------------------------------- +void layout_random(GraphData& g, float spread) { + if (g.node_count <= 0) return; + for (int i = 0; i < g.node_count; ++i) { + GraphNode& n = g.nodes[i]; + if (n.flags & NF_PINNED) continue; + n.x = spread * (2.0f * (float)rand() / (float)RAND_MAX - 1.0f); + n.y = spread * (2.0f * (float)rand() / (float)RAND_MAX - 1.0f); + n.vx = 0.0f; + n.vy = 0.0f; + } + g.update_bounds(); +} + +// --------------------------------------------------------------------------- +// layout_radial — BFS desde root, anillos concentricos por hop +// --------------------------------------------------------------------------- +void layout_radial(GraphData& g, int root_node, float ring_spacing) { + if (g.node_count <= 0) return; + if (root_node < 0 || root_node >= g.node_count) root_node = 0; + + // Adyacencia no dirigida via CSR temporal — para grafos pequenios/medios + // basta con vector>. + std::vector> adj(g.node_count); + for (int e = 0; e < g.edge_count; ++e) { + int s = (int)g.edges[e].source; + int t = (int)g.edges[e].target; + if (s < 0 || s >= g.node_count) continue; + if (t < 0 || t >= g.node_count) continue; + adj[s].push_back(t); + adj[t].push_back(s); + } + + // BFS para asignar hop (-1 = no visitado todavia) + std::vector hop(g.node_count, -1); + hop[root_node] = 0; + std::queue q; + q.push(root_node); + int max_hop = 0; + while (!q.empty()) { + int u = q.front(); q.pop(); + for (int v : adj[u]) { + if (hop[v] == -1) { + hop[v] = hop[u] + 1; + if (hop[v] > max_hop) max_hop = hop[v]; + q.push(v); + } + } + } + + // Nodos no alcanzables van al hop max_hop + 1 + int orphan_hop = max_hop + 1; + bool has_orphans = false; + for (int i = 0; i < g.node_count; ++i) { + if (hop[i] == -1) { hop[i] = orphan_hop; has_orphans = true; } + } + + // Agrupar indices por hop + int total_hops = (has_orphans ? orphan_hop : max_hop) + 1; + std::vector> by_hop(total_hops); + for (int i = 0; i < g.node_count; ++i) by_hop[hop[i]].push_back(i); + + const float two_pi = 6.28318530718f; + for (int k = 0; k < total_hops; ++k) { + const auto& ring = by_hop[k]; + if (ring.empty()) continue; + if (k == 0) { + // root en el centro + GraphNode& n = g.nodes[ring[0]]; + if (!(n.flags & NF_PINNED)) { + n.x = 0.0f; n.y = 0.0f; n.vx = 0.0f; n.vy = 0.0f; + } + continue; + } + float radius = (float)k * ring_spacing; + int count = (int)ring.size(); + for (int j = 0; j < count; ++j) { + GraphNode& n = g.nodes[ring[j]]; + if (n.flags & NF_PINNED) continue; + float angle = two_pi * (float)j / (float)count; + n.x = radius * std::cos(angle); + n.y = radius * std::sin(angle); + n.vx = 0.0f; + n.vy = 0.0f; + } + } + g.update_bounds(); +} + +// --------------------------------------------------------------------------- +// layout_hierarchical — niveles por longest-path BFS + baricentro greedy +// --------------------------------------------------------------------------- +void layout_hierarchical(GraphData& g, int direction, + float layer_spacing, float node_spacing) { + if (g.node_count <= 0) return; + + const int N = g.node_count; + + // 1) Calcular in-degree para encontrar las raices + std::vector in_deg(N, 0); + std::vector> out_adj(N); + std::vector> in_adj(N); + for (int e = 0; e < g.edge_count; ++e) { + int s = (int)g.edges[e].source; + int t = (int)g.edges[e].target; + if (s < 0 || s >= N || t < 0 || t >= N) continue; + if (s == t) continue; + out_adj[s].push_back(t); + in_adj[t].push_back(s); + in_deg[t]++; + } + + // 2) Asignar nivel: longest-path desde nodos sin in-edges. BFS multi-source + // con relax: level[v] = max(level[v], level[u]+1). + std::vector level(N, 0); + std::vector order; order.reserve(N); + std::queue q; + for (int i = 0; i < N; ++i) { + if (in_deg[i] == 0) { q.push(i); } + } + // Si todo el grafo tiene ciclos (no hay raices), forzar nodo 0 como raiz + if (q.empty()) { q.push(0); level[0] = 0; } + + // BFS con propagacion. Para evitar bucles infinitos en grafos ciclicos, + // limitamos la profundidad a N. + std::vector visited(N, 0); + while (!q.empty()) { + int u = q.front(); q.pop(); + if (visited[u]++ > N) continue; + for (int v : out_adj[u]) { + int new_lv = level[u] + 1; + if (new_lv > level[v]) { + level[v] = new_lv; + if (level[v] < N) q.push(v); + } + } + } + + int max_level = 0; + for (int i = 0; i < N; ++i) { + if (level[i] > max_level) max_level = level[i]; + } + + // 3) Agrupar nodos por nivel + std::vector> by_level(max_level + 1); + for (int i = 0; i < N; ++i) by_level[level[i]].push_back(i); + + // 4) Reducir cruces: por cada nivel L > 0 ordenar por baricentro de los + // indices (en el array del nivel L-1) de sus padres. Greedy, no optimo. + std::vector pos_in_level(N, 0); + for (int j = 0; j < (int)by_level[0].size(); ++j) pos_in_level[by_level[0][j]] = j; + + for (int L = 1; L <= max_level; ++L) { + auto& cur = by_level[L]; + std::vector bary(cur.size(), 0.0f); + for (size_t i = 0; i < cur.size(); ++i) { + int v = cur[i]; + float sum = 0.0f; int cnt = 0; + for (int p : in_adj[v]) { + if (level[p] == L - 1) { + sum += (float)pos_in_level[p]; + cnt++; + } + } + bary[i] = (cnt > 0) ? (sum / (float)cnt) : (float)i; + } + // Ordenar `cur` por bary + std::vector idx(cur.size()); + for (size_t i = 0; i < idx.size(); ++i) idx[i] = i; + std::sort(idx.begin(), idx.end(), [&](size_t a, size_t b){ + if (bary[a] != bary[b]) return bary[a] < bary[b]; + return cur[a] < cur[b]; + }); + std::vector reordered(cur.size()); + for (size_t i = 0; i < idx.size(); ++i) reordered[i] = cur[idx[i]]; + cur = std::move(reordered); + for (int j = 0; j < (int)cur.size(); ++j) pos_in_level[cur[j]] = j; + } + + // 5) Posiciones. layer_axis = nivel * layer_spacing; transverse_axis = + // (j - (size-1)/2) * node_spacing (centrado). + auto place = [&](int node_idx, float layer_axis, float transverse_axis) { + GraphNode& n = g.nodes[node_idx]; + if (n.flags & NF_PINNED) return; + float lx = 0.0f, ly = 0.0f; + switch (direction) { + case 0: // LR + lx = layer_axis; ly = transverse_axis; break; + case 1: // RL + lx = -layer_axis; ly = transverse_axis; break; + case 2: // TB + lx = transverse_axis; ly = layer_axis; break; + case 3: // BT + lx = transverse_axis; ly = -layer_axis; break; + default: + lx = layer_axis; ly = transverse_axis; break; + } + n.x = lx; + n.y = ly; + n.vx = 0.0f; + n.vy = 0.0f; + }; + + // Centramos cada nivel en torno a 0 en el eje transversal y arrancamos en + // 0 en el eje de capas. + for (int L = 0; L <= max_level; ++L) { + const auto& cur = by_level[L]; + int sz = (int)cur.size(); + if (sz == 0) continue; + float layer_axis = (float)L * layer_spacing; + float t0 = -0.5f * (sz - 1) * node_spacing; + for (int j = 0; j < sz; ++j) { + place(cur[j], layer_axis, t0 + j * node_spacing); + } + } + g.update_bounds(); +} + +// --------------------------------------------------------------------------- +// layout_fixed — no-op. Existe para que el caller pueda usar "fixed" en un +// switch sin casos especiales. +// --------------------------------------------------------------------------- +void layout_fixed(GraphData& /*g*/) { + // intencionalmente vacio +} + +} // namespace graph diff --git a/cpp/functions/viz/graph_layouts.h b/cpp/functions/viz/graph_layouts.h new file mode 100644 index 00000000..601939b3 --- /dev/null +++ b/cpp/functions/viz/graph_layouts.h @@ -0,0 +1,51 @@ +#pragma once + +// Layouts estaticos (no iterativos) para GraphData. Todos respetan NF_PINNED: +// los nodos con ese flag conservan su posicion y velocidad. Las velocidades +// del resto se ponen a cero al aplicar un layout. +// +// Para layouts iterativos (force-directed) ver graph_force_layout.h / +// graph_force_layout_gpu.h. + +struct GraphData; + +namespace graph { + +// Cuadricula uniforme. spacing en world units. Coloca los nodos en columnas +// de ceil(sqrt(N)) hasta filas. Centro del grafo en (0,0). +void layout_grid (GraphData& graph, float spacing = 20.0f); + +// Circulo unico de radio `radius`. Centro en (0,0). +void layout_circular (GraphData& graph, float radius = 100.0f); + +// Posiciones aleatorias en [-spread, spread] x [-spread, spread]. Usa rand(); +// el caller puede llamar srand(seed) antes para reproducibilidad. +void layout_random (GraphData& graph, float spread = 200.0f); + +// Layout radial tipo arbol: BFS desde `root_node`. Hop k -> circulo de radio +// k * ring_spacing. Nodos del mismo hop se distribuyen uniformemente en su +// circulo. Nodos no alcanzables (componentes desconectadas) van al ultimo hop+1. +// Si `root_node` es invalido, usa el indice 0. +void layout_radial (GraphData& graph, int root_node = 0, + float ring_spacing = 80.0f); + +// Layout jerarquico estilo Sugiyama (heuristico). Asigna niveles via BFS +// desde nodos sin in-edges (rank por longest-path). Dentro de cada nivel +// ordena por baricentro respecto al nivel previo (greedy) para reducir +// cruces de aristas. +// +// direction: +// 0 = LR (left -> right, niveles en X creciente) +// 1 = RL (right -> left) +// 2 = TB (top -> bottom, niveles en Y creciente) +// 3 = BT (bottom -> top) +void layout_hierarchical(GraphData& graph, int direction = 0, + float layer_spacing = 120.0f, + float node_spacing = 60.0f); + +// No-op. Existe para que los callers puedan tratar "fixed" como un layout +// mas en un switch sin casos especiales. NO toca posiciones, velocidades ni +// flags. +void layout_fixed (GraphData& graph); + +} // namespace graph diff --git a/cpp/functions/viz/graph_layouts.md b/cpp/functions/viz/graph_layouts.md new file mode 100644 index 00000000..8b41c222 --- /dev/null +++ b/cpp/functions/viz/graph_layouts.md @@ -0,0 +1,126 @@ +--- +name: graph_layouts +kind: function +lang: cpp +domain: viz +version: "1.0.0" +purity: pure +signature: "void graph::layout_grid(GraphData&, float spacing); void graph::layout_circular(GraphData&, float radius); void graph::layout_random(GraphData&, float spread); void graph::layout_radial(GraphData&, int root_node, float ring_spacing); void graph::layout_hierarchical(GraphData&, int direction, float layer_spacing, float node_spacing); void graph::layout_fixed(GraphData&)" +description: "Conjunto de layouts estaticos (no iterativos) para GraphData: grid, circular, random, radial, hierarchical (Sugiyama heuristico), fixed. Todos respetan NF_PINNED." +tags: [graph, layout, static, radial, hierarchical, sugiyama, osint] +uses_functions: [] +uses_types: ["GraphData_cpp_viz"] +returns: [] +returns_optional: false +error_type: "" +imports: [] +tested: true +tests: ["grid centers and respects pin", "circular places on circle", "radial root at center, hop ring", "hierarchical levels by longest path", "fixed is no-op"] +test_file_path: "cpp/tests/test_graph_layouts.cpp" +file_path: "cpp/functions/viz/graph_layouts.cpp" +framework: imgui +params: + - name: graph + desc: "Referencia al grafo (GraphData) cuyos nodos se reposicionan in-place. Mutaciones: x, y, vx=0, vy=0 por nodo no pinned." + - name: spacing + desc: "(layout_grid) Distancia en world units entre celdas adyacentes de la cuadricula." + - name: radius + desc: "(layout_circular) Radio del circulo unico en world units." + - name: spread + desc: "(layout_random) Mitad del lado del cuadrado en el que se distribuyen aleatoriamente las posiciones (rango [-spread, spread])." + - name: root_node + desc: "(layout_radial) Indice del nodo raiz. BFS desde aqui asigna el hop. Si es invalido, usa 0." + - name: ring_spacing + desc: "(layout_radial) Distancia radial entre anillos de hops consecutivos." + - name: direction + desc: "(layout_hierarchical) Orientacion: 0=LR (left->right), 1=RL, 2=TB (top->bottom), 3=BT." + - name: layer_spacing + desc: "(layout_hierarchical) Distancia entre niveles consecutivos en el eje del layout." + - name: node_spacing + desc: "(layout_hierarchical) Distancia entre nodos del mismo nivel en el eje transversal." +output: "Ninguno (void). Los nodos no pinned se mueven in-place; nodos con NF_PINNED conservan posicion y velocidad." +notes: "Issue 0049i. layout_circular/layout_grid migrados desde graph_force_layout.cpp; los wrappers deprecados graph_layout_circular/graph_layout_grid siguen funcionando como compat hasta cierre de 0049." +--- + +# graph_layouts + +Funciones de layout estatico (instantaneo, sin iterar). Todas escriben las +posiciones de los nodos no pinned y cero las velocidades; los nodos con +`NF_PINNED` no se tocan. Util para inicializar un grafo antes del force layout +o para ofrecer alternativas de visualizacion en TUIs OSINT. + +## API + +```cpp +namespace graph { + void layout_grid (GraphData&, float spacing = 20.0f); + void layout_circular (GraphData&, float radius = 100.0f); + void layout_random (GraphData&, float spread = 200.0f); + void layout_radial (GraphData&, int root_node = 0, + float ring_spacing = 80.0f); + void layout_hierarchical(GraphData&, int direction = 0, + float layer_spacing = 120.0f, + float node_spacing = 60.0f); + void layout_fixed (GraphData&); // no-op +} +``` + +## Algoritmos + +### `layout_grid` +Cuadricula uniforme. `cols = ceil(sqrt(N))`, `rows = ceil(N/cols)`. Centro del +grafo en `(0,0)`. + +### `layout_circular` +Circulo unico. Cada nodo en el angulo `2*pi*i/N`. + +### `layout_random` +Posiciones uniformes en `[-spread, spread]^2`. Usa `rand()` — llamar +`srand(seed)` antes para reproducibilidad. + +### `layout_radial` +BFS no dirigido desde `root_node`. Cada hop `k` se coloca en un circulo de +radio `k * ring_spacing`. Nodos del mismo hop se distribuyen uniformemente +en su circulo. Las componentes desconectadas van a un anillo extra al final. +Util para vistas de "vecinos a N saltos" tipicas en OSINT. + +### `layout_hierarchical` +Sugiyama-style heuristico: +1. Niveles por longest-path BFS desde nodos sin in-edges. +2. Reduccion de cruces greedy: cada nivel `L>0` se reordena por el baricentro + de los indices de sus padres en el nivel `L-1`. +3. Posiciones: el nivel define el eje principal (X o Y segun `direction`), + los nodos dentro del nivel se centran en el eje transversal. + +No es Sugiyama optimo (eso es un problema NP-hard). Es suficientemente bueno +para grafos OSINT pequenios/medianos: jerarquias `Person -> Email -> Domain`, +arboles de dependencia, etc. + +### `layout_fixed` +No-op. Se mantiene en el conjunto para que un caller pueda escribir un switch +que cubra todos los modos sin un caso especial. + +## Composicion con graph_force_layout + +Tipico: layout estatico inicial -> force-directed para refinar. + +```cpp +graph::layout_circular(g, 200.0f); // arranque uniforme +ForceLayoutConfig cfg; +for (int i = 0; i < 300; ++i) { + graph_force_layout_step(g, cfg); +} +``` + +Para grafos jerarquicos donde no se quiere que el force layout deshaga la +estructura: pinnar nodos clave (`NF_PINNED`) tras `layout_hierarchical` y +dejar que el force solo refine los del medio. + +## Notas + +- Wrappers deprecados: `graph_layout_circular` y `graph_layout_grid` siguen en + `graph_force_layout.h` y delegan aqui. Codigo nuevo debe usar el namespace + `graph::`. +- `layout_radial` y `layout_hierarchical` construyen una lista de adyacencia + temporal `O(V+E)` por llamada. Para grafos enormes (>>1M aristas) considerar + cachear la adyacencia entre llamadas. diff --git a/cpp/functions/viz/graph_viewport.cpp b/cpp/functions/viz/graph_viewport.cpp index 021f987a..965d7ec4 100644 --- a/cpp/functions/viz/graph_viewport.cpp +++ b/cpp/functions/viz/graph_viewport.cpp @@ -5,6 +5,7 @@ #include "core/graph_spatial_hash.h" #include "imgui.h" +#include #include // snprintf #include // memset #include @@ -23,6 +24,16 @@ static void viewport_to_graph(float vx, float vy, gy = (vy - widget_y - widget_h * 0.5f) / zoom + cam_y; } +static void graph_to_viewport(float gx, float gy, + float widget_x, float widget_y, + float widget_w, float widget_h, + float cam_x, float cam_y, float zoom, + float& vx, float& vy) +{ + vx = (gx - cam_x) * zoom + widget_x + widget_w * 0.5f; + vy = (gy - cam_y) * zoom + widget_y + widget_h * 0.5f; +} + // --------------------------------------------------------------------------- // graph_viewport_fit // --------------------------------------------------------------------------- @@ -84,7 +95,7 @@ void graph_viewport_destroy(GraphViewportState& state) // --------------------------------------------------------------------------- bool graph_viewport(const char* id, GraphData& graph, GraphViewportState& state, - ImVec2 size) + ImVec2 size, const GraphViewportCallbacks& cb) { bool interacted = false; @@ -159,8 +170,13 @@ bool graph_viewport(const char* id, GraphData& graph, GraphViewportState& state, bool hovered = ImGui::IsItemHovered(); bool lm_down = ImGui::IsMouseDown(ImGuiMouseButton_Left); bool lm_click = ImGui::IsMouseClicked(ImGuiMouseButton_Left); + bool lm_release= ImGui::IsMouseReleased(ImGuiMouseButton_Left); + bool lm_dbl = ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left); bool mm_down = ImGui::IsMouseDown(ImGuiMouseButton_Middle); bool rm_down = ImGui::IsMouseDown(ImGuiMouseButton_Right); + bool rm_click = ImGui::IsMouseClicked(ImGuiMouseButton_Right); + bool shift = ImGui::GetIO().KeyShift; + bool ctrl = ImGui::GetIO().KeyCtrl; ImVec2 mouse_pos = ImGui::GetMousePos(); float mx = mouse_pos.x, my = mouse_pos.y; @@ -173,9 +189,11 @@ bool graph_viewport(const char* id, GraphData& graph, GraphViewportState& state, gx_mouse, gy_mouse); // ------------------------------------------------------------------- - // 5a. Pan (middle or right mouse drag) + // 5a. Pan (middle mouse drag, o right-button drag SIN nodo bajo cursor) // ------------------------------------------------------------------- - if (hovered && (mm_down || rm_down)) { + // El pan con boton derecho se inhibe si el right-click cae sobre un + // nodo: en ese caso queremos mostrar el menu contextual via callback. + if (hovered && (mm_down || (rm_down && state.hovered_node < 0))) { ImVec2 delta = ImGui::GetIO().MouseDelta; if (delta.x != 0.0f || delta.y != 0.0f) { state.cam_x -= delta.x / state.zoom; @@ -187,11 +205,6 @@ bool graph_viewport(const char* id, GraphData& graph, GraphViewportState& state, // ------------------------------------------------------------------- // 5b. Zoom (scroll wheel) // ------------------------------------------------------------------- - // Consumimos el wheel cuando esta sobre el canvas para que la ventana - // padre (BeginChild de la galeria, p.ej.) NO scrollee a la vez que - // hacemos zoom — sin esto la pagina entera se mueve mientras el grafo - // se acerca, sensacion incomoda. Tambien marcamos NoNav del item para - // que ImGui no intente keyboard-scroll al estar enfocado. if (hovered) { float wheel = ImGui::GetIO().MouseWheel; if (wheel != 0.0f) { @@ -200,7 +213,6 @@ bool graph_viewport(const char* id, GraphData& graph, GraphViewportState& state, if (new_zoom < state.zoom_min) new_zoom = state.zoom_min; if (new_zoom > state.zoom_max) new_zoom = state.zoom_max; - // Zoom toward cursor: keep gx_mouse/gy_mouse fixed in graph space float rel_x = (mx - widget_pos.x - w * 0.5f); float rel_y = (my - widget_pos.y - h * 0.5f); state.cam_x += rel_x / old_zoom - rel_x / new_zoom; @@ -208,8 +220,6 @@ bool graph_viewport(const char* id, GraphData& graph, GraphViewportState& state, state.zoom = new_zoom; interacted = true; - // Consumir el evento — ImGui::GetIO().MouseWheel a 0 evita que - // el padre lo procese. ImGui::GetIO().MouseWheel = 0.0f; } } @@ -217,9 +227,6 @@ bool graph_viewport(const char* id, GraphData& graph, GraphViewportState& state, // ------------------------------------------------------------------- // 5c. Hover — query nearest node // ------------------------------------------------------------------- - // Clear-then-set: limpiamos NF_HOVERED del nodo previo aunque el cursor - // ya no este sobre el viewport (cambio de target). Esto evita que el - // flag persista cuando el usuario sale del widget. int prev_hovered = state.hovered_node; if (prev_hovered >= 0 && prev_hovered < graph.node_count) { graph.nodes[prev_hovered].flags &= ~NF_HOVERED; @@ -236,48 +243,129 @@ bool graph_viewport(const char* id, GraphData& graph, GraphViewportState& state, } // ------------------------------------------------------------------- - // 5d. Node drag (left mouse down on a node) + // 5d. Lasso (shift + drag sobre area vacia) // ------------------------------------------------------------------- - if (hovered && lm_down) { + // Activacion: shift presionado + lm_click (no haciendo click sobre nodo). + if (hovered && lm_click && shift && state.hovered_node < 0 + && state.drag_node == -1 && !state.lasso_active) { + state.lasso_active = true; + state.lasso_start = ImVec2(gx_mouse, gy_mouse); + state.lasso_end = ImVec2(gx_mouse, gy_mouse); + } + if (state.lasso_active) { + state.lasso_end = ImVec2(gx_mouse, gy_mouse); + if (lm_release) { + // Calcular AABB en world space y anadir nodos contenidos a la seleccion + float x0 = std::min(state.lasso_start.x, state.lasso_end.x); + float x1 = std::max(state.lasso_start.x, state.lasso_end.x); + float y0 = std::min(state.lasso_start.y, state.lasso_end.y); + float y1 = std::max(state.lasso_start.y, state.lasso_end.y); + for (int i = 0; i < graph.node_count; ++i) { + const GraphNode& n = graph.nodes[i]; + if (n.x >= x0 && n.x <= x1 && n.y >= y0 && n.y <= y1) { + graph_viewport_add_to_selection(graph, state, i); + } + } + state.lasso_active = false; + interacted = true; + } + } + + // ------------------------------------------------------------------- + // 5e. Node drag (left mouse down on a node) + // ------------------------------------------------------------------- + // Si el nodo bajo el cursor esta seleccionado, arrastra TODA la seleccion; + // en otro caso solo el nodo bajo el cursor. + if (hovered && lm_down && !state.lasso_active) { if (state.drag_node == -1 && state.hovered_node >= 0) { - state.drag_node = state.hovered_node; - state.is_dragging = true; - } - } else { - // Release drag - if (state.drag_node >= 0 && state.drag_node < graph.node_count) { - graph.nodes[state.drag_node].flags &= ~NF_PINNED; + state.drag_node = state.hovered_node; + state.is_dragging = true; + state.drag_anchor_x = gx_mouse; + state.drag_anchor_y = gy_mouse; + // Si el nodo no esta en la seleccion y no es un toggle, lo + // anadimos para que el drag mueva al menos ese nodo. + if (!graph_viewport_is_selected(state, state.drag_node)) { + if (!ctrl) { + // Reemplaza seleccion: drag de nodo no seleccionado + graph_viewport_clear_selection(graph, state); + } + graph_viewport_add_to_selection(graph, state, state.drag_node); + } + // Fijar todos los nodos seleccionados como pinned mientras dura + // el drag — el force layout no los movera. + for (int idx : state.selection) { + if (idx >= 0 && idx < graph.node_count) { + graph.nodes[idx].flags |= NF_PINNED; + } + } } + } else if (state.drag_node >= 0) { + // Release: mantener pinned (deja al usuario re-arrastrar). Quien + // quiera "soltar" el pin puede hacer Esc o un re-click sobre area + // vacia para limpiar seleccion. state.drag_node = -1; state.is_dragging = false; } if (state.drag_node >= 0 && state.drag_node < graph.node_count) { - GraphNode& n = graph.nodes[state.drag_node]; - n.x = gx_mouse; - n.y = gy_mouse; - n.vx = 0.0f; - n.vy = 0.0f; - n.flags |= NF_PINNED; - interacted = true; + // delta en world space respecto al frame previo + float dx = ImGui::GetIO().MouseDelta.x / state.zoom; + float dy = ImGui::GetIO().MouseDelta.y / state.zoom; + if (dx != 0.0f || dy != 0.0f) { + for (int idx : state.selection) { + if (idx >= 0 && idx < graph.node_count) { + GraphNode& n = graph.nodes[idx]; + n.x += dx; + n.y += dy; + n.vx = 0.0f; + n.vy = 0.0f; + n.flags |= NF_PINNED; + } + } + interacted = true; + } } // ------------------------------------------------------------------- - // 5e. Click — select node (clear-then-set NF_SELECTED) + // 5f. Click — seleccion (single / ctrl-toggle) // ------------------------------------------------------------------- - if (hovered && lm_click && state.drag_node == -1) { - if (state.selected_node >= 0 && state.selected_node < graph.node_count) { - graph.nodes[state.selected_node].flags &= ~NF_SELECTED; - } - state.selected_node = state.hovered_node; - if (state.selected_node >= 0 && state.selected_node < graph.node_count) { - graph.nodes[state.selected_node].flags |= NF_SELECTED; + // Solo procesar clicks que NO son inicio de drag (es decir: el lm_click + // ya pudo haber lanzado un drag arriba). La rama drag arriba ya gestiona + // la seleccion en su caso. Aqui nos ocupa el caso de click en area vacia + // o click puro sobre nodo sin shift/lasso. + if (hovered && lm_click && !shift && state.drag_node == -1 + && !state.lasso_active) { + if (state.hovered_node >= 0) { + if (ctrl) { + graph_viewport_toggle_selection(graph, state, state.hovered_node); + } else { + graph_viewport_clear_selection(graph, state); + graph_viewport_add_to_selection(graph, state, state.hovered_node); + } + } else { + graph_viewport_clear_selection(graph, state); } interacted = true; } // ------------------------------------------------------------------- - // 5f. Keyboard shortcuts (only when widget is active/hovered) + // 5g. Right-click sobre nodo -> on_context_menu + // ------------------------------------------------------------------- + if (hovered && rm_click && state.hovered_node >= 0 && cb.on_context_menu) { + cb.on_context_menu(state.hovered_node, mouse_pos, cb.user); + interacted = true; + } + + // ------------------------------------------------------------------- + // 5h. Double-click -> on_double_click + // ------------------------------------------------------------------- + if (hovered && lm_dbl && state.hovered_node >= 0 && cb.on_double_click) { + cb.on_double_click(state.hovered_node, cb.user); + interacted = true; + } + + // ------------------------------------------------------------------- + // 5i. Keyboard shortcuts (only when widget hovered) // ------------------------------------------------------------------- if (hovered) { if (ImGui::IsKeyPressed(ImGuiKey_Space)) { @@ -287,6 +375,10 @@ bool graph_viewport(const char* id, GraphData& graph, GraphViewportState& state, graph_viewport_fit(graph, state); interacted = true; } + if (ImGui::IsKeyPressed(ImGuiKey_Escape)) { + graph_viewport_clear_selection(graph, state); + interacted = true; + } } // ------------------------------------------------------------------- @@ -304,17 +396,35 @@ bool graph_viewport(const char* id, GraphData& graph, GraphViewportState& state, (ImTextureID)(intptr_t)tex_id, widget_pos, ImVec2(widget_pos.x + w, widget_pos.y + h), - ImVec2(0.0f, 1.0f), // UV top-left (flipped Y) - ImVec2(1.0f, 0.0f) // UV bottom-right + ImVec2(0.0f, 1.0f), + ImVec2(1.0f, 0.0f) ); // ------------------------------------------------------------------- - // 8. Tooltip on hovered node + // 7b. Lasso visual feedback (rect en pantalla) // ------------------------------------------------------------------- - if (state.hovered_node >= 0 && state.hovered_node < graph.node_count) { + if (state.lasso_active) { + float vx0, vy0, vx1, vy1; + graph_to_viewport(state.lasso_start.x, state.lasso_start.y, + widget_pos.x, widget_pos.y, w, h, + state.cam_x, state.cam_y, state.zoom, vx0, vy0); + graph_to_viewport(state.lasso_end.x, state.lasso_end.y, + widget_pos.x, widget_pos.y, w, h, + state.cam_x, state.cam_y, state.zoom, vx1, vy1); + ImVec2 a(std::min(vx0, vx1), std::min(vy0, vy1)); + ImVec2 b(std::max(vx0, vx1), std::max(vy0, vy1)); + draw_list->AddRectFilled(a, b, IM_COL32(80, 140, 220, 40)); + draw_list->AddRect (a, b, IM_COL32(120, 180, 240, 200)); + } + + // ------------------------------------------------------------------- + // 8. Tooltip on hovered node (suprimido durante drag o lasso para no + // tapar la accion) + // ------------------------------------------------------------------- + if (state.hovered_node >= 0 && state.hovered_node < graph.node_count + && !state.is_dragging && !state.lasso_active) { const GraphNode& n = graph.nodes[state.hovered_node]; - // Count degree int degree = 0; for (int i = 0; i < graph.edge_count; ++i) { if ((int)graph.edges[i].source == state.hovered_node || @@ -340,11 +450,12 @@ bool graph_viewport(const char* id, GraphData& graph, GraphViewportState& state, // 9. Status bar overlay // ------------------------------------------------------------------- { - char status[128]; + char status[160]; snprintf(status, sizeof(status), - "Nodes: %d | Edges: %d | Zoom: %.2fx | Energy: %.4f | [Space] layout [F] fit", + "Nodes: %d | Edges: %d | Sel: %zu | Zoom: %.2fx | Energy: %.4f | " + "[Space] layout [F] fit [Esc] clear [Shift+drag] lasso [Ctrl+click] toggle", graph.node_count, graph.edge_count, - state.zoom, state.layout_energy); + state.selection.size(), state.zoom, state.layout_energy); ImVec2 text_pos = ImVec2(widget_pos.x + 6.0f, widget_pos.y + h - 18.0f); draw_list->AddText(text_pos, IM_COL32(180, 180, 180, 200), status); diff --git a/cpp/functions/viz/graph_viewport.h b/cpp/functions/viz/graph_viewport.h index 5ac67ff3..e99518f4 100644 --- a/cpp/functions/viz/graph_viewport.h +++ b/cpp/functions/viz/graph_viewport.h @@ -1,10 +1,20 @@ #pragma once #include "imgui.h" +#include struct GraphData; struct GraphRenderer; struct SpatialHash; +// Callbacks opcionales del viewport. Se invocan dentro del frame ImGui — el +// caller puede abrir popups (`ImGui::OpenPopup`) o cambiar estado de la app. +// Cualquier puntero NULL se ignora. +struct GraphViewportCallbacks { + void (*on_context_menu)(int node_idx, ImVec2 screen_pos, void* user) = nullptr; + void (*on_double_click)(int node_idx, void* user) = nullptr; + void* user = nullptr; +}; + // Persistent state for graph_viewport widget. Create one per viewport and keep // alive across frames. struct GraphViewportState { @@ -15,9 +25,20 @@ struct GraphViewportState { // Interaction result (read after calling graph_viewport each frame) int hovered_node = -1; // node index under cursor, -1 if none - int selected_node = -1; // last clicked node index, -1 if none + int selected_node = -1; // legacy: ultimo nodo clicado, -1 si ninguno bool is_dragging = false; + // Multi-seleccion (issue 0049i). `selection` mantiene los indices de + // todos los nodos seleccionados (orden de insercion). El flag NF_SELECTED + // se mantiene sincronizado con esta lista. + std::vector selection; + + // Lasso (issue 0049i). Activado durante Shift+Drag sobre area vacia. + // Coordenadas en world space. + bool lasso_active = false; + ImVec2 lasso_start = ImVec2(0.0f, 0.0f); + ImVec2 lasso_end = ImVec2(0.0f, 0.0f); + // Layout bool layout_running = true; // animate force layout each frame float layout_energy = 0.0f; // kinetic energy from last step @@ -30,8 +51,12 @@ struct GraphViewportState { // Widget pixel dimensions tracked for resize detection int render_w = 0, render_h = 0; - // Node being dragged (-1 = none) + // Node being dragged (-1 = none). Si hay multi-seleccion, todos los + // seleccionados se mueven solidariamente con el delta del raton. int drag_node = -1; + // Posicion world del raton al iniciar el drag (issue 0049i). + float drag_anchor_x = 0.0f; + float drag_anchor_y = 0.0f; }; // Main viewport widget. Call every ImGui frame. @@ -39,12 +64,21 @@ struct GraphViewportState { // graph: mutable graph data (node positions updated on drag) // state: persistent state (camera, selection, GPU renderer); must outlive frames // size: widget size in pixels — ImVec2(0,0) uses all available space +// cb: callbacks opcionales (right-click, double-click). Default = sin callbacks. // Returns true if any user interaction occurred (hover, click, drag, zoom). bool graph_viewport(const char* id, GraphData& graph, GraphViewportState& state, - ImVec2 size = ImVec2(0.0f, 0.0f)); + ImVec2 size = ImVec2(0.0f, 0.0f), + const GraphViewportCallbacks& cb = GraphViewportCallbacks{}); // Release GPU resources. Call once when done with the viewport. void graph_viewport_destroy(GraphViewportState& state); // Fit camera to current graph bounds with 10% padding. void graph_viewport_fit(GraphData& graph, GraphViewportState& state); + +// Helpers de multi-seleccion (puros respecto al state). Mantienen +// NF_SELECTED sincronizado. +void graph_viewport_clear_selection(GraphData& graph, GraphViewportState& state); +void graph_viewport_add_to_selection(GraphData& graph, GraphViewportState& state, int node_idx); +void graph_viewport_toggle_selection(GraphData& graph, GraphViewportState& state, int node_idx); +bool graph_viewport_is_selected(const GraphViewportState& state, int node_idx); diff --git a/cpp/functions/viz/graph_viewport.md b/cpp/functions/viz/graph_viewport.md index 217175cb..abefdfbc 100644 --- a/cpp/functions/viz/graph_viewport.md +++ b/cpp/functions/viz/graph_viewport.md @@ -3,20 +3,20 @@ name: graph_viewport kind: component lang: cpp domain: viz -version: "1.1.0" +version: "1.2.0" purity: impure signature: "bool graph_viewport(const char* id, GraphData& graph, GraphViewportState& state, ImVec2 size)" description: "Widget ImGui completo para visualizacion interactiva de grafos con pan, zoom, hover, seleccion y layout en vivo" -tags: [graph, viewport, imgui, interactive, pan, zoom, dashboard] +tags: [graph, viewport, imgui, interactive, pan, zoom, dashboard, lasso, multi-select] uses_functions: ["graph_force_layout_cpp_viz", "graph_renderer_cpp_viz", "graph_spatial_hash_cpp_core"] +tested: true +tests: ["selection add/clear/toggle/is_selected", "out-of-range indices ignored"] +test_file_path: "cpp/tests/test_graph_viewport.cpp" uses_types: ["GraphData_cpp_viz"] returns: [] returns_optional: false error_type: "error_go_core" imports: [imgui] -tested: false -tests: [] -test_file_path: "" file_path: "cpp/functions/viz/graph_viewport.cpp" framework: imgui props: @@ -36,6 +36,10 @@ props: type: "ImVec2" required: false description: "Tamanio del widget en pixeles. ImVec2(0,0) usa todo el espacio disponible." + - name: cb + type: "GraphViewportCallbacks" + required: false + description: "Callbacks opcionales para right-click (on_context_menu) y double-click (on_double_click). Se invocan dentro del frame ImGui." emits: [] has_state: true params: @@ -47,7 +51,9 @@ params: desc: "Estado persistente: camara (cam_x, cam_y, zoom), nodo seleccionado/hovereado, renderer GPU, spatial hash. Alojado por el caller." - name: size desc: "Tamanio del widget en pixeles. (0,0) ocupa todo el espacio disponible en la ventana ImGui." -output: "true si hubo alguna interaccion del usuario en el frame actual (hover, click, drag, zoom, teclado)" + - name: cb + desc: "Callbacks opcionales: on_context_menu(idx, screen_pos, user) en right-click; on_double_click(idx, user) en doble click. Se invocan dentro del frame ImGui — el caller puede llamar OpenPopup." +output: "true si hubo alguna interaccion del usuario en el frame actual (hover, click, drag, zoom, teclado, lasso, callbacks)" --- # graph_viewport @@ -86,10 +92,15 @@ La camara usa coordenadas del espacio del grafo: | Accion | Control | |--------|---------| -| Pan | Boton medio o derecho + arrastrar | +| Pan | Boton medio o derecho + arrastrar (sobre area vacia) | | Zoom | Rueda del raton (hacia el cursor) | -| Seleccionar nodo | Click izquierdo | -| Arrastrar nodo | Click izquierdo sobre nodo | +| Seleccionar nodo (single) | Click izquierdo sobre nodo | +| Toggle nodo en seleccion | Ctrl + Click izquierdo | +| Lasso (multi-seleccion) | Shift + Click izquierdo + arrastrar sobre area vacia | +| Arrastrar seleccion entera | Click izquierdo sobre nodo seleccionado + arrastrar | +| Menu contextual | Click derecho sobre nodo (callback `on_context_menu`) | +| Activar (double-click) | Doble click sobre nodo (callback `on_double_click`) | +| Limpiar seleccion | Esc, o click en area vacia | | Toggle layout | Barra espaciadora | | Fit camara | F | @@ -113,6 +124,7 @@ El renderer OpenGL y el spatial hash se crean en el primer frame. La camara se a ## Notas de version +- **v1.2** (2026-04-29, issue 0049i): multi-seleccion con `state.selection`, lasso (Shift+Drag sobre area vacia), drag de seleccion entera (todos los nodos seleccionados pinnean y se mueven juntos), Ctrl+click toggle, Esc limpia seleccion. Callbacks opcionales `GraphViewportCallbacks` para right-click (menu contextual) y double-click. Los nodos arrastrados se quedan pinned al soltar. Tooltip suprimido durante drag/lasso. Helpers expuestos: `graph_viewport_clear_selection`, `graph_viewport_add_to_selection`, `graph_viewport_toggle_selection`, `graph_viewport_is_selected`. Tests: `cpp/tests/test_graph_viewport.cpp`. - **v1.1** (2026-04-29, issue 0049e): adapta el viewport al modelo extendido. Hover/seleccion ahora se publican tambien como `flags |= NF_HOVERED` / `NF_SELECTED` en el grafo (clear-then-set) — los `state.hovered_node` / `selected_node` siguen siendo la API estable. El drag usa `flags |= NF_PINNED` en lugar del campo `pinned` desaparecido. El tooltip muestra `Type` (nombre del EntityType si esta) y `user_data` en lugar de `community`/`value`/`label`/`id`. ## Notas de implementacion diff --git a/cpp/functions/viz/graph_viewport_selection.cpp b/cpp/functions/viz/graph_viewport_selection.cpp new file mode 100644 index 00000000..b1f17bf8 --- /dev/null +++ b/cpp/functions/viz/graph_viewport_selection.cpp @@ -0,0 +1,45 @@ +// Helpers de multi-seleccion del viewport (issue 0049i). Logica pura sobre +// GraphData + GraphViewportState — sin ImGui ni OpenGL. Vive en su propio TU +// para que los tests unitarios puedan ejercerla sin pintar nada. + +#include "viz/graph_viewport.h" +#include "viz/graph_types.h" + +#include + +void graph_viewport_clear_selection(GraphData& graph, GraphViewportState& state) { + for (int idx : state.selection) { + if (idx >= 0 && idx < graph.node_count) { + graph.nodes[idx].flags &= ~NF_SELECTED; + } + } + state.selection.clear(); + state.selected_node = -1; +} + +bool graph_viewport_is_selected(const GraphViewportState& state, int node_idx) { + return std::find(state.selection.begin(), state.selection.end(), node_idx) + != state.selection.end(); +} + +void graph_viewport_add_to_selection(GraphData& graph, GraphViewportState& state, int node_idx) { + if (node_idx < 0 || node_idx >= graph.node_count) return; + if (graph_viewport_is_selected(state, node_idx)) return; + state.selection.push_back(node_idx); + graph.nodes[node_idx].flags |= NF_SELECTED; + state.selected_node = node_idx; +} + +void graph_viewport_toggle_selection(GraphData& graph, GraphViewportState& state, int node_idx) { + if (node_idx < 0 || node_idx >= graph.node_count) return; + auto it = std::find(state.selection.begin(), state.selection.end(), node_idx); + if (it != state.selection.end()) { + state.selection.erase(it); + graph.nodes[node_idx].flags &= ~NF_SELECTED; + state.selected_node = state.selection.empty() ? -1 : state.selection.back(); + } else { + state.selection.push_back(node_idx); + graph.nodes[node_idx].flags |= NF_SELECTED; + state.selected_node = node_idx; + } +} diff --git a/cpp/tests/CMakeLists.txt b/cpp/tests/CMakeLists.txt index c21f8578..c7caca29 100644 --- a/cpp/tests/CMakeLists.txt +++ b/cpp/tests/CMakeLists.txt @@ -64,6 +64,7 @@ add_fn_test(test_icon_button test_icon_button.cpp) add_fn_test(test_graph_pack_rgba8 test_graph_pack_rgba8.cpp) add_fn_test(test_graph_should_pause test_graph_should_pause.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../functions/viz/graph_force_layout.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../functions/viz/graph_layouts.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../functions/viz/graph_types.cpp) # --- Issue 0049d — vertex pulling edge buffer (logica solo, sin GL) -------- @@ -80,6 +81,21 @@ add_fn_test(test_graph_sources test_graph_sources.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../functions/viz/graph_types.cpp) target_link_libraries(test_graph_sources PRIVATE SQLite::SQLite3) +# --- Issue 0049i — graph_layouts (radial / hierarchical / fixed / etc) ----- +add_fn_test(test_graph_layouts test_graph_layouts.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../functions/viz/graph_layouts.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../functions/viz/graph_types.cpp) + +# --- Issue 0049i — graph_viewport selection helpers (logica pura sin GL) --- +# Solo cubre graph_viewport_selection.cpp; el widget completo se prueba en +# primitives_gallery + golden image diff. graph_viewport.h incluye ImVec2, +# por eso anadimos cpp/vendor/imgui al include path. +add_fn_test(test_graph_viewport test_graph_viewport.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../functions/viz/graph_viewport_selection.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../functions/viz/graph_types.cpp) +target_include_directories(test_graph_viewport PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/../vendor/imgui) + # --- Issue 0049h — graph_force_layout_gpu (compute + spatial hash) ---------- # El test crea una ventana GLFW oculta a 4.3 core; si glfwInit/window/context # fallan (CI sin DISPLAY, Mesa sin compute), el test SKIPea. Linkamos contra @@ -87,6 +103,7 @@ target_link_libraries(test_graph_sources PRIVATE SQLite::SQLite3) add_fn_test(test_graph_force_layout_gpu test_graph_force_layout_gpu.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../functions/viz/graph_force_layout_gpu.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../functions/viz/graph_force_layout.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../functions/viz/graph_layouts.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../functions/viz/graph_types.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../functions/gfx/gl_loader.cpp) if(WIN32) diff --git a/cpp/tests/golden/gl_texture.png b/cpp/tests/golden/gl_texture.png index 1ce01b80..9ab4cac4 100644 Binary files a/cpp/tests/golden/gl_texture.png and b/cpp/tests/golden/gl_texture.png differ diff --git a/cpp/tests/golden/graph_styles.png b/cpp/tests/golden/graph_styles.png new file mode 100644 index 00000000..9094b8ee Binary files /dev/null and b/cpp/tests/golden/graph_styles.png differ diff --git a/cpp/tests/golden/graph_viewport.png b/cpp/tests/golden/graph_viewport.png index 4d523f6f..f7ebf7a9 100644 Binary files a/cpp/tests/golden/graph_viewport.png and b/cpp/tests/golden/graph_viewport.png differ diff --git a/cpp/tests/golden/scatter_3d.png b/cpp/tests/golden/scatter_3d.png index 2e8ba850..b8c319b2 100644 Binary files a/cpp/tests/golden/scatter_3d.png and b/cpp/tests/golden/scatter_3d.png differ diff --git a/cpp/tests/golden/shader_canvas.png b/cpp/tests/golden/shader_canvas.png index 8a4bb4f9..a9d0b8c8 100644 Binary files a/cpp/tests/golden/shader_canvas.png and b/cpp/tests/golden/shader_canvas.png differ diff --git a/cpp/tests/golden/sql_workbench.png b/cpp/tests/golden/sql_workbench.png index 5b7c208a..829e0909 100644 Binary files a/cpp/tests/golden/sql_workbench.png and b/cpp/tests/golden/sql_workbench.png differ diff --git a/cpp/tests/golden/timeline.png b/cpp/tests/golden/timeline.png index b955bd20..a70946e0 100644 Binary files a/cpp/tests/golden/timeline.png and b/cpp/tests/golden/timeline.png differ diff --git a/cpp/tests/golden/tween.png b/cpp/tests/golden/tween.png index 9ffed7a9..64b9ce63 100644 Binary files a/cpp/tests/golden/tween.png and b/cpp/tests/golden/tween.png differ diff --git a/cpp/tests/test_graph_layouts.cpp b/cpp/tests/test_graph_layouts.cpp new file mode 100644 index 00000000..2426aa21 --- /dev/null +++ b/cpp/tests/test_graph_layouts.cpp @@ -0,0 +1,246 @@ +// Unit tests for graph_layouts (issue 0049i). +// Cubre los seis layouts estaticos: grid, circular, random, radial, +// hierarchical, fixed. Verifica: +// - Centrado y geometria basica de los layouts geometricos. +// - NF_PINNED se respeta (nodos pinned no se mueven). +// - radial coloca el root en (0,0) y los hops en anillos correctos. +// - hierarchical asigna niveles por longest-path. +// - fixed es no-op. + +#define CATCH_CONFIG_MAIN +#include "catch_amalgamated.hpp" + +#include "viz/graph_layouts.h" +#include "viz/graph_types.h" + +#include +#include + +namespace { + +// Helper: construir un GraphData a partir de vectores +struct TestGraph { + std::vector nodes; + std::vector edges; + GraphData data{}; + + void make(int N) { + nodes.clear(); + edges.clear(); + for (int i = 0; i < N; ++i) { + nodes.push_back(graph_node(0.0f, 0.0f)); + } + sync(); + } + + void add_edge(uint32_t s, uint32_t t) { + edges.push_back(graph_edge(s, t)); + sync(); + } + + void sync() { + data.nodes = nodes.data(); + data.node_count = (int)nodes.size(); + data.node_capacity = (int)nodes.capacity(); + data.edges = edges.data(); + data.edge_count = (int)edges.size(); + data.edge_capacity = (int)edges.capacity(); + data.types = nullptr; data.type_count = 0; + data.rel_types = nullptr; data.rel_type_count = 0; + } +}; + +} // namespace + +TEST_CASE("layout_grid respects pin and centers", "[viz][layouts][grid]") { + TestGraph g; g.make(9); + // Pin nodo 0 con posicion deliberadamente fuera de la cuadricula. + g.nodes[0].x = 999.0f; + g.nodes[0].y = 999.0f; + g.nodes[0].flags |= NF_PINNED; + g.sync(); + + graph::layout_grid(g.data, 10.0f); + + // Pinned no se ha movido + REQUIRE(g.nodes[0].x == Catch::Approx(999.0f)); + REQUIRE(g.nodes[0].y == Catch::Approx(999.0f)); + + // Resto: 9 nodos con cols=3, rows=3 -> centro en (0,0) + // El nodo 4 esta en (col=1, row=1) -> exactamente (0,0) + REQUIRE(g.nodes[4].x == Catch::Approx(0.0f)); + REQUIRE(g.nodes[4].y == Catch::Approx(0.0f)); + + // Velocidades a cero + REQUIRE(g.nodes[1].vx == 0.0f); + REQUIRE(g.nodes[1].vy == 0.0f); +} + +TEST_CASE("layout_circular places nodes on circle", "[viz][layouts][circular]") { + TestGraph g; g.make(8); + graph::layout_circular(g.data, 50.0f); + + for (int i = 0; i < 8; ++i) { + float r = std::sqrt(g.nodes[i].x * g.nodes[i].x + + g.nodes[i].y * g.nodes[i].y); + REQUIRE(r == Catch::Approx(50.0f).margin(1e-3f)); + } +} + +TEST_CASE("layout_circular respects NF_PINNED", "[viz][layouts][circular]") { + TestGraph g; g.make(4); + g.nodes[2].x = 7.0f; + g.nodes[2].y = -3.0f; + g.nodes[2].flags |= NF_PINNED; + g.sync(); + + graph::layout_circular(g.data, 100.0f); + + REQUIRE(g.nodes[2].x == Catch::Approx(7.0f)); + REQUIRE(g.nodes[2].y == Catch::Approx(-3.0f)); +} + +TEST_CASE("layout_random keeps positions in [-spread, spread]", "[viz][layouts][random]") { + TestGraph g; g.make(50); + graph::layout_random(g.data, 25.0f); + for (int i = 0; i < g.data.node_count; ++i) { + REQUIRE(g.nodes[i].x >= -25.0f); + REQUIRE(g.nodes[i].x <= 25.0f); + REQUIRE(g.nodes[i].y >= -25.0f); + REQUIRE(g.nodes[i].y <= 25.0f); + REQUIRE(g.nodes[i].vx == 0.0f); + REQUIRE(g.nodes[i].vy == 0.0f); + } +} + +TEST_CASE("layout_radial root at center, neighbors on first ring", "[viz][layouts][radial]") { + // Estrella de 5 puntas: nodo 0 -> nodos 1..5 + TestGraph g; g.make(6); + for (int i = 1; i <= 5; ++i) g.add_edge(0, i); + + graph::layout_radial(g.data, /*root=*/0, /*ring=*/100.0f); + + // root en (0,0) + REQUIRE(g.nodes[0].x == Catch::Approx(0.0f).margin(1e-3f)); + REQUIRE(g.nodes[0].y == Catch::Approx(0.0f).margin(1e-3f)); + + // Vecinos a distancia ~100 + for (int i = 1; i <= 5; ++i) { + float r = std::sqrt(g.nodes[i].x * g.nodes[i].x + + g.nodes[i].y * g.nodes[i].y); + REQUIRE(r == Catch::Approx(100.0f).margin(1e-3f)); + } +} + +TEST_CASE("layout_radial places hop2 on second ring", "[viz][layouts][radial]") { + // Cadena: 0 -> 1 -> 2 -> 3 + TestGraph g; g.make(4); + g.add_edge(0, 1); + g.add_edge(1, 2); + g.add_edge(2, 3); + + graph::layout_radial(g.data, 0, 50.0f); + + auto radius = [&](int i) { + return std::sqrt(g.nodes[i].x * g.nodes[i].x + + g.nodes[i].y * g.nodes[i].y); + }; + REQUIRE(radius(0) == Catch::Approx(0.0f).margin(1e-3f)); + REQUIRE(radius(1) == Catch::Approx(50.0f).margin(1e-3f)); + REQUIRE(radius(2) == Catch::Approx(100.0f).margin(1e-3f)); + REQUIRE(radius(3) == Catch::Approx(150.0f).margin(1e-3f)); +} + +TEST_CASE("layout_radial respects pin", "[viz][layouts][radial]") { + TestGraph g; g.make(3); + g.add_edge(0, 1); + g.add_edge(0, 2); + g.nodes[1].x = 42.0f; g.nodes[1].y = 7.0f; + g.nodes[1].flags |= NF_PINNED; + g.sync(); + + graph::layout_radial(g.data, 0, 50.0f); + REQUIRE(g.nodes[1].x == Catch::Approx(42.0f)); + REQUIRE(g.nodes[1].y == Catch::Approx(7.0f)); +} + +TEST_CASE("layout_hierarchical levels by longest path (LR)", "[viz][layouts][hierarchical]") { + // DAG: 0 -> 1, 0 -> 2, 1 -> 3, 2 -> 3 (diamond) + // longest-path levels: 0=0, 1=1, 2=1, 3=2 + TestGraph g; g.make(4); + g.add_edge(0, 1); + g.add_edge(0, 2); + g.add_edge(1, 3); + g.add_edge(2, 3); + + graph::layout_hierarchical(g.data, /*direction=*/0, + /*layer=*/100.0f, + /*node=*/40.0f); + + // Nivel 0 (nodo 0) en x=0 + REQUIRE(g.nodes[0].x == Catch::Approx(0.0f).margin(1e-3f)); + // Nivel 1 (nodos 1,2) en x=100 + REQUIRE(g.nodes[1].x == Catch::Approx(100.0f).margin(1e-3f)); + REQUIRE(g.nodes[2].x == Catch::Approx(100.0f).margin(1e-3f)); + // Nivel 2 (nodo 3) en x=200 + REQUIRE(g.nodes[3].x == Catch::Approx(200.0f).margin(1e-3f)); +} + +TEST_CASE("layout_hierarchical TB swaps axes", "[viz][layouts][hierarchical]") { + TestGraph g; g.make(3); + g.add_edge(0, 1); + g.add_edge(1, 2); + + graph::layout_hierarchical(g.data, /*direction=*/2, + /*layer=*/50.0f, + /*node=*/30.0f); + + // TB: y crece con el nivel, x es transverse + REQUIRE(g.nodes[0].y == Catch::Approx(0.0f).margin(1e-3f)); + REQUIRE(g.nodes[1].y == Catch::Approx(50.0f).margin(1e-3f)); + REQUIRE(g.nodes[2].y == Catch::Approx(100.0f).margin(1e-3f)); +} + +TEST_CASE("layout_hierarchical respects pin", "[viz][layouts][hierarchical]") { + TestGraph g; g.make(3); + g.add_edge(0, 1); + g.add_edge(1, 2); + g.nodes[1].x = -999.0f; g.nodes[1].y = 555.0f; + g.nodes[1].flags |= NF_PINNED; + g.sync(); + + graph::layout_hierarchical(g.data, 0, 100.0f, 40.0f); + + REQUIRE(g.nodes[1].x == Catch::Approx(-999.0f)); + REQUIRE(g.nodes[1].y == Catch::Approx(555.0f)); +} + +TEST_CASE("layout_fixed is a no-op", "[viz][layouts][fixed]") { + TestGraph g; g.make(5); + for (int i = 0; i < 5; ++i) { + g.nodes[i].x = (float)(i * 10); + g.nodes[i].y = (float)(i * -3); + g.nodes[i].vx = 0.5f; + g.nodes[i].vy = -0.5f; + } + g.sync(); + + graph::layout_fixed(g.data); + + for (int i = 0; i < 5; ++i) { + REQUIRE(g.nodes[i].x == Catch::Approx((float)(i * 10))); + REQUIRE(g.nodes[i].y == Catch::Approx((float)(i * -3))); + REQUIRE(g.nodes[i].vx == Catch::Approx(0.5f)); + REQUIRE(g.nodes[i].vy == Catch::Approx(-0.5f)); + } +} + +TEST_CASE("layouts handle empty graph", "[viz][layouts][empty]") { + TestGraph g; g.make(0); + REQUIRE_NOTHROW(graph::layout_grid(g.data, 10.0f)); + REQUIRE_NOTHROW(graph::layout_circular(g.data, 10.0f)); + REQUIRE_NOTHROW(graph::layout_random(g.data, 10.0f)); + REQUIRE_NOTHROW(graph::layout_radial(g.data, 0, 10.0f)); + REQUIRE_NOTHROW(graph::layout_hierarchical(g.data, 0, 10.0f, 10.0f)); + REQUIRE_NOTHROW(graph::layout_fixed(g.data)); +} diff --git a/cpp/tests/test_graph_viewport.cpp b/cpp/tests/test_graph_viewport.cpp new file mode 100644 index 00000000..88975f96 --- /dev/null +++ b/cpp/tests/test_graph_viewport.cpp @@ -0,0 +1,104 @@ +// Smoke tests for graph_viewport (issue 0049i). +// El widget completo necesita un contexto OpenGL + ImGui — eso vive en el +// demo y en el test visual. Aqui solo cubrimos los helpers de seleccion +// (logica pura sobre GraphData / GraphViewportState) que no dependen de GL +// ni de ImGui frame state. + +#define CATCH_CONFIG_MAIN +#include "catch_amalgamated.hpp" + +#include "viz/graph_viewport.h" +#include "viz/graph_types.h" + +#include + +namespace { +struct TG { + std::vector nodes; + GraphData data{}; + void make(int N) { + nodes.clear(); + for (int i = 0; i < N; ++i) nodes.push_back(graph_node()); + data.nodes = nodes.data(); + data.node_count = (int)nodes.size(); + data.node_capacity = (int)nodes.capacity(); + data.edges = nullptr; data.edge_count = 0; data.edge_capacity = 0; + data.types = nullptr; data.type_count = 0; + data.rel_types = nullptr; data.rel_type_count = 0; + } +}; +} // namespace + +TEST_CASE("selection: add sets NF_SELECTED and selected_node", "[viewport][selection]") { + TG g; g.make(5); + GraphViewportState st; + + graph_viewport_add_to_selection(g.data, st, 2); + REQUIRE(st.selection.size() == 1); + REQUIRE(st.selection[0] == 2); + REQUIRE(st.selected_node == 2); + REQUIRE((g.nodes[2].flags & NF_SELECTED) != 0); + + // Anadir el mismo no duplica + graph_viewport_add_to_selection(g.data, st, 2); + REQUIRE(st.selection.size() == 1); + + graph_viewport_add_to_selection(g.data, st, 4); + REQUIRE(st.selection.size() == 2); + REQUIRE(st.selected_node == 4); +} + +TEST_CASE("selection: clear unsets all NF_SELECTED", "[viewport][selection]") { + TG g; g.make(3); + GraphViewportState st; + graph_viewport_add_to_selection(g.data, st, 0); + graph_viewport_add_to_selection(g.data, st, 1); + graph_viewport_add_to_selection(g.data, st, 2); + + graph_viewport_clear_selection(g.data, st); + + REQUIRE(st.selection.empty()); + REQUIRE(st.selected_node == -1); + for (int i = 0; i < 3; ++i) { + REQUIRE((g.nodes[i].flags & NF_SELECTED) == 0); + } +} + +TEST_CASE("selection: toggle adds then removes", "[viewport][selection]") { + TG g; g.make(3); + GraphViewportState st; + + graph_viewport_toggle_selection(g.data, st, 1); + REQUIRE(graph_viewport_is_selected(st, 1)); + REQUIRE((g.nodes[1].flags & NF_SELECTED) != 0); + + graph_viewport_toggle_selection(g.data, st, 1); + REQUIRE_FALSE(graph_viewport_is_selected(st, 1)); + REQUIRE((g.nodes[1].flags & NF_SELECTED) == 0); + REQUIRE(st.selected_node == -1); +} + +TEST_CASE("selection: out-of-range indices are ignored", "[viewport][selection]") { + TG g; g.make(2); + GraphViewportState st; + + graph_viewport_add_to_selection(g.data, st, -1); + graph_viewport_add_to_selection(g.data, st, 5); + graph_viewport_toggle_selection(g.data, st, -3); + + REQUIRE(st.selection.empty()); + REQUIRE(st.selected_node == -1); +} + +TEST_CASE("selection: is_selected reports membership", "[viewport][selection]") { + TG g; g.make(4); + GraphViewportState st; + + graph_viewport_add_to_selection(g.data, st, 0); + graph_viewport_add_to_selection(g.data, st, 3); + + REQUIRE(graph_viewport_is_selected(st, 0)); + REQUIRE_FALSE(graph_viewport_is_selected(st, 1)); + REQUIRE_FALSE(graph_viewport_is_selected(st, 2)); + REQUIRE(graph_viewport_is_selected(st, 3)); +} diff --git a/dev/issues/README.md b/dev/issues/README.md index fe4033de..eff1fee5 100644 --- a/dev/issues/README.md +++ b/dev/issues/README.md @@ -63,6 +63,6 @@ | [0049f](completed/0049f-graph-renderer-symbols.md) | Renderer extendido: shapes SDF, icon atlas, flechas, edge styles | completado | alta | feature | parte de 0049 | | [0049g](completed/0049g-graph-source-operations.md) | graph_sources: lector operations.db + abstraccion funcional | completado | alta | feature | parte de 0049 | | [0049h](completed/0049h-graph-force-layout-gpu.md) | graph_force_layout_gpu: compute shader + spatial hash | completado | media-alta | feature | parte de 0049 | -| [0049i](0049i-graph-layouts-static.md) | graph_layouts (radial/hierarchical/fixed) + viewport multi-select | pendiente | media | feature | parte de 0049 | +| [0049i](completed/0049i-graph-layouts-static.md) | graph_layouts (radial/hierarchical/fixed) + viewport multi-select | completado | media | feature | parte de 0049 | | [0049j](0049j-graph-labels.md) | graph_labels: render etiquetas con LabelPolicy | pendiente | media | feature | parte de 0049 | | [0049k](0049k-graph-explorer-app.md) | App graph_explorer (proyecto osint_graph) — integracion final | pendiente | alta | feature | parte de 0049 | diff --git a/dev/issues/0049i-graph-layouts-static.md b/dev/issues/completed/0049i-graph-layouts-static.md similarity index 100% rename from dev/issues/0049i-graph-layouts-static.md rename to dev/issues/completed/0049i-graph-layouts-static.md