Files
fn_registry/cpp/functions/viz/graph_layouts.cpp
T
egutierrez 4a0750445c feat(viz): graph_layouts (radial/hierarchical/fixed) + viewport multi-select+lasso (issue 0049i)
Phase 1 — graph_layouts:
- New module cpp/functions/viz/graph_layouts.{h,cpp,md} v1.0.0
- layout_grid, layout_circular, layout_random (migrated from graph_force_layout.cpp)
- layout_radial: BFS rings from root, hop k -> circle of radius k*ring_spacing
- layout_hierarchical: Sugiyama-style heuristic (longest-path levels + barycenter ordering)
- layout_fixed: no-op
- All respect NF_PINNED. graph_layout_circular/grid kept as deprecated wrappers.

Phase 2-3 — graph_viewport v1.2.0:
- Multi-selection via state.selection (vector<int>); NF_SELECTED kept in sync
- Lasso: Shift+Drag on empty area; AABB hit-test on release
- Drag of N-selection: all selected pinned + moved by mouse delta
- Ctrl+click toggle, Esc clears selection
- Right-click on node -> on_context_menu callback
- Double-click on node -> on_double_click callback
- Helpers exposed: graph_viewport_clear/add_to/toggle/is_selected (own TU for tests)

Phase 4 — tests:
- test_graph_layouts: 12 cases / 364 assertions covering geometry, pin, edges
- test_graph_viewport: 5 cases for selection helpers (pure logic, no GL)

Phase 5 — demo (primitives_gallery):
- Layout combo (force/grid/circular/radial/hierarchical/fixed) + Apply button
- Right-click popup with Pin/Unpin/Add-to-selection
- Status overlay shows [N selected] when selection non-empty
- Updated golden images

Issue moved to dev/issues/completed/.

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

282 lines
9.9 KiB
C++

