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(