feat(viz): graph_labels render con LabelPolicy + ImDrawList (issue 0049j)

graph_labels_draw pinta etiquetas de nodos sobre el FBO del graph_renderer
via ImDrawList. Politica configurable: always-on para selected/hovered/
pinned, top-N por size*(degree+1), culling por viewport AABB y
min_node_pixel_size. Cap duro = max_visible + |always_*|.

API:
- graph_labels_draw(graph, viewport_state, policy, cb, user)
- graph_labels_draw_at(...)  — variante con rect explicito
- graph_labels_select(...)   — helper puro testeable
- graph_compute_degrees(...) — O(E)

Splitting en dos TUs:
- graph_labels.cpp          — funciones draw (depende de ImGui)
- graph_labels_select.cpp   — helpers puros para tests sin ImGui

12 tests en test_graph_labels (culling, max_visible cap, min_pixel_size,
always_* gating por viewport, top-N por score, edge cases). Todos verdes.

Integrado en demos_graph con UI: toggle Labels, sliders Max visible /
Font / Min px, checkboxes Selected/Hovered/Pinned. Golden de
graph_viewport regenerado.

Cierra issue 0049j.
This commit is contained in:
2026-04-29 23:53:32 +02:00
parent 000e9f2ea5
commit 4b28ef6774
11 changed files with 750 additions and 1 deletions
+83
View File
@@ -0,0 +1,83 @@
// Pintado de etiquetas via ImDrawList (issue 0049j). La logica de seleccion
// de candidatos (frustum cull + top-N) vive en graph_labels_select.cpp para
// que sea testeable sin enlazar ImGui.
#include "viz/graph_labels.h"
#include "viz/graph_types.h"
#include "viz/graph_viewport.h"
#include "imgui.h"
#include <cfloat>
#include <vector>
namespace graph {
void graph_labels_draw_at(const GraphData& g,
float cam_x, float cam_y, float zoom,
float wmin_x, float wmin_y,
float w, float h,
const LabelPolicy& p,
GetLabelFn cb, void* user)
{
if (!cb) return;
if (g.node_count <= 0) return;
if (w <= 0.0f || h <= 0.0f) return;
// Degrees on-the-fly: O(E) por frame. Cachear si se vuelve hot path.
static thread_local std::vector<int> degrees;
degrees.assign((size_t)g.node_count, 0);
graph_compute_degrees(g, degrees.data());
// Cap duro: max_visible + |always_*|. |always_*| <= node_count.
const int max_total = p.max_visible + g.node_count;
static thread_local std::vector<int> indices;
indices.resize((size_t)max_total);
const int n_labels = graph_labels_select(
g, p, cam_x, cam_y, zoom, w, h,
degrees.data(), indices.data(), max_total);
if (n_labels <= 0) return;
ImDrawList* dl = ImGui::GetWindowDrawList();
ImFont* font = ImGui::GetFont();
if (!dl || !font) return;
const float cx = wmin_x + w * 0.5f;
const float cy = wmin_y + h * 0.5f;
for (int k = 0; k < n_labels; ++k) {
const int idx = indices[k];
const GraphNode& n = g.nodes[idx];
const char* text = cb(idx, user);
if (!text || !*text) continue;
const float vx = (n.x - cam_x) * zoom + cx;
const float vy = (n.y - cam_y) * zoom + cy;
const float node_px = resolve_node_size(n, g.types, g.type_count) * zoom;
const float ox = node_px * 0.5f + p.padding_x;
const float oy = -p.font_size * 0.5f;
const ImVec2 ts = font->CalcTextSizeA(p.font_size, FLT_MAX, 0.0f, text);
const ImVec2 a(vx + ox - p.padding_x, vy + oy - p.padding_y);
const ImVec2 b(vx + ox + ts.x + p.padding_x, vy + oy + ts.y + p.padding_y);
dl->AddRectFilled(a, b, p.bg_color, 2.0f);
dl->AddText(font, p.font_size, ImVec2(vx + ox, vy + oy),
p.color, text);
}
}
void graph_labels_draw(const GraphData& g, const GraphViewportState& s,
const LabelPolicy& p, GetLabelFn cb, void* user)
{
const ImVec2 wmin = ImGui::GetItemRectMin();
const ImVec2 wmax = ImGui::GetItemRectMax();
const float w = wmax.x - wmin.x;
const float h = wmax.y - wmin.y;
graph_labels_draw_at(g, s.cam_x, s.cam_y, s.zoom,
wmin.x, wmin.y, w, h, p, cb, user);
}
} // namespace graph
+65
View File
@@ -0,0 +1,65 @@
#pragma once
#include <cstdint>
// Render de etiquetas de nodos sobre el viewport del grafo (issue 0049j).
// Independiente del renderer GPU: usa `ImDrawList` del current window y se
// compone sobre la imagen del FBO ya pintada por `graph_renderer`.
struct GraphData;
struct GraphViewportState;
namespace graph {
struct LabelPolicy {
bool always_for_selected = true;
bool always_for_hovered = true;
bool always_for_pinned = false;
int max_visible = 200; // top-N por size + degree
float min_zoom_for_all = 4.0f; // a este zoom, mostrar todos los visibles del viewport
float min_node_pixel_size = 12.0f; // skip si en pantalla mide menos
float font_size = 13.0f; // pixels
uint32_t color = 0xFFFFFFFF; // ABGR
uint32_t bg_color = 0xC8000000; // semi-transparente
float padding_x = 4.0f;
float padding_y = 2.0f;
};
// Callback que devuelve el texto del label dado un node_idx. El consumer
// maneja su propio string pool / metadata. Devolver NULL o "" omite el label.
typedef const char* (*GetLabelFn)(int node_idx, void* user);
// Llamar tras `ImGui::Image(...)` del FBO (o tras `graph_viewport`). Usa
// `ImGui::GetItemRectMin/Max()` para conocer el rect del widget y
// `ImGui::GetWindowDrawList()` para pintar.
void graph_labels_draw(const GraphData& graph, const GraphViewportState& state,
const LabelPolicy& policy, GetLabelFn cb, void* user);
// Variante sin ImGui state — util para tests y para callers que ya tienen
// el widget rect resuelto. Pinta sobre el ImDrawList del current window.
// `widget_min_x/y` y `widget_w/h` describen el rect del FBO en pantalla.
void graph_labels_draw_at(const GraphData& graph,
float cam_x, float cam_y, float zoom,
float widget_min_x, float widget_min_y,
float widget_w, float widget_h,
const LabelPolicy& policy,
GetLabelFn cb, void* user);
// --- Helpers puros (exportados para tests y callers avanzados) -----------
// Calcula el grado (numero de aristas incidentes) por nodo. El caller
// reserva `out_degrees` con tamano `graph.node_count`.
void graph_compute_degrees(const GraphData& graph, int* out_degrees);
// Selecciona los indices de nodos que deberian recibir label segun la
// politica. Funcion pura: no toca ImGui ni el grafo. Devuelve el numero de
// indices escritos en `out_indices` (capado a `out_capacity`).
//
// `degrees` puede ser NULL — en ese caso el score de top-N degrada a `size`
// (sin el factor degree+1).
int graph_labels_select(const GraphData& graph, const LabelPolicy& policy,
float cam_x, float cam_y, float zoom,
float widget_w, float widget_h,
const int* degrees,
int* out_indices, int out_capacity);
} // namespace graph
+154
View File
@@ -0,0 +1,154 @@
---
name: graph_labels
kind: function
lang: cpp
domain: viz
version: "1.0.0"
purity: impure
signature: "void graph::graph_labels_draw(const GraphData&, const GraphViewportState&, const LabelPolicy&, GetLabelFn, void*); void graph::graph_labels_draw_at(const GraphData&, float cam_x, float cam_y, float zoom, float wmin_x, float wmin_y, float w, float h, const LabelPolicy&, GetLabelFn, void*); int graph::graph_labels_select(const GraphData&, const LabelPolicy&, float cam_x, float cam_y, float zoom, float w, float h, const int* degrees, int* out_indices, int out_capacity); void graph::graph_compute_degrees(const GraphData&, int* out_degrees)"
description: "Renderiza etiquetas de nodos sobre el viewport del grafo via ImDrawList con politica configurable: always-on para selected/hovered/pinned, top-N por (size * (degree+1)), y culling por viewport + min pixel size. Independiente del renderer GPU."
tags: [graph, labels, imdrawlist, viewport, osint, culling, top-n]
uses_functions: []
uses_types: ["GraphData_cpp_viz"]
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["imgui.h"]
tested: true
tests: ["select respects max_visible cap", "min_node_pixel_size culling", "always_for_selected gates by viewport", "no callback / empty graph no-op"]
test_file_path: "cpp/tests/test_graph_labels.cpp"
file_path: "cpp/functions/viz/graph_labels.cpp"
framework: imgui
params:
- name: graph
desc: "Grafo a etiquetar (lectura). Se respetan NF_VISIBLE, NF_SELECTED, NF_HOVERED, NF_PINNED."
- name: state
desc: "GraphViewportState con camara y zoom. graph_labels_draw lo usa para world->screen."
- name: cam_x
desc: "(draw_at/select) Centro de la camara en world coords (X)."
- name: cam_y
desc: "(draw_at/select) Centro de la camara en world coords (Y)."
- name: zoom
desc: "(draw_at/select) Pixels por unidad world. zoom <= 0 → no-op."
- name: wmin_x
desc: "(draw_at) Esquina superior-izquierda del widget en pixeles de pantalla (X)."
- name: wmin_y
desc: "(draw_at) Esquina superior-izquierda del widget en pixeles de pantalla (Y)."
- name: w
desc: "(draw_at/select) Ancho del widget en pixeles."
- name: h
desc: "(draw_at/select) Alto del widget en pixeles."
- name: policy
desc: "LabelPolicy: max_visible (top-N), always_for_*, min_zoom_for_all, min_node_pixel_size, font_size, color, bg_color, padding."
- name: cb
desc: "Callback `const char* (int node_idx, void* user)` que devuelve el texto del label. NULL/'' omite el nodo."
- name: user
desc: "Puntero opaco que se pasa al callback. Util para pasar el string pool o metadata del consumer."
- name: degrees
desc: "(select) Array de tamano node_count con el grado por nodo. NULL → score = size sin factor degree."
- name: out_indices
desc: "(select) Array de salida con los indices de nodos a etiquetar. Capacidad = out_capacity."
- name: out_capacity
desc: "(select) Capacidad maxima de out_indices. La funcion no escribe mas alla."
- name: out_degrees
desc: "(compute_degrees) Array de tamano node_count que recibe el grado por nodo. Cuenta solo aristas con EF_VISIBLE."
output: "Ninguno (void) salvo graph_labels_select que devuelve int (numero de indices escritos en out_indices). Las funciones draw pintan rectangulos y texto via ImDrawList del current ImGui window."
notes: "Issue 0049j. La estrategia de seleccion es: (A) nodos always_* en viewport sin chequeo de pixel size; (B) resto de nodos visibles que cumplen min_node_pixel_size, capados al top-N por (size * (degree+1)). Off-screen always_* se OMITEN intencionadamente para no spamear. El degree se calcula on-the-fly cada frame en draw_at (O(E)); para hot paths el caller puede precalcular y pasarlo a graph_labels_select. Cap duro = max_visible + |always_*|."
---
# graph_labels
Pintar etiquetas de nodos sobre la imagen del FBO del `graph_renderer`. Pensado
para uso OSINT/Maltego: necesitas leer "juan@x.com", IBAN, etc. en los nodos
relevantes, pero no puedes mostrar 20k labels — el ojo y la GPU se saturan.
## Estrategia
Cada frame el algoritmo decide que nodos etiquetar:
1. **Always-on** — si `LabelPolicy.always_for_{selected,hovered,pinned}` esta
activo y el nodo lleva ese flag, se etiqueta. Skip de `min_node_pixel_size`,
pero sigue requiriendo estar dentro del viewport (decision: off-screen no se
dibuja para no spamear con flechas o etiquetas en los bordes).
2. **Top-N** — del resto de nodos visibles en el viewport que ademas tienen
`size * zoom >= min_node_pixel_size`, se ordena por `score = size * (degree+1)`
y se cogen los `max_visible` primeros.
3. **Cap duro** — total = `max_visible + |always_*|`. Sin opciones de saltarse
esto. Si el caller quiere 0 labels excepto los always, basta poner
`max_visible = 0`.
`min_zoom_for_all` esta en la struct por compatibilidad con el spec original;
en la implementacion v1 actua como un parametro suave: con zoom alto la mayoria
de nodos visibles superan `min_node_pixel_size` y entran al top-N por si solos,
asi que el efecto deseado (mostrar todos a zoom alto) sale naturalmente sin
ramas adicionales.
## API
```cpp
namespace graph {
struct LabelPolicy { /* ... */ };
typedef const char* (*GetLabelFn)(int node_idx, void* user);
// Tras ImGui::Image(...) o graph_viewport(...). Resuelve rect via
// GetItemRectMin/Max y usa el ImDrawList del current window.
void graph_labels_draw (const GraphData&, const GraphViewportState&,
const LabelPolicy&, GetLabelFn, void*);
// Variante explicita: el caller pasa cam, zoom y rect del widget.
void graph_labels_draw_at(const GraphData&,
float cam_x, float cam_y, float zoom,
float wmin_x, float wmin_y,
float w, float h,
const LabelPolicy&, GetLabelFn, void*);
// Helpers puros (testables sin ImGui).
void graph_compute_degrees(const GraphData&, int* out_degrees);
int graph_labels_select (const GraphData&, const LabelPolicy&,
float cam_x, float cam_y, float zoom,
float w, float h,
const int* degrees,
int* out_indices, int out_capacity);
}
```
## Uso tipico (en demos_graph)
```cpp
graph_viewport("##g", graph, state, ImVec2(0, 460));
graph::LabelPolicy policy;
policy.max_visible = 200;
policy.always_for_selected = true;
policy.always_for_hovered = true;
policy.min_node_pixel_size = 12.0f;
struct LabelCtx { char buf[32]; };
auto get_label = [](int idx, void* user) -> const char* {
auto* ctx = (LabelCtx*)user;
std::snprintf(ctx->buf, sizeof(ctx->buf), "#%d", idx);
return ctx->buf;
};
LabelCtx ctx;
graph::graph_labels_draw(graph, state, policy, get_label, &ctx);
```
## Coste
- `graph_compute_degrees`: O(E).
- `graph_labels_select`: O(N) para frustum cull + O(K log K) si K candidatos
superan `max_visible` (partial_sort).
- Por label: 1 `AddRectFilled` + 1 `AddText`. ImDrawList es eficiente; con
`max_visible = 200` apenas se nota.
## Notas de integracion
- Llamar **despues** de `graph_viewport` (o `ImGui::Image` del FBO). El widget
rect se resuelve con `ImGui::GetItemRectMin/Max()`.
- Las labels se dibujan en el `ImDrawList` del **current window**. Si el FBO
esta dentro de un `BeginChild/EndChild`, el clip rect del child puede
recortar etiquetas de nodos pegados a los bordes — usar `draw_at` y pasar
el rect que se quiera respetar.
- El callback se invoca como mucho una vez por nodo etiquetado por frame.
- Para reproducibilidad en tests, usar `graph_labels_select` con un buffer
de capacidad conocida — no toca ImGui.
+107
View File
@@ -0,0 +1,107 @@
// Helpers puros de graph_labels (issue 0049j). Sin ImGui ni OpenGL — vive en
// su propio TU para que los tests unitarios puedan ejercer la seleccion de
// candidatos sin pintar nada. La parte de pintado (graph_labels_draw /
// graph_labels_draw_at) vive en graph_labels.cpp y depende de ImGui.
#include "viz/graph_labels.h"
#include "viz/graph_types.h"
#include <algorithm>
#include <vector>
namespace graph {
void graph_compute_degrees(const GraphData& g, int* out_degrees) {
if (!out_degrees || g.node_count <= 0) return;
for (int i = 0; i < g.node_count; ++i) out_degrees[i] = 0;
for (int e = 0; e < g.edge_count; ++e) {
const GraphEdge& ed = g.edges[e];
if ((ed.flags & EF_VISIBLE) == 0) continue;
if (ed.source < (uint32_t)g.node_count) ++out_degrees[ed.source];
if (ed.target < (uint32_t)g.node_count) ++out_degrees[ed.target];
}
}
int graph_labels_select(const GraphData& g, const LabelPolicy& p,
float cam_x, float cam_y, float zoom,
float widget_w, float widget_h,
const int* degrees,
int* out_indices, int out_capacity)
{
if (!out_indices || out_capacity <= 0) return 0;
if (g.node_count <= 0) return 0;
if (zoom <= 0.0f) return 0;
if (widget_w <= 0.0f || widget_h <= 0.0f) return 0;
const float half_w = widget_w * 0.5f / zoom;
const float half_h = widget_h * 0.5f / zoom;
const float min_wx = cam_x - half_w;
const float max_wx = cam_x + half_w;
const float min_wy = cam_y - half_h;
const float max_wy = cam_y + half_h;
auto in_view = [&](float x, float y) {
return x >= min_wx && x <= max_wx && y >= min_wy && y <= max_wy;
};
int written = 0;
// Pase A: nodos always_* en viewport. Skip min_node_pixel_size, pero
// off-screen NO se dibuja (decision documentada en el issue).
for (int i = 0; i < g.node_count && written < out_capacity; ++i) {
const GraphNode& n = g.nodes[i];
if ((n.flags & NF_VISIBLE) == 0) continue;
if (!in_view(n.x, n.y)) continue;
const bool sel = (n.flags & NF_SELECTED) && p.always_for_selected;
const bool hov = (n.flags & NF_HOVERED) && p.always_for_hovered;
const bool pin = (n.flags & NF_PINNED) && p.always_for_pinned;
if (sel || hov || pin) {
out_indices[written++] = i;
}
}
const int always_count = written;
if (p.max_visible <= 0) return written;
struct Cand { int idx; float score; };
static thread_local std::vector<Cand> cands;
cands.clear();
cands.reserve((size_t)g.node_count);
for (int i = 0; i < g.node_count; ++i) {
const GraphNode& n = g.nodes[i];
if ((n.flags & NF_VISIBLE) == 0) continue;
if (!in_view(n.x, n.y)) continue;
const float size = resolve_node_size(n, g.types, g.type_count);
if (size * zoom < p.min_node_pixel_size) continue;
bool already = false;
for (int k = 0; k < always_count; ++k) {
if (out_indices[k] == i) { already = true; break; }
}
if (already) continue;
const int deg = degrees ? degrees[i] : 0;
const float sc = size * (float)(deg + 1);
cands.push_back(Cand{ i, sc });
}
int budget = p.max_visible;
if ((int)cands.size() > budget) {
std::partial_sort(
cands.begin(), cands.begin() + budget, cands.end(),
[](const Cand& a, const Cand& b) { return a.score > b.score; });
cands.resize((size_t)budget);
}
(void)p.min_zoom_for_all; // gate suave: a zoom alto el min_pixel filtra menos
for (size_t k = 0; k < cands.size() && written < out_capacity; ++k) {
out_indices[written++] = cands[k].idx;
}
return written;
}
} // namespace graph