Files
fn_registry/cpp/functions/viz/graph_viewport.cpp
T
egutierrez c29428a187 feat(viz): graph_types modelo extendido + EntityType/RelationType + flags (issue 0049e)
Extiende el modelo agnostico de graph_types.h para soportar shapes/iconos/
filtros/labels/streaming sin acoplar a backend. Migra el unico consumer
(demos_graph) en el mismo cambio.

- GraphNode v2: type_id + shape_override/color_override/size_override +
  flags (NF_PINNED/VISIBLE/SELECTED/HOVERED) + label_idx + user_data.
- GraphEdge v2: type_id + style_override + flags (EF_DIRECTED/VISIBLE).
- EntityType / RelationType: tablas en GraphData (types, rel_types).
- Helpers de resolucion (resolve_node_color/shape/size, resolve_edge_*)
  y constructores ergonomicos (graph_node, graph_edge, entity_type,
  relation_type) — sentinel-based para herencia automatica del tipo.
- graph_renderer v1.4: lee NF_VISIBLE / EF_VISIBLE, resuelve apariencia
  via override → EntityType → fallback indexado por type_id. Skipea
  aristas con endpoints invisibles. Shapes siguen pintandose como
  circulo (0049f cableara el dispatch real).
- graph_force_layout v1.2: pinned ahora vive en flags & NF_PINNED.
- graph_viewport v1.1: hover/seleccion publican NF_HOVERED/SELECTED en
  el grafo (clear-then-set). Drag usa NF_PINNED. Tooltip muestra Type/
  user_data en lugar de community/value/label.
- demos_graph: 8 EntityType (paleta antigua) + 1 RelationType. type_id
  por cluster. user_data = indice numerico del nodo. Apariencia visual
  identica al pre-cambio.
- test_graph_types.cpp: 12 casos cubriendo helpers, defaults, bitmask
  manipulation y resoluciones override-vs-EntityType. test_graph_edge_
  static actualizado al nuevo modelo (ya no tiene .color directo).
- 4 .md de tipos nuevos (graph_node, graph_edge, entity_type,
  relation_type) + GraphData v2.0 actualizado.

Tests: 31/31 ctest verdes (incluye test_visual golden).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 22:44:40 +02:00

356 lines
14 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] = resolve_node_size(graph.nodes[i],
graph.types, graph.type_count);
}
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
// -------------------------------------------------------------------
// 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;
}
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;
graph.nodes[nearest].flags |= NF_HOVERED;
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].flags &= ~NF_PINNED;
}
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;
}
// -------------------------------------------------------------------
// 5e. Click — select node (clear-then-set NF_SELECTED)
// -------------------------------------------------------------------
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;
}
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 (graph.types && n.type_id < (uint16_t)graph.type_count
&& graph.types[n.type_id].name) {
ImGui::Text("Type: %s", graph.types[n.type_id].name);
} else {
ImGui::Text("Type: %u", (unsigned)n.type_id);
}
ImGui::Text("Index: %d", state.hovered_node);
if (n.user_data) ImGui::Text("user_data: %llu", (unsigned long long)n.user_data);
ImGui::Text("Degree: %d", degree);
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;
}