0e6a013937
Tres mejoras de UX/escala en el demo de grafos:
1. **Wheel zoom dentro del canvas no scrollea la pagina**
En graph_viewport.cpp tras procesar MouseWheel para zoom hacemos
io.MouseWheel = 0 — consume el evento para que el BeginChild padre
(la galeria) no scrollee a la vez que el grafo se acerca. Antes
sentia "doble accion" al rodar la rueda sobre el canvas.
2. **graph_force_layout: pool dinamico (soporta 1M nodos)**
El array static QuadNode[1<<20] (~48MB siempre reservados, tope
rigido en ~250k nodos por la fan-out) se reemplaza por
std::vector<QuadNode>. graph_force_layout_step llama a
quad_pool_reserve(5*N + 1024) ANTES de construir el arbol — asi las
referencias QuadNode& que mantenemos vivas durante quad_subdivide
no se invalidan por reallocaciones a mitad del build (resize solo
ocurre en el reserve inicial). Memoria escala lineal con N: 1M
nodos ≈ 240MB de pool, una vez por programa.
3. **Demo de grafo: sliders extendidos + cluster_r escala con sqrt(N)**
- "Nodes" pasa de 100..20k a 100..1M con escala logaritmica
(ImGuiSliderFlags_Logarithmic) para que el rango medio sea util.
- Nuevos sliders "Edges/node" (1..10) e "Inter %" (0..30%) — antes
hardcoded a 3 y 5%.
- cluster_radius y scatter ahora escalan con sqrt(N): a 1k nodos
~370 px de radio, a 1M ~12000 px. Antes era constante a 200/40
y los nodos quedaban empaquetados al subir N — visualmente "sin
limite cuadrado", esparcidos sobre un area proporcional al grafo.
- Golden de graph_viewport regenerado por la nueva fila de sliders.
Notas:
- A 1M nodos sin GPU compute esta limitado por el upload de aristas
(vertex pulling con TBO llega en 0049d). Render mantenible hasta
~200-300k.
- En Linux/Windows ambos builds limpios. 27/27 tests verde.
337 lines
13 KiB
C++
337 lines
13 KiB
C++
#include "viz/graph_viewport.h"
|
|
#include "viz/graph_types.h"
|
|
#include "viz/graph_renderer.h"
|
|
#include "viz/graph_force_layout.h"
|
|
#include "core/graph_spatial_hash.h"
|
|
#include "imgui.h"
|
|
|
|
#include <cstdio> // snprintf
|
|
#include <cstring> // memset
|
|
#include <vector>
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Internal helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
static void viewport_to_graph(float vx, float vy,
|
|
float widget_x, float widget_y,
|
|
float widget_w, float widget_h,
|
|
float cam_x, float cam_y, float zoom,
|
|
float& gx, float& gy)
|
|
{
|
|
gx = (vx - widget_x - widget_w * 0.5f) / zoom + cam_x;
|
|
gy = (vy - widget_y - widget_h * 0.5f) / zoom + cam_y;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// graph_viewport_fit
|
|
// ---------------------------------------------------------------------------
|
|
|
|
void graph_viewport_fit(GraphData& graph, GraphViewportState& state)
|
|
{
|
|
graph.update_bounds();
|
|
if (graph.node_count == 0) {
|
|
state.cam_x = 0.0f;
|
|
state.cam_y = 0.0f;
|
|
state.zoom = 1.0f;
|
|
return;
|
|
}
|
|
|
|
float cx = (graph.min_x + graph.max_x) * 0.5f;
|
|
float cy = (graph.min_y + graph.max_y) * 0.5f;
|
|
state.cam_x = cx;
|
|
state.cam_y = cy;
|
|
|
|
float span_x = graph.max_x - graph.min_x;
|
|
float span_y = graph.max_y - graph.min_y;
|
|
float span = (span_x > span_y ? span_x : span_y);
|
|
|
|
// Use render dimensions if available; fall back to a safe default.
|
|
float view_px = (state.render_w > 0 ? (float)state.render_w : 600.0f);
|
|
float view_py = (state.render_h > 0 ? (float)state.render_h : 400.0f);
|
|
float view_min = (view_px < view_py ? view_px : view_py);
|
|
|
|
if (span > 0.0f) {
|
|
state.zoom = (view_min * 0.9f) / span;
|
|
} else {
|
|
state.zoom = 1.0f;
|
|
}
|
|
|
|
// Clamp to allowed range
|
|
if (state.zoom < state.zoom_min) state.zoom = state.zoom_min;
|
|
if (state.zoom > state.zoom_max) state.zoom = state.zoom_max;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// graph_viewport_destroy
|
|
// ---------------------------------------------------------------------------
|
|
|
|
void graph_viewport_destroy(GraphViewportState& state)
|
|
{
|
|
if (state.renderer) {
|
|
graph_renderer_destroy(state.renderer);
|
|
state.renderer = nullptr;
|
|
}
|
|
if (state.spatial) {
|
|
delete state.spatial;
|
|
state.spatial = nullptr;
|
|
}
|
|
state.initialized = false;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// graph_viewport — main widget
|
|
// ---------------------------------------------------------------------------
|
|
|
|
bool graph_viewport(const char* id, GraphData& graph, GraphViewportState& state,
|
|
ImVec2 size)
|
|
{
|
|
bool interacted = false;
|
|
|
|
// Resolve size
|
|
ImVec2 avail = ImGui::GetContentRegionAvail();
|
|
float w = (size.x > 0.0f) ? size.x : avail.x;
|
|
float h = (size.y > 0.0f) ? size.y : avail.y;
|
|
if (w < 1.0f) w = 1.0f;
|
|
if (h < 1.0f) h = 1.0f;
|
|
|
|
int iw = (int)w, ih = (int)h;
|
|
|
|
// -------------------------------------------------------------------
|
|
// 1. Lazy init
|
|
// -------------------------------------------------------------------
|
|
if (!state.initialized) {
|
|
state.renderer = graph_renderer_create(iw, ih);
|
|
state.spatial = new SpatialHash(20.0f, 4096);
|
|
state.render_w = iw;
|
|
state.render_h = ih;
|
|
state.initialized = true;
|
|
graph_viewport_fit(graph, state);
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// 2. Resize
|
|
// -------------------------------------------------------------------
|
|
if (iw != state.render_w || ih != state.render_h) {
|
|
graph_renderer_resize(state.renderer, iw, ih);
|
|
state.render_w = iw;
|
|
state.render_h = ih;
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// 3. Force layout step
|
|
// -------------------------------------------------------------------
|
|
if (state.layout_running && graph.node_count > 0) {
|
|
state.layout_energy = graph_force_layout_step(graph);
|
|
if (state.layout_energy < 0.01f) {
|
|
state.layout_running = false;
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// 4. Build spatial hash
|
|
// -------------------------------------------------------------------
|
|
if (graph.node_count > 0) {
|
|
static std::vector<float> xs_buf, ys_buf, sz_buf;
|
|
xs_buf.resize(graph.node_count);
|
|
ys_buf.resize(graph.node_count);
|
|
sz_buf.resize(graph.node_count);
|
|
for (int i = 0; i < graph.node_count; ++i) {
|
|
xs_buf[i] = graph.nodes[i].x;
|
|
ys_buf[i] = graph.nodes[i].y;
|
|
sz_buf[i] = graph.nodes[i].size;
|
|
}
|
|
state.spatial->build(xs_buf.data(), ys_buf.data(), sz_buf.data(), graph.node_count);
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// 5. Invisible button to capture input
|
|
// -------------------------------------------------------------------
|
|
ImGui::PushID(id);
|
|
|
|
ImVec2 widget_pos = ImGui::GetCursorScreenPos();
|
|
ImGui::InvisibleButton("canvas", ImVec2(w, h),
|
|
ImGuiButtonFlags_MouseButtonLeft |
|
|
ImGuiButtonFlags_MouseButtonMiddle|
|
|
ImGuiButtonFlags_MouseButtonRight);
|
|
|
|
bool hovered = ImGui::IsItemHovered();
|
|
bool lm_down = ImGui::IsMouseDown(ImGuiMouseButton_Left);
|
|
bool lm_click = ImGui::IsMouseClicked(ImGuiMouseButton_Left);
|
|
bool mm_down = ImGui::IsMouseDown(ImGuiMouseButton_Middle);
|
|
bool rm_down = ImGui::IsMouseDown(ImGuiMouseButton_Right);
|
|
|
|
ImVec2 mouse_pos = ImGui::GetMousePos();
|
|
float mx = mouse_pos.x, my = mouse_pos.y;
|
|
|
|
// Convert mouse to graph space
|
|
float gx_mouse, gy_mouse;
|
|
viewport_to_graph(mx, my,
|
|
widget_pos.x, widget_pos.y, w, h,
|
|
state.cam_x, state.cam_y, state.zoom,
|
|
gx_mouse, gy_mouse);
|
|
|
|
// -------------------------------------------------------------------
|
|
// 5a. Pan (middle or right mouse drag)
|
|
// -------------------------------------------------------------------
|
|
if (hovered && (mm_down || rm_down)) {
|
|
ImVec2 delta = ImGui::GetIO().MouseDelta;
|
|
if (delta.x != 0.0f || delta.y != 0.0f) {
|
|
state.cam_x -= delta.x / state.zoom;
|
|
state.cam_y -= delta.y / state.zoom;
|
|
interacted = true;
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// 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) {
|
|
float old_zoom = state.zoom;
|
|
float new_zoom = old_zoom * (1.0f + wheel * 0.1f);
|
|
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;
|
|
state.cam_y += rel_y / old_zoom - rel_y / new_zoom;
|
|
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;
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// 5c. Hover — query nearest node
|
|
// -------------------------------------------------------------------
|
|
state.hovered_node = -1;
|
|
if (hovered && graph.node_count > 0) {
|
|
float hit_radius = 10.0f / state.zoom;
|
|
int nearest = state.spatial->query_nearest(gx_mouse, gy_mouse, hit_radius);
|
|
if (nearest >= 0) {
|
|
state.hovered_node = nearest;
|
|
interacted = true;
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// 5d. Node drag (left mouse down on a node)
|
|
// -------------------------------------------------------------------
|
|
if (hovered && lm_down) {
|
|
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].pinned = false;
|
|
}
|
|
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.pinned = true;
|
|
interacted = true;
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// 5e. Click — select node
|
|
// -------------------------------------------------------------------
|
|
if (hovered && lm_click && state.drag_node == -1) {
|
|
state.selected_node = state.hovered_node;
|
|
interacted = true;
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// 5f. Keyboard shortcuts (only when widget is active/hovered)
|
|
// -------------------------------------------------------------------
|
|
if (hovered) {
|
|
if (ImGui::IsKeyPressed(ImGuiKey_Space)) {
|
|
state.layout_running = !state.layout_running;
|
|
}
|
|
if (ImGui::IsKeyPressed(ImGuiKey_F)) {
|
|
graph_viewport_fit(graph, state);
|
|
interacted = true;
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// 6. Render to GPU texture
|
|
// -------------------------------------------------------------------
|
|
unsigned int tex_id = graph_renderer_draw(state.renderer, graph,
|
|
state.cam_x, state.cam_y,
|
|
state.zoom);
|
|
|
|
// -------------------------------------------------------------------
|
|
// 7. Display texture (flip UV for OpenGL FBO convention)
|
|
// -------------------------------------------------------------------
|
|
ImDrawList* draw_list = ImGui::GetWindowDrawList();
|
|
draw_list->AddImage(
|
|
(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
|
|
);
|
|
|
|
// -------------------------------------------------------------------
|
|
// 8. Tooltip on hovered node
|
|
// -------------------------------------------------------------------
|
|
if (state.hovered_node >= 0 && state.hovered_node < graph.node_count) {
|
|
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 ||
|
|
(int)graph.edges[i].target == state.hovered_node) {
|
|
++degree;
|
|
}
|
|
}
|
|
|
|
ImGui::BeginTooltip();
|
|
if (n.label) ImGui::TextUnformatted(n.label);
|
|
ImGui::Text("ID: %u", n.id);
|
|
ImGui::Text("Community: %u", n.community);
|
|
ImGui::Text("Degree: %d", degree);
|
|
ImGui::Text("Value: %.3f", n.value);
|
|
ImGui::EndTooltip();
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// 9. Status bar overlay
|
|
// -------------------------------------------------------------------
|
|
{
|
|
char status[128];
|
|
snprintf(status, sizeof(status),
|
|
"Nodes: %d | Edges: %d | Zoom: %.2fx | Energy: %.4f | [Space] layout [F] fit",
|
|
graph.node_count, graph.edge_count,
|
|
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);
|
|
}
|
|
|
|
ImGui::PopID();
|
|
return interacted;
|
|
}
|