Merge issue/0037-directional-orphan-placement
This commit is contained in:
@@ -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).
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user