feat(viz): graph_layouts (radial/hierarchical/fixed) + viewport multi-select+lasso (issue 0049i)

Phase 1 — graph_layouts:
- New module cpp/functions/viz/graph_layouts.{h,cpp,md} v1.0.0
- layout_grid, layout_circular, layout_random (migrated from graph_force_layout.cpp)
- layout_radial: BFS rings from root, hop k -> circle of radius k*ring_spacing
- layout_hierarchical: Sugiyama-style heuristic (longest-path levels + barycenter ordering)
- layout_fixed: no-op
- All respect NF_PINNED. graph_layout_circular/grid kept as deprecated wrappers.

Phase 2-3 — graph_viewport v1.2.0:
- Multi-selection via state.selection (vector<int>); NF_SELECTED kept in sync
- Lasso: Shift+Drag on empty area; AABB hit-test on release
- Drag of N-selection: all selected pinned + moved by mouse delta
- Ctrl+click toggle, Esc clears selection
- Right-click on node -> on_context_menu callback
- Double-click on node -> on_double_click callback
- Helpers exposed: graph_viewport_clear/add_to/toggle/is_selected (own TU for tests)

Phase 4 — tests:
- test_graph_layouts: 12 cases / 364 assertions covering geometry, pin, edges
- test_graph_viewport: 5 cases for selection helpers (pure logic, no GL)

Phase 5 — demo (primitives_gallery):
- Layout combo (force/grid/circular/radial/hierarchical/fixed) + Apply button
- Right-click popup with Pin/Unpin/Add-to-selection
- Status overlay shows [N selected] when selection non-empty
- Updated golden images

Issue moved to dev/issues/completed/.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-29 23:42:31 +02:00
parent d09b35b533
commit 4a0750445c
24 changed files with 1187 additions and 92 deletions
@@ -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
+91 -3
View File
@@ -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(
+4 -28
View File
@@ -1,4 +1,5 @@
#include "viz/graph_force_layout.h"
#include "viz/graph_layouts.h"
#include "viz/graph_types.h"
#include <cmath>
@@ -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);
}
+3 -1
View File
@@ -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);
+281
View File
@@ -0,0 +1,281 @@
#include "viz/graph_layouts.h"
#include "viz/graph_types.h"
#include <algorithm>
#include <cmath>
#include <cstdlib>
#include <queue>
#include <vector>
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<vector<int>>.
std::vector<std::vector<int>> 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<int> hop(g.node_count, -1);
hop[root_node] = 0;
std::queue<int> 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<std::vector<int>> 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<int> in_deg(N, 0);
std::vector<std::vector<int>> out_adj(N);
std::vector<std::vector<int>> 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<int> level(N, 0);
std::vector<int> order; order.reserve(N);
std::queue<int> 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<int> 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<std::vector<int>> 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<int> 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<float> 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<size_t> 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<int> 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
+51
View File
@@ -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
+126
View File
@@ -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.
+158 -47
View File
@@ -5,6 +5,7 @@
#include "core/graph_spatial_hash.h"
#include "imgui.h"
#include <algorithm>
#include <cstdio> // snprintf
#include <cstring> // memset
#include <vector>
@@ -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);
+37 -3
View File
@@ -1,10 +1,20 @@
#pragma once
#include "imgui.h"
#include <vector>
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<int> 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);
+21 -9
View File
@@ -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
@@ -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 <algorithm>
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;
}
}
+17
View File
@@ -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)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 70 KiB

+246
View File
@@ -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 <cmath>
#include <vector>
namespace {
// Helper: construir un GraphData a partir de vectores
struct TestGraph {
std::vector<GraphNode> nodes;
std::vector<GraphEdge> 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));
}
+104
View File
@@ -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 <vector>
namespace {
struct TG {
std::vector<GraphNode> 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));
}
+1 -1
View File
@@ -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 |