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 f7ebf7a9..9ca4d76b 100644 Binary files a/cpp/tests/golden/graph_viewport.png and b/cpp/tests/golden/graph_viewport.png differ 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