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
+158 -47
View File
@@ -5,6 +5,7 @@
#include "core/graph_spatial_hash.h"
#include "imgui.h"
#include <algorithm>
#include <cstdio> // snprintf
#include <cstring> // memset
#include <vector>
@@ -23,6 +24,16 @@ static void viewport_to_graph(float vx, float vy,
gy = (vy - widget_y - widget_h * 0.5f) / zoom + cam_y;
}
static void graph_to_viewport(float gx, float gy,
float widget_x, float widget_y,
float widget_w, float widget_h,
float cam_x, float cam_y, float zoom,
float& vx, float& vy)
{
vx = (gx - cam_x) * zoom + widget_x + widget_w * 0.5f;
vy = (gy - cam_y) * zoom + widget_y + widget_h * 0.5f;
}
// ---------------------------------------------------------------------------
// graph_viewport_fit
// ---------------------------------------------------------------------------
@@ -84,7 +95,7 @@ void graph_viewport_destroy(GraphViewportState& state)
// ---------------------------------------------------------------------------
bool graph_viewport(const char* id, GraphData& graph, GraphViewportState& state,
ImVec2 size)
ImVec2 size, const GraphViewportCallbacks& cb)
{
bool interacted = false;
@@ -159,8 +170,13 @@ bool graph_viewport(const char* id, GraphData& graph, GraphViewportState& state,
bool hovered = ImGui::IsItemHovered();
bool lm_down = ImGui::IsMouseDown(ImGuiMouseButton_Left);
bool lm_click = ImGui::IsMouseClicked(ImGuiMouseButton_Left);
bool lm_release= ImGui::IsMouseReleased(ImGuiMouseButton_Left);
bool lm_dbl = ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left);
bool mm_down = ImGui::IsMouseDown(ImGuiMouseButton_Middle);
bool rm_down = ImGui::IsMouseDown(ImGuiMouseButton_Right);
bool rm_click = ImGui::IsMouseClicked(ImGuiMouseButton_Right);
bool shift = ImGui::GetIO().KeyShift;
bool ctrl = ImGui::GetIO().KeyCtrl;
ImVec2 mouse_pos = ImGui::GetMousePos();
float mx = mouse_pos.x, my = mouse_pos.y;
@@ -173,9 +189,11 @@ bool graph_viewport(const char* id, GraphData& graph, GraphViewportState& state,
gx_mouse, gy_mouse);
// -------------------------------------------------------------------
// 5a. Pan (middle or right mouse drag)
// 5a. Pan (middle mouse drag, o right-button drag SIN nodo bajo cursor)
// -------------------------------------------------------------------
if (hovered && (mm_down || rm_down)) {
// El pan con boton derecho se inhibe si el right-click cae sobre un
// nodo: en ese caso queremos mostrar el menu contextual via callback.
if (hovered && (mm_down || (rm_down && state.hovered_node < 0))) {
ImVec2 delta = ImGui::GetIO().MouseDelta;
if (delta.x != 0.0f || delta.y != 0.0f) {
state.cam_x -= delta.x / state.zoom;
@@ -187,11 +205,6 @@ bool graph_viewport(const char* id, GraphData& graph, GraphViewportState& state,
// -------------------------------------------------------------------
// 5b. Zoom (scroll wheel)
// -------------------------------------------------------------------
// Consumimos el wheel cuando esta sobre el canvas para que la ventana
// padre (BeginChild de la galeria, p.ej.) NO scrollee a la vez que
// hacemos zoom — sin esto la pagina entera se mueve mientras el grafo
// se acerca, sensacion incomoda. Tambien marcamos NoNav del item para
// que ImGui no intente keyboard-scroll al estar enfocado.
if (hovered) {
float wheel = ImGui::GetIO().MouseWheel;
if (wheel != 0.0f) {
@@ -200,7 +213,6 @@ bool graph_viewport(const char* id, GraphData& graph, GraphViewportState& state,
if (new_zoom < state.zoom_min) new_zoom = state.zoom_min;
if (new_zoom > state.zoom_max) new_zoom = state.zoom_max;
// Zoom toward cursor: keep gx_mouse/gy_mouse fixed in graph space
float rel_x = (mx - widget_pos.x - w * 0.5f);
float rel_y = (my - widget_pos.y - h * 0.5f);
state.cam_x += rel_x / old_zoom - rel_x / new_zoom;
@@ -208,8 +220,6 @@ bool graph_viewport(const char* id, GraphData& graph, GraphViewportState& state,
state.zoom = new_zoom;
interacted = true;
// Consumir el evento — ImGui::GetIO().MouseWheel a 0 evita que
// el padre lo procese.
ImGui::GetIO().MouseWheel = 0.0f;
}
}
@@ -217,9 +227,6 @@ bool graph_viewport(const char* id, GraphData& graph, GraphViewportState& state,
// -------------------------------------------------------------------
// 5c. Hover — query nearest node
// -------------------------------------------------------------------
// Clear-then-set: limpiamos NF_HOVERED del nodo previo aunque el cursor
// ya no este sobre el viewport (cambio de target). Esto evita que el
// flag persista cuando el usuario sale del widget.
int prev_hovered = state.hovered_node;
if (prev_hovered >= 0 && prev_hovered < graph.node_count) {
graph.nodes[prev_hovered].flags &= ~NF_HOVERED;
@@ -236,48 +243,129 @@ bool graph_viewport(const char* id, GraphData& graph, GraphViewportState& state,
}
// -------------------------------------------------------------------
// 5d. Node drag (left mouse down on a node)
// 5d. Lasso (shift + drag sobre area vacia)
// -------------------------------------------------------------------
if (hovered && lm_down) {
// Activacion: shift presionado + lm_click (no haciendo click sobre nodo).
if (hovered && lm_click && shift && state.hovered_node < 0
&& state.drag_node == -1 && !state.lasso_active) {
state.lasso_active = true;
state.lasso_start = ImVec2(gx_mouse, gy_mouse);
state.lasso_end = ImVec2(gx_mouse, gy_mouse);
}
if (state.lasso_active) {
state.lasso_end = ImVec2(gx_mouse, gy_mouse);
if (lm_release) {
// Calcular AABB en world space y anadir nodos contenidos a la seleccion
float x0 = std::min(state.lasso_start.x, state.lasso_end.x);
float x1 = std::max(state.lasso_start.x, state.lasso_end.x);
float y0 = std::min(state.lasso_start.y, state.lasso_end.y);
float y1 = std::max(state.lasso_start.y, state.lasso_end.y);
for (int i = 0; i < graph.node_count; ++i) {
const GraphNode& n = graph.nodes[i];
if (n.x >= x0 && n.x <= x1 && n.y >= y0 && n.y <= y1) {
graph_viewport_add_to_selection(graph, state, i);
}
}
state.lasso_active = false;
interacted = true;
}
}
// -------------------------------------------------------------------
// 5e. Node drag (left mouse down on a node)
// -------------------------------------------------------------------
// Si el nodo bajo el cursor esta seleccionado, arrastra TODA la seleccion;
// en otro caso solo el nodo bajo el cursor.
if (hovered && lm_down && !state.lasso_active) {
if (state.drag_node == -1 && state.hovered_node >= 0) {
state.drag_node = state.hovered_node;
state.is_dragging = true;
}
} else {
// Release drag
if (state.drag_node >= 0 && state.drag_node < graph.node_count) {
graph.nodes[state.drag_node].flags &= ~NF_PINNED;
state.drag_node = state.hovered_node;
state.is_dragging = true;
state.drag_anchor_x = gx_mouse;
state.drag_anchor_y = gy_mouse;
// Si el nodo no esta en la seleccion y no es un toggle, lo
// anadimos para que el drag mueva al menos ese nodo.
if (!graph_viewport_is_selected(state, state.drag_node)) {
if (!ctrl) {
// Reemplaza seleccion: drag de nodo no seleccionado
graph_viewport_clear_selection(graph, state);
}
graph_viewport_add_to_selection(graph, state, state.drag_node);
}
// Fijar todos los nodos seleccionados como pinned mientras dura
// el drag — el force layout no los movera.
for (int idx : state.selection) {
if (idx >= 0 && idx < graph.node_count) {
graph.nodes[idx].flags |= NF_PINNED;
}
}
}
} else if (state.drag_node >= 0) {
// Release: mantener pinned (deja al usuario re-arrastrar). Quien
// quiera "soltar" el pin puede hacer Esc o un re-click sobre area
// vacia para limpiar seleccion.
state.drag_node = -1;
state.is_dragging = false;
}
if (state.drag_node >= 0 && state.drag_node < graph.node_count) {
GraphNode& n = graph.nodes[state.drag_node];
n.x = gx_mouse;
n.y = gy_mouse;
n.vx = 0.0f;
n.vy = 0.0f;
n.flags |= NF_PINNED;
interacted = true;
// delta en world space respecto al frame previo
float dx = ImGui::GetIO().MouseDelta.x / state.zoom;
float dy = ImGui::GetIO().MouseDelta.y / state.zoom;
if (dx != 0.0f || dy != 0.0f) {
for (int idx : state.selection) {
if (idx >= 0 && idx < graph.node_count) {
GraphNode& n = graph.nodes[idx];
n.x += dx;
n.y += dy;
n.vx = 0.0f;
n.vy = 0.0f;
n.flags |= NF_PINNED;
}
}
interacted = true;
}
}
// -------------------------------------------------------------------
// 5e. Click — select node (clear-then-set NF_SELECTED)
// 5f. Click — seleccion (single / ctrl-toggle)
// -------------------------------------------------------------------
if (hovered && lm_click && state.drag_node == -1) {
if (state.selected_node >= 0 && state.selected_node < graph.node_count) {
graph.nodes[state.selected_node].flags &= ~NF_SELECTED;
}
state.selected_node = state.hovered_node;
if (state.selected_node >= 0 && state.selected_node < graph.node_count) {
graph.nodes[state.selected_node].flags |= NF_SELECTED;
// Solo procesar clicks que NO son inicio de drag (es decir: el lm_click
// ya pudo haber lanzado un drag arriba). La rama drag arriba ya gestiona
// la seleccion en su caso. Aqui nos ocupa el caso de click en area vacia
// o click puro sobre nodo sin shift/lasso.
if (hovered && lm_click && !shift && state.drag_node == -1
&& !state.lasso_active) {
if (state.hovered_node >= 0) {
if (ctrl) {
graph_viewport_toggle_selection(graph, state, state.hovered_node);
} else {
graph_viewport_clear_selection(graph, state);
graph_viewport_add_to_selection(graph, state, state.hovered_node);
}
} else {
graph_viewport_clear_selection(graph, state);
}
interacted = true;
}
// -------------------------------------------------------------------
// 5f. Keyboard shortcuts (only when widget is active/hovered)
// 5g. Right-click sobre nodo -> on_context_menu
// -------------------------------------------------------------------
if (hovered && rm_click && state.hovered_node >= 0 && cb.on_context_menu) {
cb.on_context_menu(state.hovered_node, mouse_pos, cb.user);
interacted = true;
}
// -------------------------------------------------------------------
// 5h. Double-click -> on_double_click
// -------------------------------------------------------------------
if (hovered && lm_dbl && state.hovered_node >= 0 && cb.on_double_click) {
cb.on_double_click(state.hovered_node, cb.user);
interacted = true;
}
// -------------------------------------------------------------------
// 5i. Keyboard shortcuts (only when widget hovered)
// -------------------------------------------------------------------
if (hovered) {
if (ImGui::IsKeyPressed(ImGuiKey_Space)) {
@@ -287,6 +375,10 @@ bool graph_viewport(const char* id, GraphData& graph, GraphViewportState& state,
graph_viewport_fit(graph, state);
interacted = true;
}
if (ImGui::IsKeyPressed(ImGuiKey_Escape)) {
graph_viewport_clear_selection(graph, state);
interacted = true;
}
}
// -------------------------------------------------------------------
@@ -304,17 +396,35 @@ bool graph_viewport(const char* id, GraphData& graph, GraphViewportState& state,
(ImTextureID)(intptr_t)tex_id,
widget_pos,
ImVec2(widget_pos.x + w, widget_pos.y + h),
ImVec2(0.0f, 1.0f), // UV top-left (flipped Y)
ImVec2(1.0f, 0.0f) // UV bottom-right
ImVec2(0.0f, 1.0f),
ImVec2(1.0f, 0.0f)
);
// -------------------------------------------------------------------
// 8. Tooltip on hovered node
// 7b. Lasso visual feedback (rect en pantalla)
// -------------------------------------------------------------------
if (state.hovered_node >= 0 && state.hovered_node < graph.node_count) {
if (state.lasso_active) {
float vx0, vy0, vx1, vy1;
graph_to_viewport(state.lasso_start.x, state.lasso_start.y,
widget_pos.x, widget_pos.y, w, h,
state.cam_x, state.cam_y, state.zoom, vx0, vy0);
graph_to_viewport(state.lasso_end.x, state.lasso_end.y,
widget_pos.x, widget_pos.y, w, h,
state.cam_x, state.cam_y, state.zoom, vx1, vy1);
ImVec2 a(std::min(vx0, vx1), std::min(vy0, vy1));
ImVec2 b(std::max(vx0, vx1), std::max(vy0, vy1));
draw_list->AddRectFilled(a, b, IM_COL32(80, 140, 220, 40));
draw_list->AddRect (a, b, IM_COL32(120, 180, 240, 200));
}
// -------------------------------------------------------------------
// 8. Tooltip on hovered node (suprimido durante drag o lasso para no
// tapar la accion)
// -------------------------------------------------------------------
if (state.hovered_node >= 0 && state.hovered_node < graph.node_count
&& !state.is_dragging && !state.lasso_active) {
const GraphNode& n = graph.nodes[state.hovered_node];
// Count degree
int degree = 0;
for (int i = 0; i < graph.edge_count; ++i) {
if ((int)graph.edges[i].source == state.hovered_node ||
@@ -340,11 +450,12 @@ bool graph_viewport(const char* id, GraphData& graph, GraphViewportState& state,
// 9. Status bar overlay
// -------------------------------------------------------------------
{
char status[128];
char status[160];
snprintf(status, sizeof(status),
"Nodes: %d | Edges: %d | Zoom: %.2fx | Energy: %.4f | [Space] layout [F] fit",
"Nodes: %d | Edges: %d | Sel: %zu | Zoom: %.2fx | Energy: %.4f | "
"[Space] layout [F] fit [Esc] clear [Shift+drag] lasso [Ctrl+click] toggle",
graph.node_count, graph.edge_count,
state.zoom, state.layout_energy);
state.selection.size(), state.zoom, state.layout_energy);
ImVec2 text_pos = ImVec2(widget_pos.x + 6.0f, widget_pos.y + h - 18.0f);
draw_list->AddText(text_pos, IM_COL32(180, 180, 180, 200), status);