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