From 4b28ef67744a0ae9b1c9d792a166393cdbccf9dd Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Wed, 29 Apr 2026 23:53:32 +0200 Subject: [PATCH] feat(viz): graph_labels render con LabelPolicy + ImDrawList (issue 0049j) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- cpp/apps/primitives_gallery/CMakeLists.txt | 2 + cpp/apps/primitives_gallery/demos_graph.cpp | 39 +++ cpp/functions/viz/graph_labels.cpp | 83 +++++ cpp/functions/viz/graph_labels.h | 65 ++++ cpp/functions/viz/graph_labels.md | 154 +++++++++ cpp/functions/viz/graph_labels_select.cpp | 107 +++++++ cpp/tests/CMakeLists.txt | 5 + cpp/tests/golden/graph_viewport.png | Bin 93666 -> 97383 bytes cpp/tests/test_graph_labels.cpp | 294 ++++++++++++++++++ dev/issues/README.md | 2 +- .../{ => completed}/0049j-graph-labels.md | 0 11 files changed, 750 insertions(+), 1 deletion(-) create mode 100644 cpp/functions/viz/graph_labels.cpp create mode 100644 cpp/functions/viz/graph_labels.h create mode 100644 cpp/functions/viz/graph_labels.md create mode 100644 cpp/functions/viz/graph_labels_select.cpp create mode 100644 cpp/tests/test_graph_labels.cpp rename dev/issues/{ => completed}/0049j-graph-labels.md (100%) diff --git a/cpp/apps/primitives_gallery/CMakeLists.txt b/cpp/apps/primitives_gallery/CMakeLists.txt index a029e980..c71a2139 100644 --- a/cpp/apps/primitives_gallery/CMakeLists.txt +++ b/cpp/apps/primitives_gallery/CMakeLists.txt @@ -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 diff --git a/cpp/apps/primitives_gallery/demos_graph.cpp b/cpp/apps/primitives_gallery/demos_graph.cpp index b4f8e454..11cce7ab 100644 --- a/cpp/apps/primitives_gallery/demos_graph.cpp +++ b/cpp/apps/primitives_gallery/demos_graph.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 "#" 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(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(); diff --git a/cpp/functions/viz/graph_labels.cpp b/cpp/functions/viz/graph_labels.cpp new file mode 100644 index 00000000..a8b08a4b --- /dev/null +++ b/cpp/functions/viz/graph_labels.cpp @@ -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 +#include + +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 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 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 diff --git a/cpp/functions/viz/graph_labels.h b/cpp/functions/viz/graph_labels.h new file mode 100644 index 00000000..2cc0499c --- /dev/null +++ b/cpp/functions/viz/graph_labels.h @@ -0,0 +1,65 @@ +#pragma once +#include + +// 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 diff --git a/cpp/functions/viz/graph_labels.md b/cpp/functions/viz/graph_labels.md new file mode 100644 index 00000000..167f22b9 --- /dev/null +++ b/cpp/functions/viz/graph_labels.md @@ -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. diff --git a/cpp/functions/viz/graph_labels_select.cpp b/cpp/functions/viz/graph_labels_select.cpp new file mode 100644 index 00000000..99771843 --- /dev/null +++ b/cpp/functions/viz/graph_labels_select.cpp @@ -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 +#include + +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 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 diff --git a/cpp/tests/CMakeLists.txt b/cpp/tests/CMakeLists.txt index c7caca29..88c49d8f 100644 --- a/cpp/tests/CMakeLists.txt +++ b/cpp/tests/CMakeLists.txt @@ -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, diff --git a/cpp/tests/golden/graph_viewport.png b/cpp/tests/golden/graph_viewport.png index f7ebf7a9ab9b39ffc5854f9351909431781ba928..9ca4d76b61000382152875f1e736e3bb2089deea 100644 GIT binary patch delta 35043 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

f9)^!$nc&)Xdj~bmpbisu^ivtm z>(?Y!7@~qJUE%!23`Uv!-m54nwF;#jR;!3lpxgc?fsdNEY-tm={Po)YR`MRCUMUZ4 zM~|zV#U1*MEqK-5-aG-<4=47=X>!x923;k*fB%TE6>RxH&-4=+9E9FJqN^yUE?^2R z6VcCY&GjSR_eFLsy%!gIi>Cm{7T zqaTjJ=4%{|MV`FBLTrNF^B3p0qM1;9<&*5@yo#sU$9PDAhRq}?yy^yu+kz7r1VWD9 zc}9DW6-hOphWKlYPKe&z zC$?h0>c=u##7V+U?I`$_bLPb%`ir`(TbNnQ0DRM;A^y284nKs}6Ajt2zbm<^>HDuN zxe88aMFfEd-!K38EWTqEnUeG2T|eGB$CeA(paPlwvLDaN`S)Qu>SP^W4$&su0wO%t z(*I&D2R0p)FltZJ5}(=aw{GX#u?UiVu`l|6KUy;}vrqK*5+A2E(#@FC(YXO;%VJ_D z=nMLiWPM0hKPrq8V3YbAv}=Kg(=RwZF`k}tm}{hn!>H3-(NA&d^^MG=)X8@wZUt>o z{`+rQWipbh8q1sm47=(tG`JDB zyV=%BtII)CO^K@}&fAMVSuY*Fqr6HR5H{nOWW|5flnz0Pt@IF?=XSdUF>`7jz6g(9 zkj~DMmNM7f|67Y@=E9|W?>)&2wZ6iqYE@1JfvX1(xfON3;cJwte4I;W)*bKOmUhn! z7b}-zFWnT!?{V{V!I$Dbh6Xe9S$N|}$(csd3x?GKpQ;EQCDCt8Y4%D39Uhqz$O2Tg z2JrfhxUv>62J1eYK+P6i#LBIzayq9xCUC(_?8hE7OF{R*9{la7>f_*Ljg(CnvKt-15ueG>x*Fxxv>Ox0HdL zsIMbA28*v?Kjwp}kJeA;`2W!q_^&pB%i~+Cfp(qI6w@01~+$<%G0 zCl&{3S~^OGS4mEruS=L;UIBAT#$A~85dH6ag)>UXwWWzu4Vgs*S>eqPb&1q#7={&g zzZ1VB8wFG&LqFQvn82h?9!%8Kv8o?Evf@9?B%*Mf7)7z5xBMsx#UAMS_~CfqtE(H} ze#TO$2)&iN%ie6QU*(BD*q%l(d+>lDJ0(l-$Xy#cL6QoN|GPlp=jK}RK@bkZYwjQY z;K>QY1q1BD&kgT$0SkqtuYV4tKR-I1`i?(Xv|2EoUqXEp)22juL>LIj~pUV!VwVRbKv;Cag}J z82G+@8AUd$N}A;=ti@5srV&|Qc>HMu?c^bHTtsgT!c1|Mf!>-%37W7ANJ(KOIfivg zXKc^mp7~Ud$uO5TFu?xC%9hp4(#UZ&HCKcaI3pZ7F4C9R((irG@8!hsyEU+lNP(`$ za=-&)i@$$r*YQyEHO1~ZT~PXi;yY?4ZTpdRAg`~R`)-Y}BJmiYqykwG{SVIRr}XKH4K&3H;a0)n~K-eb9XtZYe|0@WYJ^f9e zp{_t}=6vtaN#8B>l+w>?Z8IciWMsl@%ZQ#Z<~}n2QjR6qLDDyxLcDFs{?Tiidx-ai z`}0bQCaZ&40Z?2_&r+OYK=AY4eAf4@aw-}A{) z_J8f;cT*Y&(mYTd7Q5(w-f9bFWH#QOvRr6p{llryko~svJa>|2iN}*U-J$UG+?4EDk zu#!k~dTDO<9H4=CQq#?C$XaHsc^I3bAXXFox{?4zvNyR!a~m{UK@Z)`PJbxM-j;LMgjcdO72 zrGJpOiXMwB8^vDu2YxR1bqWGeb>^%D)LXQl`Uuj$7UQ1H-Ixtq*irAbGFr>}4+J=I zv3c(x3QlD9!lEE19W_5?4nxYPfgR(!cYidQK-!D$frBdg*}P_joLMMJrGlWKl)X7) z^RgwDWs@+usxFq*CF`z16M1hYik+)V9^?*bx&=jP^2^LOX-!`$L|YGf@*6rQK4PI)hUPGKk?>rB+H-TQT9q$p-rdsXcbu7(9%M)spS?2+KOJJ3$YF-Ua;S+<5b!_dunpg@hG+vsBA#o zs^4>Qk1*3a*!u8Kj!SKt{6nq7v`!WmVcD%>bdlsO8?KpOSYVcfwK5SFOR@UryD$7B zY*-A(T1xDqa-My@w->&z_CsuZ#VAf4Y{@l+)u8&%LF%T)Y&Dc|Cel)gPP}z<`*jmC zq7JXr`wbkwXqrsro0$}+QvHvBYBr(>jk}Y8xCt&;hE-xB*531dE>m@*kBbxRJ9YOG zsQ9JQE?b2xI!8ZE-?;4UIPL&G*}y#6j1C(^5^x=pTDpz0}bDkW$O8| zzR8LY=i7ytRhFQe!(e%OnbraPXcEtE=d?x-?wc~xCAM*DO#iD1(EowP{%~jaV~Y3M z0BrlN&ra0@{MXm;G5{F{_dDsl)d%D+2W^u`*q`)DaHw}LR!fV)vin#wfwWv+lNoh(sXK4mwQwm&6**uC4Ft>Uc#tfApu`$%*xcBHgv?p zmRGM}Q|Hfff#Vl#2@uxUO?8KrnnOII=lRGxp~Qqon`VP+)}etmX5=ln!+5Fyq+22IA$UBp zhSFE#<<|4;XkUain4IU3r@mV@nbQTW=Ft1i1iuK)D$e&~B68}~#qxC7qs)|VDoTiF zKO8wZX=#{f5?cQf<0tU}>HH1G?02}+XfJtCeDX{IaQ-W697kk=J?)JxIQ1RKSji4v zF)fGEl!A`k%!pl47SWHng}G*$<4|@9Npx2Q+CMuZzT!nJlz!$tU*fRaLKr71E~rz+ z|0|BV7WopdpS%YtyAGVZypm&u*>RnhtAR^9W-{9QBD;?!*q#+(3{QH7xemNsJe)y| zb^`YQK#N*Xh0LjPIAlZG)BXM<3i6T?oq7PT_r6D38M)fX9= z>qKQr?Dzc!f_|jmQGED<6Y9Y=u*j2zC{ zfuHwK8@V0~qM{s4Y=dpH=|NjfFtj@%ERt<*P?id`Jp#o&&!dm`{XEyWK4CkZF4yU{ zh|u_iz2g!J#FZH#X;`SB&|WADuz#y{30@1|OyY(6WAZzH^ezdteIXeYF=ZAVp~R<_ zYC4szOqNmY!tS)wu7TL-xU+e|sll>2MmUbnjdHCCuwWxpOl+c*LE|IpnonIddcpIqgX< z?=YdT02tKBDUetoVyxNd6-8Z_dF3ad0#nf?0&^o^b#@KDH2^LHhtAA=*X9IB2Y;0w zI1*z{S5p?mS2OSFvl3hQE}e&eN--39`LQb7?91-9L2P;c+_Xh)cX(KPZTv1_vB<=wu(bE zemIz$I@#qciO`EMuB(22^hA`En?_kb)=Z;xdhs|(QV^8$6}G&Zmm$Ln7;0fm|CHla z97ZupbX$?_P4#w474vh+d#a<-01IuxmjF!rWC1d)EKAM)RJ6BDsV9SR1~~toVZH)> z`E7nV7%!q@^odCu4@B_Ar%#-X}- zORG%GQNt6U9X-tRa^TF=AKIyN^ufz!0&Q6;+XtqbQLSrz!cOv}5QT%-3vZ!B?2h(; z>ZCKkj_eaBPJv-(dE4K@?jtoFrYf(Qm?GKipnr)z+h@=DA+JwJDPKr$y~;(}za8>+ z2XJD#8jIPOreV`R|7A8B8zK>^vfodsa4?9b2xjA@f6pkTWR}a4IUzn-l8~L@LSH~6 z<^0G5HlZ+z*6zviOq`Xv?ZXIXY@dhTv>y>ZU-1L<;Hx`Tk@mXC7JkizG2>3W>bA6k z*|CpDDt|)U21RpxFhz-do?KCn_1=~;KXsuGCs-%h&`l;5ZPL>9?CSEZX^~-RpX?FJ z$BTya*!(qa&SekgdER{~3p3I$*-X8L&!5$+GO#^Aky#bI)oYmisT%%K!d!Yf;RUZn zpI&Z2Qg=caGf5>7jB|=6w-zxF*13rRy?q+NoA*Yi9iz~Rn%g9< zK?CiWrsvQp#b9iiJdye-BrvdnrV3$Cq+L^n?}br?V~ETqzUvUfLsH7~c)8p+`LLu$ z{%Ru2-a)hQzzh?3)DVb57By*X{%b4vlGX&Tu>>|Q6@x{_fl>E(9ydhZ5YL{w`jK#O z-H?ZXUd$9dAm>zGcxV~#D$z1o%LwJ=mVg~VgqT{BJ~UM{D;8Ikf@F|R%;lU zyG3hPxEUE36a*4ESmpP;Z^-rxvxTtW8HC|!dU$p6Y_rhl#1Iz*zo=RK!kN>=SJ2li znRB>TKPM(lk}$)-E9aEo`89~nlbALK&~ADOEIJ*i<}YfMpOfALU86&kO~FU_cGHj! z9m-PF*Wgp1n3&Uvi!h5rrmNjjc^!ZVE1EI|?aH8#Ggd;%oq_X86JBrm!Z$}m5pw37 zS1M)NICJijr9hihcm_QS?2zp{wbDvrkPZz-*Vp$=9}3{KU#(90QyADSHel`hqNqTz zLgs0t%*+6-xHMAPLdt2KPkj&aNS*AZxj%UKFuCh1#1wQ}lrt_JUuF(d+-){bjwYs( zHZZIdD`NiCpX?{U2eIk&KML^D&$BRvffuahF2c5?=evFs<^ES@&dx`NZ&{kN6vgY%1xmBQRE`vC$A-tvv zowf#ZxK2)xbzFZ**{AAQX|N0)M=HLy8T6Yt}hWpctv{w>5}^ zBm7(&a8fWOXQpw`HtlmodE6c6vM>m93YuFd+~7 zMW!WxQ7{E1AMS5%0&;7W2XG>gYEQ>AJ$8-po8yHTzYGwYZu>n{=n>$?uA$m0YQ&cv4xa@q zj`mB2S|v#TYAN?$&}2Alq-uTD-$+T~HQMv14uN(>Twcj=lim$!R6pVTIAea}#7h#) zDsc95mFki=?;J|V=}|=%w!vy4bQ@YYbxPjd9QrNQ4A*xta~UmgXNDYyu>Sh@XpZ^$O>SBmnRC0zV-9yi{Fyl@cWAlvA?VVNBvdmP+h&fDK6>sVz82Xw{l(Ceg6l@fBoqHj& z*{7)hE}*&t_+vI(JJ0HEG2AlID${i5SXLx9&A4f;>CpWu2R9Gf6vXr_NOgpC4&NP* zluRIB0*Q%0nsOO{DNH~QS~5$%);x!c>J8fL_jrqPOtUeRIRquELl%bMAooN7^SGjT z*4)Z8l%8!b+N@kAFWp^AYL3=?!Lu^v+Z91)xRFDPw$Q{I-1wCesrd?=a%o<*s&{Kg6ZCyrs9?r_atA3D&xd_wXhS5*=zfa1WBNehGp$Q6@UfnLTTo zw6mUL4(8+1RLbgrZb7TeCv%|iAf^EDla+GU5I8DM^Yr*PQPWO5`2RWt2j)-=sk;Pw z>X0Z!IwB=`a=;*-DSck0#-j~7EWNs%UaS=nqI+c&X?Ond=mvlwy(bkuUW`vXVDMGS4$V6!$MQo!L`=XA6=fCNJ?bUVgjy1lt_;aI0p*wb~ zl!S`luU%TSr0)E;lX_J;;w}3`-cMhEDwB?QzK&IaGAn$)yxmL60!@bZf0dj(w=I{9 zcVTs=35N(x?5pbMt{mAdqdBV^BkFoyA|1GlUn;|;-S{+N|MBUeFIq4p`#Y+WovMCL z{+lZDDK2ju1!(;L1T8BGF?e8w*TJCKr7(-c<;Nm#fll|8S_lljayr{JjP6?AzRZ7; z%QaIE0OedB0r~I0&T)CA(g=8~dT_8RaT4ZCsS0wCuat03b+a=!DU9_^qDmZI{<8A& zm%%wzs2cYc_Op_0mVenz=S*Kw6u@-Yn-&eusS9PB9>1aFyeWk_V=CpX9HI?`msn@= zzJGXq8J6j_68w7{N*GSd1S^$_+(81E0Vwe|#2-gtk*K#S@ujBzD!Xp!yt^nsmAZon zz`Gd49}s_R=~%wKrZYDsEdJN%*g$0-E9*wUe>QFK LTz_{REA@W>QB2?J delta 31144 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

@9Vq(H+pZ>*E8Y(97i_ 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 diff --git a/cpp/tests/test_graph_labels.cpp b/cpp/tests/test_graph_labels.cpp new file mode 100644 index 00000000..b1850b35 --- /dev/null +++ b/cpp/tests/test_graph_labels.cpp @@ -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 + +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 nodes; + std::vector 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); +} diff --git a/dev/issues/README.md b/dev/issues/README.md index eff1fee5..e7d712cc 100644 --- a/dev/issues/README.md +++ b/dev/issues/README.md @@ -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 | diff --git a/dev/issues/0049j-graph-labels.md b/dev/issues/completed/0049j-graph-labels.md similarity index 100% rename from dev/issues/0049j-graph-labels.md rename to dev/issues/completed/0049j-graph-labels.md