diff --git a/issues/0037-directional-orphan-placement.md b/issues/0037-directional-orphan-placement.md new file mode 100644 index 0000000..3af20e9 --- /dev/null +++ b/issues/0037-directional-orphan-placement.md @@ -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). diff --git a/main.cpp b/main.cpp index 1cf092b..914c34c 100644 --- a/main.cpp +++ b/main.cpp @@ -47,6 +47,7 @@ #include #include #include +#include #include #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& 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 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; }