From 012e2e97a63758c830fd4a157ff6e2e5efcdb1b1 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Fri, 1 May 2026 18:39:59 +0200 Subject: [PATCH] fix(layout): layout estable al recargar (issue 0031) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Antes: cada reload disparado por enrichers (dirty_counter) ejecutaba graph_viewport_fit (recentraba camara), recargaba desde SQL con todos los nodos en (0,0), aplicaba layout_circular si todo estaba en cero, y los huerfanos quedaban apilados sobre el origen. Si physics estaba ON, las fuerzas dispersaban todo el grafo violentamente. Ahora: - Auto-save de posiciones antes de cada reload — preserva lo que el usuario ve en pantalla sin pulsar "Save layout". - No graph_viewport_fit en reloads (solo en primera carga via load_input(first_load=true)). La camara permanece donde estaba. - No layout_circular en reloads (mismo guard via first_load). - Halo placement: nodos huerfanos (en (0,0) tras layout_store_load) se colocan junto a su primer vecino con coordenadas conocidas, buscando slot angular libre en radios crecientes (80,140,200,280,400) con jitter deterministico por user_data. Si no hay vecinos colocados, se aparcan en columna lateral fuera del bbox. - Anti-overlap garantizado a min_dist=60 px entre centros. - Physics siempre OFF tras reload — el usuario las activa explicitamente. - Auto-save tambien al inicio de reload_after_mutation (mutaciones manuales add/delete/duplicate/change_type) por consistencia. - Refresca entity_index tras reload (los nuevos nodos creados por enrichers tienen user_data nuevos que el indice anterior no conoce). Tests visuales: compila limpio, jobs_init continua detectando enrichers, smoke test del binario OK. Co-Authored-By: Claude Opus 4.7 (1M context) --- issues/0031-stable-layout-on-reload.md | 144 ++++++++++++++++++ main.cpp | 199 ++++++++++++++++++++++--- 2 files changed, 326 insertions(+), 17 deletions(-) create mode 100644 issues/0031-stable-layout-on-reload.md diff --git a/issues/0031-stable-layout-on-reload.md b/issues/0031-stable-layout-on-reload.md new file mode 100644 index 0000000..e5e0a8d --- /dev/null +++ b/issues/0031-stable-layout-on-reload.md @@ -0,0 +1,144 @@ +--- +id: 0031 +title: Layout estable al recargar — auto-save, halo placement, sin fit, physics off +status: in_progress +priority: high +created: 2026-05-01 +related_to: [0026] +--- + +## Problema + +Cuando un enricher (issue 0026) crea entidades nuevas, el `dirty_counter` +dispara `want_reload` y el grafo pierde la disposicion que tenia el +usuario: + +1. **`graph_viewport_fit()` se llama en cada reload** → recentra y + reescala la camara, sensacion de "todo se movio". +2. **`layout_store_save` solo se ejecuta al pulsar "Save layout"** → + si el usuario no lo pulsa, las posiciones en RAM se pierden y los + nodos viejos vuelven a (0,0) tras el reload. +3. **`layout_circular` se aplica si todos los nodos estan en (0,0)** + tras reload → si no hay nada guardado en `layout_store`, todo se + reorganiza en circulo. +4. **Nodos creados por enrichers llegan en (0,0)** → quedan apilados + sobre el origen tras `layout_store_load`. Con physics ON se reparten + violentamente al chocar entre si. + +## Decisiones (confirmadas por el usuario) + +- **A. Auto-save antes de cada reload**: preservar las posiciones que el + usuario tiene en RAM sin que tenga que pulsar "Save layout" jamas. +- **B. No `graph_viewport_fit` en reloads**: solo en la primera carga + de cada proyecto/archivo. La camara permanece donde la tenia el usuario. +- **C. Halo placement para nodos huerfanos**: nodos que tras + `layout_store_load` siguen en (0,0) se posicionan junto a su primer + vecino con coordenadas conocidas, **garantizando no solapamiento** con + nodos existentes ni entre ellos. +- **D. No `layout_circular` en reloads**: la condicion `zero_pos == node_count` + solo aplica en la primera carga. +- **E. Physics siempre pausadas**: `layout_running = false` al cargar y + al recargar. El usuario las activa explicitamente con el toggle + Physics si quiere ver fuerzas. + +## Implementacion + +### main.cpp:want_reload (sustituye el bloque actual) + +```cpp +if (g_app.want_reload) { + g_app.want_reload = false; + + // (A) auto-save: persistir posiciones actuales antes de liberar grafo. + if (g_loaded) ge::layout_store_save(g_graph_hash, g_graph); + + graph::GraphLoadStats stats{}; + if (ge::reload_graph(g_input, &g_graph, &stats)) { + ge::views_reset_visibility(g_app); + ge::views_apply_visibility(g_app); + g_graph.update_bounds(); + // (B) NO graph_viewport_fit aqui. + int restored = ge::layout_store_load(g_graph_hash, g_graph); + // (C) huerfanos -> halo placement junto a vecinos. + place_orphans_near_neighbors(g_graph, /*min_dist=*/60.0f); + if (restored > 0 || g_graph.node_count > 0) g_graph.update_bounds(); + g_atlas_bound = false; + g_gpu_dirty = true; + // (E) physics siempre pausadas tras reload. + g_viewport.layout_running = false; + } +} +``` + +### load_input — distinguir primera carga de reload + +Anadir flag `bool first_load`. La condicion `zero_pos == node_count` y +el `graph_viewport_fit()` solo se aplican si `first_load == true`. + +```cpp +static bool load_input(bool first_load = true); +``` + +Internamente: `reload_graph()` ya no llama a `load_input`, sino a una +version que pasa `first_load=false`. O `want_reload` hace el flujo +manualmente como arriba (sin reusar load_input). + +### Nuevo helper `place_orphans_near_neighbors` + +Vive en `main.cpp` (O nuevo `layout_helpers.{h,cpp}` si crece). + +```cpp +// Para cada nodo en (0,0) (huerfano tras reload): +// 1. Busca su primer vecino (via aristas) con posicion no-cero. +// 2. Coloca el huerfano en un anillo a r=80 px alrededor del padre, +// eligiendo el primer slot angular (de 12) que no colisione con +// ningun otro nodo a min_dist. Si todos ocupados, expande radio +// (140, 200, 280, 400). Jitter deterministico por user_data para +// que dos huerfanos del mismo padre no caigan en el mismo slot. +// 3. Si el huerfano no tiene vecinos colocados (ej. componente +// conexa nueva), lo aparca en una columna a la derecha del bbox +// del grafo, separados verticalmente min_dist. +// +// Complejidad O(N * orphans). Suficiente para grafos bajo 5k nodos. +static void place_orphans_near_neighbors(GraphData& g, float min_dist); +``` + +Algoritmo de un huerfano: + +```cpp +int parent = first_placed_neighbor(g, i); // O(edges) +if (parent < 0) { park_in_free_column(...); continue; } +const float radii[] = {80, 140, 200, 280, 400}; +const int slots = 12; // 30 grados +float jitter = ((g.nodes[i].user_data >> 16) & 0xFF) / 255.0f * (2*PI/slots); +for (float r : radii) { + for (int s = 0; s < slots; ++s) { + float a = jitter + s * (2*PI/slots); + float cx = g.nodes[parent].x + r * cosf(a); + float cy = g.nodes[parent].y + r * sinf(a); + if (no_collision(g, i, cx, cy, min_dist)) { + g.nodes[i].x = cx; g.nodes[i].y = cy; + goto placed; + } + } +} +// Fallback: ultima posicion del ultimo radio + slot 0 (acepta solape). +placed: +``` + +`no_collision` es O(n) — itera todos los nodos del grafo y rechaza si +algun otro esta a < min_dist. Marca el huerfano recien colocado para +que el siguiente huerfano sepa de el. + +## Definicion de hecho + +- Reload tras enricher NO mueve la camara. +- Reload tras enricher NO cambia las posiciones de los nodos que ya + tenian sitio. +- Las entidades nuevas creadas por el enricher aparecen visibles, cerca + de su nodo padre semantico, sin solaparse con nadie. +- Physics permanecen OFF tras el reload (el usuario las activa + manualmente si quiere). +- Si el usuario nunca pulsa "Save layout", el cierre normal de la app + no preserva estado, pero cualquier reload SI preserva (gracias al + auto-save antes de reload). diff --git a/main.cpp b/main.cpp index 14d759b..652a2be 100644 --- a/main.cpp +++ b/main.cpp @@ -105,6 +105,126 @@ static int layout_name_to_index(const std::string& s) { return -1; } +// ---------------------------------------------------------------------------- +// Halo placement de nodos huerfanos (issue 0031) +// ---------------------------------------------------------------------------- + +// True si la posicion candidata (cx, cy) no colisiona con ningun nodo del +// grafo distinto de `self_idx`, considerando min_dist como umbral entre +// centros. +static bool layout_no_collision(const GraphData& g, int self_idx, + float cx, float cy, float min_dist) +{ + const float md2 = min_dist * min_dist; + for (int i = 0; i < g.node_count; ++i) { + if (i == self_idx) continue; + const GraphNode& o = g.nodes[i]; + // Ignora nodos que tampoco tienen posicion asignada — no son + // un obstaculo todavia, los colocara este mismo pase. + if (o.x == 0.0f && o.y == 0.0f) continue; + float dx = o.x - cx, dy = o.y - cy; + if (dx * dx + dy * dy < md2) return false; + } + return true; +} + +// Devuelve el indice del primer vecino de `node_idx` que tenga posicion +// asignada (no (0,0)). -1 si ninguno la tiene. +static int layout_first_placed_neighbor(const GraphData& g, int node_idx) { + for (int e = 0; e < g.edge_count; ++e) { + int other = -1; + if ((int)g.edges[e].source == node_idx) other = (int)g.edges[e].target; + else if ((int)g.edges[e].target == node_idx) other = (int)g.edges[e].source; + if (other < 0 || other >= g.node_count) continue; + const GraphNode& n = g.nodes[other]; + if (n.x != 0.0f || n.y != 0.0f) return other; + } + return -1; +} + +// Coloca todos los nodos del grafo que esten en (0,0) cerca de su primer +// vecino con posicion conocida, eligiendo el primer slot angular libre +// dentro de un radio creciente. Sin vecinos colocados → aparca en columna +// a la derecha del bbox actual. +static void place_orphans_near_neighbors(GraphData& g, float min_dist) { + if (g.node_count == 0) return; + const float radii[] = {80.0f, 140.0f, 200.0f, 280.0f, 400.0f}; + const int n_radii = (int)(sizeof(radii) / sizeof(radii[0])); + const int slots = 12; + const float two_pi = 6.28318530718f; + const float slot_arc = two_pi / slots; + + // Recompute bbox solo de los nodos colocados (para park_in_column). + float bbox_max_x = 0.0f, bbox_min_y = 0.0f, bbox_max_y = 0.0f; + bool bbox_init = false; + for (int i = 0; i < g.node_count; ++i) { + const GraphNode& n = g.nodes[i]; + if (n.x == 0.0f && n.y == 0.0f) continue; + if (!bbox_init) { + bbox_max_x = n.x; bbox_min_y = n.y; bbox_max_y = n.y; + bbox_init = true; + } else { + if (n.x > bbox_max_x) bbox_max_x = n.x; + if (n.y < bbox_min_y) bbox_min_y = n.y; + if (n.y > bbox_max_y) bbox_max_y = n.y; + } + } + float park_x = bbox_init ? bbox_max_x + 120.0f : 0.0f; + float park_y = bbox_init ? bbox_min_y : 0.0f; + int park_n = 0; + + int placed = 0, parked = 0; + for (int i = 0; i < g.node_count; ++i) { + GraphNode& n = g.nodes[i]; + if (n.x != 0.0f || n.y != 0.0f) continue; + + int parent = layout_first_placed_neighbor(g, i); + if (parent < 0) { + // Sin vecino colocado: aparca en columna lateral, separados + // verticalmente por min_dist. Si tampoco hay bbox (grafo + // recien creado), columna en (0, 0) hacia abajo. + n.x = park_x; + n.y = park_y + park_n * min_dist; + n.vx = 0.0f; n.vy = 0.0f; + ++park_n; ++parked; + continue; + } + + // Jitter deterministico por user_data → dos huerfanos del mismo + // padre eligen ciclos diferentes y no se solapan. + float jitter = ((float)((n.user_data >> 16) & 0xFF) / 255.0f) * slot_arc; + + bool placed_ok = false; + for (int ri = 0; ri < n_radii && !placed_ok; ++ri) { + float r = radii[ri]; + for (int s = 0; s < slots && !placed_ok; ++s) { + float a = jitter + s * slot_arc; + float cx = g.nodes[parent].x + r * std::cos(a); + float cy = g.nodes[parent].y + r * std::sin(a); + if (layout_no_collision(g, i, cx, cy, min_dist)) { + n.x = cx; n.y = cy; + n.vx = 0.0f; n.vy = 0.0f; + placed_ok = true; + } + } + } + if (!placed_ok) { + // Fallback: pone en el ultimo radio + slot 0 (acepta solape). + float r = radii[n_radii - 1]; + n.x = g.nodes[parent].x + r * std::cos(jitter); + n.y = g.nodes[parent].y + r * std::sin(jitter); + n.vx = 0.0f; n.vy = 0.0f; + } + ++placed; + } + + if (placed > 0 || parked > 0) { + std::fprintf(stdout, + "[graph_explorer] placed %d orphans near neighbors, %d parked in column\n", + placed, parked); + } +} + static void apply_static_layout(int mode) { if (g_graph.node_count == 0) return; switch (mode) { @@ -123,7 +243,10 @@ static void apply_static_layout(int mode) { } // Forward decl — definido mas abajo, lo necesita switch_to_project. -static bool load_input(); +// `first_load=true` activa: layout_circular si todo (0,0), graph_viewport_fit. +// En reloads (first_load=false) ambos se omiten para preservar el estado del +// usuario (issue 0031). +static bool load_input(bool first_load = true); // ---------------------------------------------------------------------------- // Registry path resolution (issue 0026) @@ -204,7 +327,7 @@ static bool switch_to_project(const std::string& slug) { return ok; } -static bool load_input() { +static bool load_input(bool first_load) { g_input.kind = ge::INPUT_OPERATIONS; g_input.uri = g_input_path.c_str(); @@ -260,14 +383,18 @@ static bool load_input() { g_viewport.layout_running = false; g_viewport.layout_energy = 0.0f; - // Posicionar nodos: si todos tienen (x,y)=0, aplicar layout circular como - // arranque (grafos cargados desde operations.db vienen sin posiciones). - int zero_pos = 0; - for (int i = 0; i < g_graph.node_count; ++i) { - if (g_graph.nodes[i].x == 0.0f && g_graph.nodes[i].y == 0.0f) ++zero_pos; - } - if (zero_pos == g_graph.node_count) { - graph::layout_circular(g_graph, 200.0f); + // Posicionar nodos en primera carga: si todos tienen (x,y)=0, aplicar + // layout circular como arranque. En reloads NO — los huerfanos los + // resuelve `place_orphans_near_neighbors` despues de layout_store_load + // (issue 0031). + if (first_load) { + int zero_pos = 0; + for (int i = 0; i < g_graph.node_count; ++i) { + if (g_graph.nodes[i].x == 0.0f && g_graph.nodes[i].y == 0.0f) ++zero_pos; + } + if (zero_pos == g_graph.node_count) { + graph::layout_circular(g_graph, 200.0f); + } } g_graph.update_bounds(); @@ -283,11 +410,21 @@ static bool load_input() { int restored = ge::layout_store_load(g_graph_hash, g_graph); if (restored > 0) { std::fprintf(stdout, "[graph_explorer] restored %d node positions from layout store\n", restored); - g_graph.update_bounds(); } - // Vista inicial - graph_viewport_fit(g_graph, g_viewport); + // Huerfanos (nodos sin posicion guardada): halo placement junto a su + // primer vecino con coordenadas conocidas (issue 0031). En primera carga + // tambien aplica — si layout_circular ya los puso en circulo, no entran + // (ya no estan en (0,0)). En reloads es donde mas valor da: nodos + // creados por enrichers caen junto a su padre semantico. + place_orphans_near_neighbors(g_graph, /*min_dist=*/60.0f); + g_graph.update_bounds(); + + // Vista inicial — solo en primera carga; los reloads preservan camara + // del usuario (issue 0031). + if (first_load) { + graph_viewport_fit(g_graph, g_viewport); + } g_gpu_dirty = true; // App state — visibility por tipo @@ -672,16 +809,40 @@ static void render() { } if (g_app.want_reload) { g_app.want_reload = false; + // (A) Auto-save antes de liberar el grafo: preserva las posiciones + // que tenia el usuario en pantalla sin que tenga que pulsar + // "Save layout" jamas (issue 0031). + if (g_loaded && g_graph_hash != 0) { + ge::layout_store_save(g_graph_hash, g_graph); + } + graph::GraphLoadStats stats{}; if (ge::reload_graph(g_input, &g_graph, &stats)) { ge::views_reset_visibility(g_app); ge::views_apply_visibility(g_app); - g_graph.update_bounds(); - graph_viewport_fit(g_graph, g_viewport); + + // Restaura posiciones guardadas para nodos preexistentes. int restored = ge::layout_store_load(g_graph_hash, g_graph); - if (restored > 0) g_graph.update_bounds(); + (void)restored; + + // (C) Halo placement: huerfanos creados por enrichers se + // colocan junto a su primer vecino con posicion conocida, + // evitando solapamiento con nodos existentes (issue 0031). + place_orphans_near_neighbors(g_graph, /*min_dist=*/60.0f); + g_graph.update_bounds(); + + // (B) NO graph_viewport_fit en reloads: preserva camara del + // usuario (issue 0031). + + // (E) Physics siempre pausadas tras reload (issue 0031). + g_viewport.layout_running = false; + + // Refresca el indice user_data -> sql id (puede haber nuevos + // nodos cuyo user_data no estaba en el indice anterior). + ge::entity_index_build(g_input.uri, &g_idx); + g_atlas_bound = false; // re-bind atlas tras reload - g_gpu_dirty = true; + g_gpu_dirty = true; } } if (g_app.want_save_layout) { @@ -793,6 +954,10 @@ static void render() { // ---- Mutaciones (add/delete/duplicate/change_type) ---- auto reload_after_mutation = [&]() { + // Auto-save antes de liberar el grafo (issue 0031). + if (g_loaded && g_graph_hash != 0) { + ge::layout_store_save(g_graph_hash, g_graph); + } graph::GraphLoadStats stats{}; if (!ge::reload_graph(g_input, &g_graph, &stats)) return; ge::entity_index_build(g_input.uri, &g_idx);