7644a50d00
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>
282 lines
9.9 KiB
C++
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
|