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:
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user