merge: issue/0049j-graph-labels — graph_labels + LabelPolicy + ImDrawList overlay
This commit is contained in:
@@ -73,6 +73,8 @@ add_imgui_app(primitives_gallery
|
||||
${CMAKE_SOURCE_DIR}/functions/viz/graph_layouts.cpp
|
||||
${CMAKE_SOURCE_DIR}/functions/viz/graph_viewport.cpp
|
||||
${CMAKE_SOURCE_DIR}/functions/viz/graph_viewport_selection.cpp
|
||||
${CMAKE_SOURCE_DIR}/functions/viz/graph_labels.cpp
|
||||
${CMAKE_SOURCE_DIR}/functions/viz/graph_labels_select.cpp
|
||||
${CMAKE_SOURCE_DIR}/functions/core/graph_spatial_hash.cpp
|
||||
# GL loader (Linux no-op, Windows wglGetProcAddress)
|
||||
${CMAKE_SOURCE_DIR}/functions/gfx/gl_loader.cpp
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include "viz/graph_force_layout.h"
|
||||
#include "viz/graph_force_layout_gpu.h"
|
||||
#include "viz/graph_layouts.h"
|
||||
#include "viz/graph_labels.h"
|
||||
#include "core/button.h"
|
||||
#include "core/tokens.h"
|
||||
|
||||
@@ -157,6 +158,10 @@ void demo_graph() {
|
||||
};
|
||||
static int s_apply_layout = 0; // se incrementa cuando hay que reaplicar
|
||||
|
||||
// Labels (issue 0049j). LabelPolicy controlable desde la UI.
|
||||
static graph::LabelPolicy s_label_policy;
|
||||
static bool s_labels_enabled = true;
|
||||
|
||||
if (s_needs_regen) {
|
||||
init_demo_types();
|
||||
generate_synthetic_graph(s_n_nodes, s_n_clusters,
|
||||
@@ -238,6 +243,25 @@ void demo_graph() {
|
||||
ImGui::PopItemWidth();
|
||||
ImGui::SameLine();
|
||||
if (button("Apply layout", ButtonVariant::Subtle)) s_apply_layout++;
|
||||
|
||||
// --- Labels (issue 0049j) ---------------------------------------
|
||||
ImGui::Checkbox("Labels", &s_labels_enabled);
|
||||
ImGui::SameLine();
|
||||
ImGui::PushItemWidth(140);
|
||||
ImGui::SliderInt("Max visible", &s_label_policy.max_visible, 0, 1000);
|
||||
ImGui::SameLine();
|
||||
ImGui::SliderFloat("Font", &s_label_policy.font_size,
|
||||
8.0f, 24.0f, "%.0f");
|
||||
ImGui::SameLine();
|
||||
ImGui::SliderFloat("Min px", &s_label_policy.min_node_pixel_size,
|
||||
0.0f, 40.0f, "%.0f");
|
||||
ImGui::PopItemWidth();
|
||||
ImGui::SameLine();
|
||||
ImGui::Checkbox("Selected", &s_label_policy.always_for_selected);
|
||||
ImGui::SameLine();
|
||||
ImGui::Checkbox("Hovered", &s_label_policy.always_for_hovered);
|
||||
ImGui::SameLine();
|
||||
ImGui::Checkbox("Pinned", &s_label_policy.always_for_pinned);
|
||||
}
|
||||
|
||||
section("Stats");
|
||||
@@ -359,6 +383,21 @@ void demo_graph() {
|
||||
|
||||
graph_viewport("##graph_demo", s_graph, s_state, ImVec2(0, 460), cb);
|
||||
|
||||
// Labels overlay (issue 0049j). El callback formatea "#<idx>" en un
|
||||
// buffer estatico por demo — apps reales (osint_graph) usaran un
|
||||
// string pool de la BD origen.
|
||||
if (s_labels_enabled) {
|
||||
struct LblCtx { char buf[32]; };
|
||||
static LblCtx s_lbl_ctx;
|
||||
auto get_label = [](int idx, void* user) -> const char* {
|
||||
auto* ctx = static_cast<LblCtx*>(user);
|
||||
std::snprintf(ctx->buf, sizeof(ctx->buf), "#%d", idx);
|
||||
return ctx->buf;
|
||||
};
|
||||
graph::graph_labels_draw(s_graph, s_state, s_label_policy,
|
||||
get_label, &s_lbl_ctx);
|
||||
}
|
||||
|
||||
if (ImGui::BeginPopup("##graph_node_ctx")) {
|
||||
ImGui::Text("Node #%d", s_ctx_node);
|
||||
ImGui::Separator();
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -86,6 +86,11 @@ add_fn_test(test_graph_layouts test_graph_layouts.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/../functions/viz/graph_layouts.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/../functions/viz/graph_types.cpp)
|
||||
|
||||
# --- Issue 0049j — graph_labels (LabelPolicy + select pure logic) ---------
|
||||
add_fn_test(test_graph_labels test_graph_labels.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/../functions/viz/graph_labels_select.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/../functions/viz/graph_types.cpp)
|
||||
|
||||
# --- Issue 0049i — graph_viewport selection helpers (logica pura sin GL) ---
|
||||
# Solo cubre graph_viewport_selection.cpp; el widget completo se prueba en
|
||||
# primitives_gallery + golden image diff. graph_viewport.h incluye ImVec2,
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 95 KiB |
@@ -0,0 +1,294 @@
|
||||
// Unit tests para graph_labels (issue 0049j). Cubre la logica pura de
|
||||
// seleccion (graph_labels_select) y graph_compute_degrees:
|
||||
// - culling por viewport (AABB)
|
||||
// - cap por max_visible
|
||||
// - min_node_pixel_size
|
||||
// - always_for_selected requiere estar en viewport
|
||||
// - empty graph no-op
|
||||
//
|
||||
// La parte de pintado (graph_labels_draw / graph_labels_draw_at) usa ImGui y
|
||||
// se ejercita visualmente en primitives_gallery.
|
||||
|
||||
#define CATCH_CONFIG_MAIN
|
||||
#include "catch_amalgamated.hpp"
|
||||
|
||||
#include "viz/graph_labels.h"
|
||||
#include "viz/graph_types.h"
|
||||
|
||||
#include <vector>
|
||||
|
||||
namespace {
|
||||
|
||||
// Helper: construye un GraphData con N nodos en una rejilla de world coords
|
||||
// donde el nodo i esta en (i*step, 0). Tamaño base = 5 px.
|
||||
struct GridGraph {
|
||||
std::vector<GraphNode> nodes;
|
||||
std::vector<GraphEdge> edges;
|
||||
GraphData data{};
|
||||
|
||||
void make(int N, float step = 10.0f, float size = 5.0f) {
|
||||
nodes.clear();
|
||||
edges.clear();
|
||||
for (int i = 0; i < N; ++i) {
|
||||
GraphNode n = graph_node((float)i * step, 0.0f);
|
||||
n.size_override = size;
|
||||
nodes.push_back(n);
|
||||
}
|
||||
sync();
|
||||
}
|
||||
|
||||
void add_edge(uint32_t s, uint32_t t) {
|
||||
edges.push_back(graph_edge(s, t));
|
||||
sync();
|
||||
}
|
||||
|
||||
void sync() {
|
||||
data.nodes = nodes.data();
|
||||
data.node_count = (int)nodes.size();
|
||||
data.node_capacity = (int)nodes.capacity();
|
||||
data.edges = edges.data();
|
||||
data.edge_count = (int)edges.size();
|
||||
data.edge_capacity = (int)edges.capacity();
|
||||
data.types = nullptr; data.type_count = 0;
|
||||
data.rel_types = nullptr; data.rel_type_count = 0;
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("graph_compute_degrees counts incident edges", "[viz][labels][degrees]") {
|
||||
GridGraph g; g.make(4);
|
||||
g.add_edge(0, 1);
|
||||
g.add_edge(0, 2);
|
||||
g.add_edge(1, 2);
|
||||
|
||||
int deg[4];
|
||||
graph::graph_compute_degrees(g.data, deg);
|
||||
REQUIRE(deg[0] == 2);
|
||||
REQUIRE(deg[1] == 2);
|
||||
REQUIRE(deg[2] == 2);
|
||||
REQUIRE(deg[3] == 0);
|
||||
}
|
||||
|
||||
TEST_CASE("graph_compute_degrees skips invisible edges", "[viz][labels][degrees]") {
|
||||
GridGraph g; g.make(3);
|
||||
g.add_edge(0, 1);
|
||||
g.add_edge(0, 2);
|
||||
g.edges[0].flags &= ~EF_VISIBLE; // hide first edge
|
||||
|
||||
int deg[3];
|
||||
graph::graph_compute_degrees(g.data, deg);
|
||||
REQUIRE(deg[0] == 1);
|
||||
REQUIRE(deg[1] == 0);
|
||||
REQUIRE(deg[2] == 1);
|
||||
}
|
||||
|
||||
TEST_CASE("graph_labels_select respects max_visible cap", "[viz][labels][select]") {
|
||||
// 100 nodos en linea, todos en viewport, todos por encima de min_pixel.
|
||||
GridGraph g; g.make(100, 10.0f, 5.0f);
|
||||
|
||||
graph::LabelPolicy p;
|
||||
p.max_visible = 7;
|
||||
p.always_for_selected = false;
|
||||
p.always_for_hovered = false;
|
||||
p.always_for_pinned = false;
|
||||
p.min_node_pixel_size = 0.0f; // no cull por tamano
|
||||
|
||||
int out[200];
|
||||
// Camara centrada en x=500 (medio de la linea), zoom 5 (5 px / world unit)
|
||||
// widget enorme para que todos esten en viewport.
|
||||
int n = graph::graph_labels_select(g.data, p,
|
||||
/*cam_x=*/500.0f, /*cam_y=*/0.0f,
|
||||
/*zoom=*/5.0f,
|
||||
/*w=*/100000.0f, /*h=*/100000.0f,
|
||||
/*degrees=*/nullptr,
|
||||
out, 200);
|
||||
REQUIRE(n == 7);
|
||||
}
|
||||
|
||||
TEST_CASE("graph_labels_select skips nodes off-viewport", "[viz][labels][select]") {
|
||||
// 20 nodos en x = 0..190. Camara en x=0, zoom=1, widget 100x100.
|
||||
// Visible AABB = x in [-50, 50], y in [-50, 50].
|
||||
// Nodos visibles: i*10 in [-50,50] -> i in [0..5].
|
||||
GridGraph g; g.make(20, 10.0f, 5.0f);
|
||||
|
||||
graph::LabelPolicy p;
|
||||
p.max_visible = 100;
|
||||
p.min_node_pixel_size = 0.0f;
|
||||
p.always_for_selected = false;
|
||||
p.always_for_hovered = false;
|
||||
|
||||
int out[100];
|
||||
int n = graph::graph_labels_select(g.data, p,
|
||||
0.0f, 0.0f, 1.0f,
|
||||
100.0f, 100.0f,
|
||||
nullptr, out, 100);
|
||||
REQUIRE(n == 6); // i = 0..5
|
||||
}
|
||||
|
||||
TEST_CASE("graph_labels_select drops nodes below min_node_pixel_size",
|
||||
"[viz][labels][select]") {
|
||||
// Nodos de tamano 1 world unit. zoom=1 -> 1 px en pantalla.
|
||||
// min_node_pixel_size=12 -> ninguno pasa el filtro top-N.
|
||||
GridGraph g; g.make(10, 5.0f, 1.0f);
|
||||
|
||||
graph::LabelPolicy p;
|
||||
p.max_visible = 100;
|
||||
p.min_node_pixel_size = 12.0f;
|
||||
p.always_for_selected = false;
|
||||
p.always_for_hovered = false;
|
||||
|
||||
int out[100];
|
||||
int n = graph::graph_labels_select(g.data, p,
|
||||
25.0f, 0.0f, 1.0f,
|
||||
1000.0f, 1000.0f,
|
||||
nullptr, out, 100);
|
||||
REQUIRE(n == 0);
|
||||
}
|
||||
|
||||
TEST_CASE("graph_labels_select always_for_selected: in-viewport on, off-viewport off",
|
||||
"[viz][labels][select][selected]") {
|
||||
GridGraph g; g.make(20, 10.0f, 1.0f);
|
||||
// Nodo 0 (x=0, dentro del viewport) seleccionado y nodo 19 (x=190,
|
||||
// fuera) seleccionado. Solo el primero debe entrar en out.
|
||||
g.nodes[0].flags |= NF_SELECTED;
|
||||
g.nodes[19].flags |= NF_SELECTED;
|
||||
g.sync();
|
||||
|
||||
graph::LabelPolicy p;
|
||||
p.max_visible = 0; // solo always_*
|
||||
p.min_node_pixel_size = 999.0f; // cualquier top-N descartado
|
||||
p.always_for_selected = true;
|
||||
p.always_for_hovered = false;
|
||||
p.always_for_pinned = false;
|
||||
|
||||
int out[10];
|
||||
// viewport AABB en cam=0, zoom=1, w=h=100 -> x in [-50,50]
|
||||
int n = graph::graph_labels_select(g.data, p,
|
||||
0.0f, 0.0f, 1.0f,
|
||||
100.0f, 100.0f,
|
||||
nullptr, out, 10);
|
||||
REQUIRE(n == 1);
|
||||
REQUIRE(out[0] == 0);
|
||||
}
|
||||
|
||||
TEST_CASE("graph_labels_select always_* skip min_pixel_size",
|
||||
"[viz][labels][select][selected]") {
|
||||
// Nodos de 1 px world. Sin always_*, max_visible=100, min_pixel=12 -> 0.
|
||||
// Con always_for_hovered y un nodo HOVERED en viewport, debe dibujarse
|
||||
// el HOVERED a pesar de ser pequenio.
|
||||
GridGraph g; g.make(5, 5.0f, 1.0f);
|
||||
g.nodes[0].flags |= NF_HOVERED;
|
||||
g.sync();
|
||||
|
||||
graph::LabelPolicy p;
|
||||
p.max_visible = 100;
|
||||
p.min_node_pixel_size = 12.0f;
|
||||
p.always_for_selected = false;
|
||||
p.always_for_hovered = true;
|
||||
p.always_for_pinned = false;
|
||||
|
||||
int out[10];
|
||||
int n = graph::graph_labels_select(g.data, p,
|
||||
10.0f, 0.0f, 1.0f,
|
||||
1000.0f, 1000.0f,
|
||||
nullptr, out, 10);
|
||||
REQUIRE(n == 1);
|
||||
REQUIRE(out[0] == 0);
|
||||
}
|
||||
|
||||
TEST_CASE("graph_labels_select max_visible=0 + always returns only always",
|
||||
"[viz][labels][select]") {
|
||||
GridGraph g; g.make(10, 5.0f, 5.0f);
|
||||
g.nodes[3].flags |= NF_PINNED;
|
||||
g.sync();
|
||||
|
||||
graph::LabelPolicy p;
|
||||
p.max_visible = 0;
|
||||
p.min_node_pixel_size = 0.0f;
|
||||
p.always_for_selected = false;
|
||||
p.always_for_hovered = false;
|
||||
p.always_for_pinned = true;
|
||||
|
||||
int out[10];
|
||||
int n = graph::graph_labels_select(g.data, p,
|
||||
25.0f, 0.0f, 1.0f,
|
||||
1000.0f, 1000.0f,
|
||||
nullptr, out, 10);
|
||||
REQUIRE(n == 1);
|
||||
REQUIRE(out[0] == 3);
|
||||
}
|
||||
|
||||
TEST_CASE("graph_labels_select picks top-N by size*degree", "[viz][labels][select][topn]") {
|
||||
// 5 nodos, todos en viewport. Tamano uniforme. Damos al nodo 2 mas grado
|
||||
// (3 aristas) — debe entrar primero en top-N=1.
|
||||
GridGraph g; g.make(5, 5.0f, 5.0f);
|
||||
g.add_edge(2, 0);
|
||||
g.add_edge(2, 1);
|
||||
g.add_edge(2, 3);
|
||||
|
||||
int deg[5];
|
||||
graph::graph_compute_degrees(g.data, deg);
|
||||
|
||||
graph::LabelPolicy p;
|
||||
p.max_visible = 1;
|
||||
p.min_node_pixel_size = 0.0f;
|
||||
p.always_for_selected = false;
|
||||
p.always_for_hovered = false;
|
||||
|
||||
int out[5];
|
||||
int n = graph::graph_labels_select(g.data, p,
|
||||
10.0f, 0.0f, 1.0f,
|
||||
1000.0f, 1000.0f,
|
||||
deg, out, 5);
|
||||
REQUIRE(n == 1);
|
||||
REQUIRE(out[0] == 2);
|
||||
}
|
||||
|
||||
TEST_CASE("graph_labels_select skips NF_VISIBLE=0 nodes", "[viz][labels][select]") {
|
||||
GridGraph g; g.make(5, 5.0f, 5.0f);
|
||||
g.nodes[2].flags &= ~NF_VISIBLE;
|
||||
g.sync();
|
||||
|
||||
graph::LabelPolicy p;
|
||||
p.max_visible = 100;
|
||||
p.min_node_pixel_size = 0.0f;
|
||||
p.always_for_selected = false;
|
||||
p.always_for_hovered = false;
|
||||
|
||||
int out[10];
|
||||
int n = graph::graph_labels_select(g.data, p,
|
||||
10.0f, 0.0f, 1.0f,
|
||||
1000.0f, 1000.0f,
|
||||
nullptr, out, 10);
|
||||
REQUIRE(n == 4);
|
||||
for (int k = 0; k < n; ++k) REQUIRE(out[k] != 2);
|
||||
}
|
||||
|
||||
TEST_CASE("graph_labels_select empty graph no-op", "[viz][labels][select][empty]") {
|
||||
GridGraph g; g.make(0);
|
||||
graph::LabelPolicy p;
|
||||
int out[10];
|
||||
int n = graph::graph_labels_select(g.data, p,
|
||||
0.0f, 0.0f, 1.0f,
|
||||
100.0f, 100.0f,
|
||||
nullptr, out, 10);
|
||||
REQUIRE(n == 0);
|
||||
}
|
||||
|
||||
TEST_CASE("graph_labels_select degenerate inputs return 0", "[viz][labels][select][edge]") {
|
||||
GridGraph g; g.make(5);
|
||||
graph::LabelPolicy p;
|
||||
int out[10];
|
||||
|
||||
// zoom <= 0
|
||||
REQUIRE(graph::graph_labels_select(g.data, p, 0,0, 0.0f, 100,100, nullptr, out, 10) == 0);
|
||||
REQUIRE(graph::graph_labels_select(g.data, p, 0,0, -1.0f, 100,100, nullptr, out, 10) == 0);
|
||||
// widget invalido
|
||||
REQUIRE(graph::graph_labels_select(g.data, p, 0,0, 1.0f, 0.0f, 100, nullptr, out, 10) == 0);
|
||||
REQUIRE(graph::graph_labels_select(g.data, p, 0,0, 1.0f, 100, 0.0f, nullptr, out, 10) == 0);
|
||||
// out_capacity 0
|
||||
REQUIRE(graph::graph_labels_select(g.data, p, 0,0, 1.0f, 100,100, nullptr, out, 0) == 0);
|
||||
// out null
|
||||
REQUIRE(graph::graph_labels_select(g.data, p, 0,0, 1.0f, 100,100, nullptr, nullptr, 10) == 0);
|
||||
}
|
||||
@@ -64,5 +64,5 @@
|
||||
| [0049g](completed/0049g-graph-source-operations.md) | graph_sources: lector operations.db + abstraccion funcional | completado | alta | feature | parte de 0049 |
|
||||
| [0049h](completed/0049h-graph-force-layout-gpu.md) | graph_force_layout_gpu: compute shader + spatial hash | completado | media-alta | feature | parte de 0049 |
|
||||
| [0049i](completed/0049i-graph-layouts-static.md) | graph_layouts (radial/hierarchical/fixed) + viewport multi-select | completado | media | feature | parte de 0049 |
|
||||
| [0049j](0049j-graph-labels.md) | graph_labels: render etiquetas con LabelPolicy | pendiente | media | feature | parte de 0049 |
|
||||
| [0049j](completed/0049j-graph-labels.md) | graph_labels: render etiquetas con LabelPolicy | completado | media | feature | parte de 0049 |
|
||||
| [0049k](0049k-graph-explorer-app.md) | App graph_explorer (proyecto osint_graph) — integracion final | pendiente | alta | feature | parte de 0049 |
|
||||
|
||||
Reference in New Issue
Block a user