Merge issue/0037-directional-orphan-placement

This commit is contained in:
2026-05-04 01:27:13 +02:00
2 changed files with 187 additions and 41 deletions
@@ -0,0 +1,95 @@
---
id: 0037
title: Placement direccional de orphans — abanico de 45 grados en una sola direccion
status: done
priority: high
created: 2026-05-04
completed: 2026-05-03
---
## Contexto
Hoy `place_orphans_near_neighbors` (en `main.cpp`) reparte los hijos del
mismo anchor en un anillo de 360 grados alrededor del padre. Resultado:
con muchos hijos, el viewport se llena en todas las direcciones,
pisando nodos preexistentes y dificultando la lectura del grafo.
Issue 0035 ya cluster los hijos del mismo anchor (no los desperdiga
por anillos crecientes), pero siguen ocupando 360 grados.
## Objetivo
Que los nuevos hijos creados por un enricher (orphans con un anchor
detectado por `layout_first_placed_neighbor`) se desplieguen en un
**abanico de 45 grados** (no 360) en UNA sola direccion saliendo del
anchor. Asi el grafo "crece" en una direccion clara por cada
ejecucion de enricher, sin pisar lo existente.
## Diseño
### 1. Direccion del abanico — outward por defecto
Para cada anchor con orphans:
- Calcular el centroide del resto de nodos placed del grafo (excluyendo
los orphans propios y el anchor).
- La direccion outward es el vector `anchor - centroide`, normalizado.
- Si solo hay 1 nodo en el grafo (el propio anchor) o el centroide
coincide con el anchor: usar direccion `(1, 0)` (derecha).
### 2. Reparto en abanico de 45 grados
`arc_span = π / 4` (45 grados, configurable como constante).
Para los N orphans del anchor:
- Si `N == 1`: lo plantamos exactamente en la direccion outward al
radio base (~80 px).
- Si `N <= 8` (capacidad del primer anillo en 45 grados con min_dist):
se reparten equiespaciados en el arco a un radio fijo.
- Si `N > 8`: se llenan anillos sucesivos del mismo arco con radios
crecientes (`80, 140, 200, 280, 400`) y misma capacidad por anillo.
Cada slot dentro del arco tiene angulo:
```
angle = out_angle - arc_span/2 + slot * (arc_span / (slot_count - 1))
```
(ajusta para cuando `slot_count == 1` para no dividir entre cero).
### 3. Colision con nodos preexistentes
Despues de calcular `(px, py)` para cada hijo, si colisiona con un
nodo no-orphan, incrementar el indice de anillo (mismo angulo,
radio mayor) hasta encontrar hueco o agotar anillos. En el ultimo
recurso aceptar solape — coherente con la logica actual.
### 4. Fallback sin anchor
Los orphans sin anchor (no tienen vecino placed) siguen el camino
existente: ring placement alrededor de la camara o parking lot.
NO se les aplica el abanico.
## Acceptance criteria
- Lanzar `split_words` (≥50 palabras unicas) sobre un nodo `text`:
los 10 sueltos del preview + 1 Group caen en un abanico de 45
grados saliendo del nodo `text`, en la direccion alejada del
resto del grafo.
- Lanzar el mismo enricher otra vez sobre OTRO nodo en otra
posicion: el abanico nuevo apunta hacia su propia direccion
outward.
- Si el grafo solo tiene 1 nodo (sin existing centroide), el
abanico sale a la derecha por default.
- Tests pytest siguen verdes (no requiere tests nuevos — el cambio
es algoritmico y se valida visualmente; opcional anyadir un test
C++ standalone que verifique angulos contra una fixture).
## TBD
Branch `issue/0037-directional-orphan-placement`, merge `--no-ff` a
master.
## Out of scope
- Animar la transicion del placement (poof-in suave). Fase 2.
- Layout interno del Group cuando se expande — sigue siendo todos
los hijos del Group ocultos en colapsado.
- Grouping logic (eso es 0035, ya cerrado fase 1).
+92 -41
View File
@@ -47,6 +47,7 @@
#include <filesystem>
#include <fstream>
#include <unordered_map>
#include <unordered_set>
#include <vector>
#ifndef _WIN32
@@ -343,12 +344,14 @@ static void place_orphans_near_neighbors(GraphData& g, float min_dist,
}
// ----- Pase 2: place clusters (orphans con anchor) -----
// Para cada anchor con sus hijos, los repartimos en un anillo
// alrededor del padre. Si hay mas hijos de los que caben en el
// anillo base, abrimos anillos adicionales. Cada hijo sigue
// pasando find_collision_free_slot como fallback si el slot ideal
// estaba ocupado por otro nodo del grafo.
const float two_pi = 6.28318530718f;
// Issue 0037: los hijos del mismo anchor se reparten en un ABANICO
// de 45 grados saliendo del anchor en la direccion outward (alejada
// del centroide del resto del grafo). Antes era 360 grados — con
// muchos hijos llenaba TODAS las direcciones y pisaba nodos
// preexistentes. Ahora el grafo crece direccionalmente por cada
// ejecucion de enricher.
const float pi = 3.14159265359f;
const float arc_span = pi / 4.0f; // 45 grados — issue 0037
for (auto& kv : orphans_by_anchor) {
int parent = kv.first;
std::vector<int>& kids = kv.second;
@@ -361,44 +364,92 @@ static void place_orphans_near_neighbors(GraphData& g, float min_dist,
});
float cx = g.nodes[parent].x;
float cy = g.nodes[parent].y;
// Capacidad por anillo: circunferencia / min_dist.
// Para min_dist=60, ring r=80 -> ~8 slots; r=140 -> ~14.
for (size_t k = 0; k < kids.size(); ++k) {
// Anillo y slot dentro del anillo en funcion del indice.
int ri = 0; size_t accum = 0; size_t cap = 0;
for (; ri < n_neighbor_radii; ++ri) {
float r_here = neighbor_radii[ri];
cap = (size_t)std::max(6.0f, two_pi * r_here / min_dist);
if (k < accum + cap) break;
accum += cap;
// --- Outward direction: anchor - centroide(rest of graph) ---
// Excluir el propio anchor y los kids (que aun estan en (0,0)).
// Si solo hay 1 nodo placed o el centroide coincide con el
// anchor, default a la derecha (out_angle = 0).
float out_angle = 0.0f;
{
// Marcar kids para excluirlos rapido.
std::unordered_set<int> kid_set(kids.begin(), kids.end());
double sum_x = 0.0, sum_y = 0.0;
int n_other = 0;
for (int j = 0; j < g.node_count; ++j) {
if (j == parent) continue;
if (kid_set.count(j)) continue;
const GraphNode& nj = g.nodes[j];
if (nj.x == 0.0f && nj.y == 0.0f) continue;
sum_x += nj.x; sum_y += nj.y; ++n_other;
}
if (ri >= n_neighbor_radii) ri = n_neighbor_radii - 1;
float r_use = neighbor_radii[ri];
cap = (size_t)std::max(6.0f, two_pi * r_use / min_dist);
size_t slot = k - accum;
// Jitter pequeno por user_data para que rondas distintas no
// queden alineadas si comparten anchor.
uint64_t seed = g.nodes[kids[k]].user_data;
float jitter = ((float)((seed >> 16) & 0xFF) / 255.0f) * (two_pi / cap);
float angle = jitter + (float)slot * (two_pi / cap);
float px = cx + r_use * std::cos(angle);
float py = cy + r_use * std::sin(angle);
// Si el slot ideal colisiona con un nodo ajeno al cluster,
// delegamos en find_collision_free_slot que probara mas
// angulos en radios crecientes.
GraphNode& kid = g.nodes[kids[k]];
if (layout_no_collision(g, kids[k], px, py, min_dist)) {
kid.x = px; kid.y = py;
} else {
float ox, oy;
if (find_collision_free_slot(
g, kids[k], cx, cy, min_dist, seed,
neighbor_radii, n_neighbor_radii, &ox, &oy)) {
kid.x = ox; kid.y = oy;
} else {
kid.x = px; kid.y = py; // ultimo recurso: solape
if (n_other > 0) {
float cent_x = (float)(sum_x / n_other);
float cent_y = (float)(sum_y / n_other);
float dx = cx - cent_x;
float dy = cy - cent_y;
if (dx != 0.0f || dy != 0.0f) {
out_angle = std::atan2(dy, dx);
}
}
}
// --- Distribucion en abanico: capacidad por anillo restringida ---
// cap_r = max(2, arc_span * r / min_dist). Para min_dist=60,
// r=80 -> cap=2; r=140 -> 2; r=200 -> 3; etc. Ajustamos a un
// minimo de 2 para que kids unitarios o duos no degeneren.
size_t n = kids.size();
size_t accum = 0;
int ri = 0;
size_t cap_r = (size_t)std::max(2.0f, arc_span * neighbor_radii[ri] / min_dist);
for (size_t k = 0; k < n; ++k) {
// Avanzar de anillo cuando el actual se ha llenado.
while (k >= accum + cap_r && ri < n_neighbor_radii - 1) {
accum += cap_r;
++ri;
cap_r = (size_t)std::max(2.0f, arc_span * neighbor_radii[ri] / min_dist);
}
if (k >= accum + cap_r) {
// Ultimo anillo desbordado: aceptamos solape angular.
cap_r = std::max((size_t)2, n - accum);
}
size_t slot = k - accum;
size_t slot_count = std::min(cap_r, n - accum);
float angle;
if (slot_count <= 1) {
angle = out_angle;
} else {
angle = out_angle - arc_span * 0.5f
+ (float)slot * (arc_span / (float)(slot_count - 1));
}
// Jitter pequeno por user_data — pequeno para no romper el arco.
uint64_t seed = g.nodes[kids[k]].user_data;
float jit_max = (arc_span / (float)cap_r) * 0.3f;
float jitter = (((float)((seed >> 16) & 0xFF) / 255.0f) - 0.5f) * jit_max;
angle += jitter;
float r_use = neighbor_radii[ri];
float px = cx + r_use * std::cos(angle);
float py = cy + r_use * std::sin(angle);
// Si colisiona con un nodo no-orphan (ya placed), subimos
// de radio en MISMO angulo hasta encontrar hueco. Si se
// agotan los radios, aceptamos solape en el actual.
GraphNode& kid = g.nodes[kids[k]];
if (!layout_no_collision(g, kids[k], px, py, min_dist)) {
bool found = false;
for (int ri2 = ri + 1; ri2 < n_neighbor_radii; ++ri2) {
float r2 = neighbor_radii[ri2];
float px2 = cx + r2 * std::cos(angle);
float py2 = cy + r2 * std::sin(angle);
if (layout_no_collision(g, kids[k], px2, py2, min_dist)) {
px = px2; py = py2;
found = true;
break;
}
}
(void)found; // ultimo recurso: solape coherente con la logica previa
}
kid.x = px; kid.y = py;
kid.vx = kid.vy = 0.0f;
++placed_neighbor;
}