#include "viz/graph_layouts.h"
#include "viz/graph_types.h"
#include <algorithm>
#include <cmath>
#include <cstdlib>
#include <queue>
#include <vector>
namespace graph {
// ---------------------------------------------------------------------------
// layout_grid
// ---------------------------------------------------------------------------
void layout_grid(GraphData& g, float spacing) {
if (g.node_count <= 0) return;
int cols = (int)std::ceil(std::sqrt((float)g.node_count));
if (cols < 1) cols = 1;
int rows = (g.node_count + cols - 1) / cols;
float ox = -0.5f * (cols - 1) * spacing;
float oy = -0.5f * (rows - 1) * spacing;
for (int i = 0; i < g.node_count; ++i) {
GraphNode& n = g.nodes[i];
if (n.flags & NF_PINNED) continue;
int col = i % cols;
int row = i / cols;
n.x = ox + col * spacing;
n.y = oy + row * spacing;
n.vx = 0.0f;
n.vy = 0.0f;
}
g.update_bounds();
}
// ---------------------------------------------------------------------------
// layout_circular
// ---------------------------------------------------------------------------
void layout_circular(GraphData& g, float radius) {
if (g.node_count <= 0) return;
const float two_pi = 6.28318530718f;
for (int i = 0; i < g.node_count; ++i) {
GraphNode& n = g.nodes[i];
if (n.flags & NF_PINNED) continue;
float angle = two_pi * (float)i / (float)g.node_count;
n.x = radius * std::cos(angle);
n.y = radius * std::sin(angle);
n.vx = 0.0f;
n.vy = 0.0f;
}
g.update_bounds();
}
// ---------------------------------------------------------------------------
// layout_random
// ---------------------------------------------------------------------------
void layout_random(GraphData& g, float spread) {
if (g.node_count <= 0) return;
for (int i = 0; i < g.node_count; ++i) {
GraphNode& n = g.nodes[i];
if (n.flags & NF_PINNED) continue;
n.x = spread * (2.0f * (float)rand() / (float)RAND_MAX - 1.0f);
n.y = spread * (2.0f * (float)rand() / (float)RAND_MAX - 1.0f);
n.vx = 0.0f;
n.vy = 0.0f;
}
g.update_bounds();
}
// ---------------------------------------------------------------------------
// layout_radial — BFS desde root, anillos concentricos por hop
// ---------------------------------------------------------------------------
void layout_radial(GraphData& g, int root_node, float ring_spacing) {
if (g.node_count <= 0) return;
if (root_node < 0 || root_node >= g.node_count) root_node = 0;
// Adyacencia no dirigida via CSR temporal — para grafos pequenios/medios
// basta con vector<vector<int>>.
std::vector<std::vector<int>> adj(g.node_count);
for (int e = 0; e < g.edge_count; ++e) {
int s = (int)g.edges[e].source;
int t = (int)g.edges[e].target;
if (s < 0 || s >= g.node_count) continue;
if (t < 0 || t >= g.node_count) continue;
adj[s].push_back(t);
adj[t].push_back(s);
}
// BFS para asignar hop (-1 = no visitado todavia)
std::vector<int> hop(g.node_count, -1);
hop[root_node] = 0;
std::queue<int> q;
q.push(root_node);
int max_hop = 0;
while (!q.empty()) {
int u = q.front(); q.pop();
for (int v : adj[u]) {
if (hop[v] == -1) {
hop[v] = hop[u] + 1;
if (hop[v] > max_hop) max_hop = hop[v];
q.push(v);
}
}
}
// Nodos no alcanzables van al hop max_hop + 1
int orphan_hop = max_hop + 1;
bool has_orphans = false;
for (int i = 0; i < g.node_count; ++i) {
if (hop[i] == -1) { hop[i] = orphan_hop; has_orphans = true; }
}
// Agrupar indices por hop
int total_hops = (has_orphans ? orphan_hop : max_hop) + 1;
std::vector<std::vector<int>> by_hop(total_hops);
for (int i = 0; i < g.node_count; ++i) by_hop[hop[i]].push_back(i);
const float two_pi = 6.28318530718f;
for (int k = 0; k < total_hops; ++k) {
const auto& ring = by_hop[k];
if (ring.empty()) continue;
if (k == 0) {
// root en el centro
GraphNode& n = g.nodes[ring[0]];
if (!(n.flags & NF_PINNED)) {
n.x = 0.0f; n.y = 0.0f; n.vx = 0.0f; n.vy = 0.0f;
}
continue;
}
float radius = (float)k * ring_spacing;
int count = (int)ring.size();
for (int j = 0; j < count; ++j) {
GraphNode& n = g.nodes[ring[j]];
if (n.flags & NF_PINNED) continue;
float angle = two_pi * (float)j / (float)count;
n.x = radius * std::cos(angle);
n.y = radius * std::sin(angle);
n.vx = 0.0f;
n.vy = 0.0f;
}
}
g.update_bounds();
}
// ---------------------------------------------------------------------------
// layout_hierarchical — niveles por longest-path BFS + baricentro greedy
// ---------------------------------------------------------------------------
void layout_hierarchical(GraphData& g, int direction,
float layer_spacing, float node_spacing) {
if (g.node_count <= 0) return;
const int N = g.node_count;
// 1) Calcular in-degree para encontrar las raices
std::vector<int> in_deg(N, 0);
std::vector<std::vector<int>> out_adj(N);
std::vector<std::vector<int>> in_adj(N);
for (int e = 0; e < g.edge_count; ++e) {
int s = (int)g.edges[e].source;
int t = (int)g.edges[e].target;
if (s < 0 || s >= N || t < 0 || t >= N) continue;
if (s == t) continue;
out_adj[s].push_back(t);
in_adj[t].push_back(s);
in_deg[t]++;
}
// 2) Asignar nivel: longest-path desde nodos sin in-edges. BFS multi-source
// con relax: level[v] = max(level[v], level[u]+1).
std::vector<int> level(N, 0);
std::vector<int> order; order.reserve(N);
std::queue<int> q;
for (int i = 0; i < N; ++i) {
if (in_deg[i] == 0) { q.push(i); }
}
// Si todo el grafo tiene ciclos (no hay raices), forzar nodo 0 como raiz
if (q.empty()) { q.push(0); level[0] = 0; }
// BFS con propagacion. Para evitar bucles infinitos en grafos ciclicos,
// limitamos la profundidad a N.
std::vector<int> visited(N, 0);
while (!q.empty()) {
int u = q.front(); q.pop();
if (visited[u]++ > N) continue;
for (int v : out_adj[u]) {
int new_lv = level[u] + 1;
if (new_lv > level[v]) {
level[v] = new_lv;
if (level[v] < N) q.push(v);
}
}
}
int max_level = 0;
for (int i = 0; i < N; ++i) {
if (level[i] > max_level) max_level = level[i];
}
// 3) Agrupar nodos por nivel
std::vector<std::vector<int>> by_level(max_level + 1);
for (int i = 0; i < N; ++i) by_level[level[i]].push_back(i);
// 4) Reducir cruces: por cada nivel L > 0 ordenar por baricentro de los
// indices (en el array del nivel L-1) de sus padres. Greedy, no optimo.
std::vector<int> pos_in_level(N, 0);
for (int j = 0; j < (int)by_level[0].size(); ++j) pos_in_level[by_level[0][j]] = j;
for (int L = 1; L <= max_level; ++L) {
auto& cur = by_level[L];
std::vector<float> bary(cur.size(), 0.0f);
for (size_t i = 0; i < cur.size(); ++i) {
int v = cur[i];
float sum = 0.0f; int cnt = 0;
for (int p : in_adj[v]) {
if (level[p] == L - 1) {
sum += (float)pos_in_level[p];
cnt++;
}
}
bary[i] = (cnt > 0) ? (sum / (float)cnt) : (float)i;
}
// Ordenar `cur` por bary
std::vector<size_t> idx(cur.size());
for (size_t i = 0; i < idx.size(); ++i) idx[i] = i;
std::sort(idx.begin(), idx.end(), [&](size_t a, size_t b){
if (bary[a] != bary[b]) return bary[a] < bary[b];
return cur[a] < cur[b];
});
std::vector<int> reordered(cur.size());
for (size_t i = 0; i < idx.size(); ++i) reordered[i] = cur[idx[i]];
cur = std::move(reordered);
for (int j = 0; j < (int)cur.size(); ++j) pos_in_level[cur[j]] = j;
}
// 5) Posiciones. layer_axis = nivel * layer_spacing; transverse_axis =
// (j - (size-1)/2) * node_spacing (centrado).
auto place = [&](int node_idx, float layer_axis, float transverse_axis) {
GraphNode& n = g.nodes[node_idx];
if (n.flags & NF_PINNED) return;
float lx = 0.0f, ly = 0.0f;
switch (direction) {
case 0: // LR
lx = layer_axis; ly = transverse_axis; break;
case 1: // RL
lx = -layer_axis; ly = transverse_axis; break;
case 2: // TB
lx = transverse_axis; ly = layer_axis; break;
case 3: // BT
lx = transverse_axis; ly = -layer_axis; break;
default:
lx = layer_axis; ly = transverse_axis; break;
}
n.x = lx;
n.y = ly;
n.vx = 0.0f;
n.vy = 0.0f;
};
// Centramos cada nivel en torno a 0 en el eje transversal y arrancamos en
// 0 en el eje de capas.
for (int L = 0; L <= max_level; ++L) {
const auto& cur = by_level[L];
int sz = (int)cur.size();
if (sz == 0) continue;
float layer_axis = (float)L * layer_spacing;
float t0 = -0.5f * (sz - 1) * node_spacing;
for (int j = 0; j < sz; ++j) {
place(cur[j], layer_axis, t0 + j * node_spacing);
}
}
g.update_bounds();
}
// ---------------------------------------------------------------------------
// layout_fixed — no-op. Existe para que el caller pueda usar "fixed" en un
// switch sin casos especiales.
// ---------------------------------------------------------------------------
void layout_fixed(GraphData& /*g*/) {
// intencionalmente vacio
}
} // namespace graph