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>
@@ -70,7 +70,9 @@ add_imgui_app(primitives_gallery
|
|||||||
${CMAKE_SOURCE_DIR}/functions/viz/graph_icons.cpp
|
${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.cpp
|
||||||
${CMAKE_SOURCE_DIR}/functions/viz/graph_force_layout_gpu.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.cpp
|
||||||
|
${CMAKE_SOURCE_DIR}/functions/viz/graph_viewport_selection.cpp
|
||||||
${CMAKE_SOURCE_DIR}/functions/core/graph_spatial_hash.cpp
|
${CMAKE_SOURCE_DIR}/functions/core/graph_spatial_hash.cpp
|
||||||
# GL loader (Linux no-op, Windows wglGetProcAddress)
|
# GL loader (Linux no-op, Windows wglGetProcAddress)
|
||||||
${CMAKE_SOURCE_DIR}/functions/gfx/gl_loader.cpp
|
${CMAKE_SOURCE_DIR}/functions/gfx/gl_loader.cpp
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
#include "viz/graph_viewport.h"
|
#include "viz/graph_viewport.h"
|
||||||
#include "viz/graph_force_layout.h"
|
#include "viz/graph_force_layout.h"
|
||||||
#include "viz/graph_force_layout_gpu.h"
|
#include "viz/graph_force_layout_gpu.h"
|
||||||
|
#include "viz/graph_layouts.h"
|
||||||
#include "core/button.h"
|
#include "core/button.h"
|
||||||
#include "core/tokens.h"
|
#include "core/tokens.h"
|
||||||
|
|
||||||
@@ -148,6 +149,14 @@ void demo_graph() {
|
|||||||
static ForceLayoutGPU* s_gpu_ctx = nullptr;
|
static ForceLayoutGPU* s_gpu_ctx = nullptr;
|
||||||
static bool s_gpu_dirty = true; // re-upload tras regen / cambio
|
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) {
|
if (s_needs_regen) {
|
||||||
init_demo_types();
|
init_demo_types();
|
||||||
generate_synthetic_graph(s_n_nodes, s_n_clusters,
|
generate_synthetic_graph(s_n_nodes, s_n_clusters,
|
||||||
@@ -213,6 +222,22 @@ void demo_graph() {
|
|||||||
if (s_use_gpu != prev_gpu) {
|
if (s_use_gpu != prev_gpu) {
|
||||||
s_gpu_dirty = true; // re-upload al cambiar de modo
|
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");
|
section("Stats");
|
||||||
@@ -242,7 +267,25 @@ void demo_graph() {
|
|||||||
ImGui::PopStyleColor();
|
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) {
|
if (s_initialized) {
|
||||||
// Avanzamos 1 paso de force layout cada frame mientras layout_running.
|
// Avanzamos 1 paso de force layout cada frame mientras layout_running.
|
||||||
// Auto-pause: si la energia por nodo cae bajo el umbral durante N
|
// 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;
|
static int s_low_energy_frames = 0;
|
||||||
const int k_pause_after_frames = 30;
|
const int k_pause_after_frames = 30;
|
||||||
const float k_pause_per_node = 0.001f; // umbral de energia/nodo
|
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;
|
ForceLayoutConfig cfg;
|
||||||
cfg.repulsion = s_repulsion;
|
cfg.repulsion = s_repulsion;
|
||||||
cfg.attraction = s_attraction;
|
cfg.attraction = s_attraction;
|
||||||
@@ -294,7 +337,52 @@ void demo_graph() {
|
|||||||
} else {
|
} else {
|
||||||
s_low_energy_frames = 0;
|
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(
|
code_block(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
#include "viz/graph_force_layout.h"
|
#include "viz/graph_force_layout.h"
|
||||||
|
#include "viz/graph_layouts.h"
|
||||||
#include "viz/graph_types.h"
|
#include "viz/graph_types.h"
|
||||||
|
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
@@ -342,19 +343,9 @@ void graph_force_layout_reset(GraphData& graph, float spread) {
|
|||||||
graph.update_bounds();
|
graph.update_bounds();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wrappers deprecados — delegan en graph::layout_* (graph_layouts.h).
|
||||||
void graph_layout_circular(GraphData& graph, float radius) {
|
void graph_layout_circular(GraphData& graph, float radius) {
|
||||||
if (graph.node_count <= 0) return;
|
graph::layout_circular(graph, radius);
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bool graph_force_layout_should_pause(int consecutive_low_frames, int min_consecutive) {
|
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) {
|
void graph_layout_grid(GraphData& graph, float spacing) {
|
||||||
if (graph.node_count <= 0) return;
|
graph::layout_grid(graph, spacing);
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,9 @@ float graph_force_layout_step(GraphData& graph, const ForceLayoutConfig& config
|
|||||||
// Reset: randomize positions within [-spread, spread], zero velocities.
|
// Reset: randomize positions within [-spread, spread], zero velocities.
|
||||||
void graph_force_layout_reset(GraphData& graph, float spread = 200.0f);
|
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_circular(GraphData& graph, float radius = 100.0f);
|
||||||
void graph_layout_grid(GraphData& graph, float spacing = 20.0f);
|
void graph_layout_grid(GraphData& graph, float spacing = 20.0f);
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
#include "core/graph_spatial_hash.h"
|
#include "core/graph_spatial_hash.h"
|
||||||
#include "imgui.h"
|
#include "imgui.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
#include <cstdio> // snprintf
|
#include <cstdio> // snprintf
|
||||||
#include <cstring> // memset
|
#include <cstring> // memset
|
||||||
#include <vector>
|
#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;
|
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
|
// graph_viewport_fit
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -84,7 +95,7 @@ void graph_viewport_destroy(GraphViewportState& state)
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
bool graph_viewport(const char* id, GraphData& graph, GraphViewportState& state,
|
bool graph_viewport(const char* id, GraphData& graph, GraphViewportState& state,
|
||||||
ImVec2 size)
|
ImVec2 size, const GraphViewportCallbacks& cb)
|
||||||
{
|
{
|
||||||
bool interacted = false;
|
bool interacted = false;
|
||||||
|
|
||||||
@@ -159,8 +170,13 @@ bool graph_viewport(const char* id, GraphData& graph, GraphViewportState& state,
|
|||||||
bool hovered = ImGui::IsItemHovered();
|
bool hovered = ImGui::IsItemHovered();
|
||||||
bool lm_down = ImGui::IsMouseDown(ImGuiMouseButton_Left);
|
bool lm_down = ImGui::IsMouseDown(ImGuiMouseButton_Left);
|
||||||
bool lm_click = ImGui::IsMouseClicked(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 mm_down = ImGui::IsMouseDown(ImGuiMouseButton_Middle);
|
||||||
bool rm_down = ImGui::IsMouseDown(ImGuiMouseButton_Right);
|
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();
|
ImVec2 mouse_pos = ImGui::GetMousePos();
|
||||||
float mx = mouse_pos.x, my = mouse_pos.y;
|
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);
|
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;
|
ImVec2 delta = ImGui::GetIO().MouseDelta;
|
||||||
if (delta.x != 0.0f || delta.y != 0.0f) {
|
if (delta.x != 0.0f || delta.y != 0.0f) {
|
||||||
state.cam_x -= delta.x / state.zoom;
|
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)
|
// 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) {
|
if (hovered) {
|
||||||
float wheel = ImGui::GetIO().MouseWheel;
|
float wheel = ImGui::GetIO().MouseWheel;
|
||||||
if (wheel != 0.0f) {
|
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_min) new_zoom = state.zoom_min;
|
||||||
if (new_zoom > state.zoom_max) new_zoom = state.zoom_max;
|
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_x = (mx - widget_pos.x - w * 0.5f);
|
||||||
float rel_y = (my - widget_pos.y - h * 0.5f);
|
float rel_y = (my - widget_pos.y - h * 0.5f);
|
||||||
state.cam_x += rel_x / old_zoom - rel_x / new_zoom;
|
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;
|
state.zoom = new_zoom;
|
||||||
interacted = true;
|
interacted = true;
|
||||||
|
|
||||||
// Consumir el evento — ImGui::GetIO().MouseWheel a 0 evita que
|
|
||||||
// el padre lo procese.
|
|
||||||
ImGui::GetIO().MouseWheel = 0.0f;
|
ImGui::GetIO().MouseWheel = 0.0f;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -217,9 +227,6 @@ bool graph_viewport(const char* id, GraphData& graph, GraphViewportState& state,
|
|||||||
// -------------------------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
// 5c. Hover — query nearest node
|
// 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;
|
int prev_hovered = state.hovered_node;
|
||||||
if (prev_hovered >= 0 && prev_hovered < graph.node_count) {
|
if (prev_hovered >= 0 && prev_hovered < graph.node_count) {
|
||||||
graph.nodes[prev_hovered].flags &= ~NF_HOVERED;
|
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) {
|
if (state.drag_node == -1 && state.hovered_node >= 0) {
|
||||||
state.drag_node = state.hovered_node;
|
state.drag_node = state.hovered_node;
|
||||||
state.is_dragging = true;
|
state.is_dragging = true;
|
||||||
}
|
state.drag_anchor_x = gx_mouse;
|
||||||
} else {
|
state.drag_anchor_y = gy_mouse;
|
||||||
// Release drag
|
// Si el nodo no esta en la seleccion y no es un toggle, lo
|
||||||
if (state.drag_node >= 0 && state.drag_node < graph.node_count) {
|
// anadimos para que el drag mueva al menos ese nodo.
|
||||||
graph.nodes[state.drag_node].flags &= ~NF_PINNED;
|
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.drag_node = -1;
|
||||||
state.is_dragging = false;
|
state.is_dragging = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.drag_node >= 0 && state.drag_node < graph.node_count) {
|
if (state.drag_node >= 0 && state.drag_node < graph.node_count) {
|
||||||
GraphNode& n = graph.nodes[state.drag_node];
|
// delta en world space respecto al frame previo
|
||||||
n.x = gx_mouse;
|
float dx = ImGui::GetIO().MouseDelta.x / state.zoom;
|
||||||
n.y = gy_mouse;
|
float dy = ImGui::GetIO().MouseDelta.y / state.zoom;
|
||||||
n.vx = 0.0f;
|
if (dx != 0.0f || dy != 0.0f) {
|
||||||
n.vy = 0.0f;
|
for (int idx : state.selection) {
|
||||||
n.flags |= NF_PINNED;
|
if (idx >= 0 && idx < graph.node_count) {
|
||||||
interacted = true;
|
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) {
|
// Solo procesar clicks que NO son inicio de drag (es decir: el lm_click
|
||||||
if (state.selected_node >= 0 && state.selected_node < graph.node_count) {
|
// ya pudo haber lanzado un drag arriba). La rama drag arriba ya gestiona
|
||||||
graph.nodes[state.selected_node].flags &= ~NF_SELECTED;
|
// la seleccion en su caso. Aqui nos ocupa el caso de click en area vacia
|
||||||
}
|
// o click puro sobre nodo sin shift/lasso.
|
||||||
state.selected_node = state.hovered_node;
|
if (hovered && lm_click && !shift && state.drag_node == -1
|
||||||
if (state.selected_node >= 0 && state.selected_node < graph.node_count) {
|
&& !state.lasso_active) {
|
||||||
graph.nodes[state.selected_node].flags |= NF_SELECTED;
|
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;
|
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 (hovered) {
|
||||||
if (ImGui::IsKeyPressed(ImGuiKey_Space)) {
|
if (ImGui::IsKeyPressed(ImGuiKey_Space)) {
|
||||||
@@ -287,6 +375,10 @@ bool graph_viewport(const char* id, GraphData& graph, GraphViewportState& state,
|
|||||||
graph_viewport_fit(graph, state);
|
graph_viewport_fit(graph, state);
|
||||||
interacted = true;
|
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,
|
(ImTextureID)(intptr_t)tex_id,
|
||||||
widget_pos,
|
widget_pos,
|
||||||
ImVec2(widget_pos.x + w, widget_pos.y + h),
|
ImVec2(widget_pos.x + w, widget_pos.y + h),
|
||||||
ImVec2(0.0f, 1.0f), // UV top-left (flipped Y)
|
ImVec2(0.0f, 1.0f),
|
||||||
ImVec2(1.0f, 0.0f) // UV bottom-right
|
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];
|
const GraphNode& n = graph.nodes[state.hovered_node];
|
||||||
|
|
||||||
// Count degree
|
|
||||||
int degree = 0;
|
int degree = 0;
|
||||||
for (int i = 0; i < graph.edge_count; ++i) {
|
for (int i = 0; i < graph.edge_count; ++i) {
|
||||||
if ((int)graph.edges[i].source == state.hovered_node ||
|
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
|
// 9. Status bar overlay
|
||||||
// -------------------------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
{
|
{
|
||||||
char status[128];
|
char status[160];
|
||||||
snprintf(status, sizeof(status),
|
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,
|
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);
|
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);
|
draw_list->AddText(text_pos, IM_COL32(180, 180, 180, 200), status);
|
||||||
|
|||||||
@@ -1,10 +1,20 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
#include "imgui.h"
|
#include "imgui.h"
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
struct GraphData;
|
struct GraphData;
|
||||||
struct GraphRenderer;
|
struct GraphRenderer;
|
||||||
struct SpatialHash;
|
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
|
// Persistent state for graph_viewport widget. Create one per viewport and keep
|
||||||
// alive across frames.
|
// alive across frames.
|
||||||
struct GraphViewportState {
|
struct GraphViewportState {
|
||||||
@@ -15,9 +25,20 @@ struct GraphViewportState {
|
|||||||
|
|
||||||
// Interaction result (read after calling graph_viewport each frame)
|
// Interaction result (read after calling graph_viewport each frame)
|
||||||
int hovered_node = -1; // node index under cursor, -1 if none
|
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;
|
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
|
// Layout
|
||||||
bool layout_running = true; // animate force layout each frame
|
bool layout_running = true; // animate force layout each frame
|
||||||
float layout_energy = 0.0f; // kinetic energy from last step
|
float layout_energy = 0.0f; // kinetic energy from last step
|
||||||
@@ -30,8 +51,12 @@ struct GraphViewportState {
|
|||||||
// Widget pixel dimensions tracked for resize detection
|
// Widget pixel dimensions tracked for resize detection
|
||||||
int render_w = 0, render_h = 0;
|
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;
|
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.
|
// Main viewport widget. Call every ImGui frame.
|
||||||
@@ -39,12 +64,21 @@ struct GraphViewportState {
|
|||||||
// graph: mutable graph data (node positions updated on drag)
|
// graph: mutable graph data (node positions updated on drag)
|
||||||
// state: persistent state (camera, selection, GPU renderer); must outlive frames
|
// state: persistent state (camera, selection, GPU renderer); must outlive frames
|
||||||
// size: widget size in pixels — ImVec2(0,0) uses all available space
|
// 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).
|
// Returns true if any user interaction occurred (hover, click, drag, zoom).
|
||||||
bool graph_viewport(const char* id, GraphData& graph, GraphViewportState& state,
|
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.
|
// Release GPU resources. Call once when done with the viewport.
|
||||||
void graph_viewport_destroy(GraphViewportState& state);
|
void graph_viewport_destroy(GraphViewportState& state);
|
||||||
|
|
||||||
// Fit camera to current graph bounds with 10% padding.
|
// Fit camera to current graph bounds with 10% padding.
|
||||||
void graph_viewport_fit(GraphData& graph, GraphViewportState& state);
|
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);
|
||||||
|
|||||||
@@ -3,20 +3,20 @@ name: graph_viewport
|
|||||||
kind: component
|
kind: component
|
||||||
lang: cpp
|
lang: cpp
|
||||||
domain: viz
|
domain: viz
|
||||||
version: "1.1.0"
|
version: "1.2.0"
|
||||||
purity: impure
|
purity: impure
|
||||||
signature: "bool graph_viewport(const char* id, GraphData& graph, GraphViewportState& state, ImVec2 size)"
|
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"
|
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"]
|
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"]
|
uses_types: ["GraphData_cpp_viz"]
|
||||||
returns: []
|
returns: []
|
||||||
returns_optional: false
|
returns_optional: false
|
||||||
error_type: "error_go_core"
|
error_type: "error_go_core"
|
||||||
imports: [imgui]
|
imports: [imgui]
|
||||||
tested: false
|
|
||||||
tests: []
|
|
||||||
test_file_path: ""
|
|
||||||
file_path: "cpp/functions/viz/graph_viewport.cpp"
|
file_path: "cpp/functions/viz/graph_viewport.cpp"
|
||||||
framework: imgui
|
framework: imgui
|
||||||
props:
|
props:
|
||||||
@@ -36,6 +36,10 @@ props:
|
|||||||
type: "ImVec2"
|
type: "ImVec2"
|
||||||
required: false
|
required: false
|
||||||
description: "Tamanio del widget en pixeles. ImVec2(0,0) usa todo el espacio disponible."
|
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: []
|
emits: []
|
||||||
has_state: true
|
has_state: true
|
||||||
params:
|
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."
|
desc: "Estado persistente: camara (cam_x, cam_y, zoom), nodo seleccionado/hovereado, renderer GPU, spatial hash. Alojado por el caller."
|
||||||
- name: size
|
- name: size
|
||||||
desc: "Tamanio del widget en pixeles. (0,0) ocupa todo el espacio disponible en la ventana ImGui."
|
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
|
# graph_viewport
|
||||||
@@ -86,10 +92,15 @@ La camara usa coordenadas del espacio del grafo:
|
|||||||
|
|
||||||
| Accion | Control |
|
| Accion | Control |
|
||||||
|--------|---------|
|
|--------|---------|
|
||||||
| Pan | Boton medio o derecho + arrastrar |
|
| Pan | Boton medio o derecho + arrastrar (sobre area vacia) |
|
||||||
| Zoom | Rueda del raton (hacia el cursor) |
|
| Zoom | Rueda del raton (hacia el cursor) |
|
||||||
| Seleccionar nodo | Click izquierdo |
|
| Seleccionar nodo (single) | Click izquierdo sobre nodo |
|
||||||
| Arrastrar nodo | 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 |
|
| Toggle layout | Barra espaciadora |
|
||||||
| Fit camara | F |
|
| 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
|
## 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`.
|
- **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
|
## 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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_pack_rgba8 test_graph_pack_rgba8.cpp)
|
||||||
add_fn_test(test_graph_should_pause test_graph_should_pause.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_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/viz/graph_types.cpp)
|
||||||
|
|
||||||
# --- Issue 0049d — vertex pulling edge buffer (logica solo, sin GL) --------
|
# --- 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)
|
${CMAKE_CURRENT_SOURCE_DIR}/../functions/viz/graph_types.cpp)
|
||||||
target_link_libraries(test_graph_sources PRIVATE SQLite::SQLite3)
|
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) ----------
|
# --- Issue 0049h — graph_force_layout_gpu (compute + spatial hash) ----------
|
||||||
# El test crea una ventana GLFW oculta a 4.3 core; si glfwInit/window/context
|
# 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
|
# 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
|
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_gpu.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/../functions/viz/graph_force_layout.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/viz/graph_types.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/../functions/gfx/gl_loader.cpp)
|
${CMAKE_CURRENT_SOURCE_DIR}/../functions/gfx/gl_loader.cpp)
|
||||||
if(WIN32)
|
if(WIN32)
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 133 KiB After Width: | Height: | Size: 133 KiB |
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 70 KiB |
@@ -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));
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [0049k](0049k-graph-explorer-app.md) | App graph_explorer (proyecto osint_graph) — integracion final | pendiente | alta | feature | parte de 0049 |
|
||||||
|
|||||||