diff --git a/data.cpp b/data.cpp index 93d8dee..efc6c4d 100644 --- a/data.cpp +++ b/data.cpp @@ -1,10 +1,29 @@ #include "data.h" +#include "../../../../cpp/vendor/sqlite3/sqlite3.h" + #include +#include #include +#include +#include +#include +#include +#include namespace ge { +// FNV1a-64 — debe coincidir con graph_sources.cpp y entity_ops.cpp para que +// los `user_data` calculados en este archivo casen con los del loader. +static uint64_t gf_fnv1a64(const char* s) { + uint64_t h = 1469598103934665603ULL; + for (; s && *s; ++s) { + h ^= (uint8_t)*s; + h *= 1099511628211ULL; + } + return h; +} + bool load_graph(const InputArgs& args, GraphData* out, graph::GraphLoadStats* stats) { if (!out || !stats) return false; *stats = graph::GraphLoadStats{}; @@ -27,9 +46,248 @@ bool load_graph(const InputArgs& args, GraphData* out, graph::GraphLoadStats* st } } -bool reload_graph(const InputArgs& args, GraphData* out, graph::GraphLoadStats* stats) { +bool reload_graph(const InputArgs& args, GraphData* out, graph::GraphLoadStats* stats, + const std::unordered_map* group_expanded) { if (out) graph::graph_free(out); - return load_graph(args, out, stats); + bool ok = load_graph(args, out, stats); + if (!ok) return false; + if (group_expanded && args.uri && *args.uri) { + // Best-effort: si falla la consulta de group_id, dejamos el grafo + // sin filtrar — el caller ya tiene un grafo valido. + apply_group_filter(out, args.uri, *group_expanded); + } + return true; +} + +// ---------------------------------------------------------------------------- +// apply_group_filter (issue 0035b) +// ---------------------------------------------------------------------------- + +namespace { + +// Detecta si la columna `group_id` existe en `entities`. Sin la columna, el +// filtro no tiene nada que hacer y devuelve sin tocar el grafo. La migracion +// (issue 0035a) la anade en BDs nuevas y existentes; pero si por algun motivo +// abrimos una BD vieja antes de que migrate corra, no debemos petar. +bool has_group_id_column(sqlite3* db) { + sqlite3_stmt* st = nullptr; + if (sqlite3_prepare_v2(db, "PRAGMA table_info(entities)", -1, &st, nullptr) != SQLITE_OK) + return false; + bool found = false; + while (sqlite3_step(st) == SQLITE_ROW) { + const unsigned char* name = sqlite3_column_text(st, 1); + if (name && std::strcmp((const char*)name, "group_id") == 0) { + found = true; + break; + } + } + sqlite3_finalize(st); + return found; +} + +// Detecta el nombre de la columna de tipo en entities (puede ser type_ref o +// type segun la version del schema). +std::string entity_type_column(sqlite3* db) { + sqlite3_stmt* st = nullptr; + if (sqlite3_prepare_v2(db, "PRAGMA table_info(entities)", -1, &st, nullptr) != SQLITE_OK) + return "type_ref"; + std::string col = "type_ref"; + bool seen_type_ref = false; + bool seen_type = false; + while (sqlite3_step(st) == SQLITE_ROW) { + const unsigned char* name = sqlite3_column_text(st, 1); + if (!name) continue; + if (std::strcmp((const char*)name, "type_ref") == 0) seen_type_ref = true; + if (std::strcmp((const char*)name, "type") == 0) seen_type = true; + } + sqlite3_finalize(st); + if (seen_type_ref) col = "type_ref"; + else if (seen_type) col = "type"; + return col; +} + +} // anon + +bool apply_group_filter(GraphData* g, const char* db_path, + const std::unordered_map& group_expanded) { + if (!g || !db_path || !*db_path) return false; + if (g->node_count <= 0) return true; + + sqlite3* db = nullptr; + if (sqlite3_open(db_path, &db) != SQLITE_OK) { + if (db) sqlite3_close(db); + return false; + } + if (!has_group_id_column(db)) { + sqlite3_close(db); + return true; // schema antiguo: nada que filtrar + } + + std::string type_col = entity_type_column(db); + + // Lee (id, group_id, type) para todas las entidades. Construimos: + // - hash_to_entity_id: user_data (FNV1a64 del id) → string id + // - entity_to_group_id: id string → group_id string ("" si NULL) + // - is_group_type: id string → true si type == "Group" + std::unordered_map hash_to_entity_id; + std::unordered_map entity_to_group_id; + std::unordered_set group_entities; + + { + std::string q = "SELECT id, group_id, " + type_col + " FROM entities"; + sqlite3_stmt* st = nullptr; + if (sqlite3_prepare_v2(db, q.c_str(), -1, &st, nullptr) != SQLITE_OK) { + sqlite3_close(db); + return false; + } + while (sqlite3_step(st) == SQLITE_ROW) { + const unsigned char* id_c = sqlite3_column_text(st, 0); + const unsigned char* gid_c = sqlite3_column_text(st, 1); + const unsigned char* tp_c = sqlite3_column_text(st, 2); + if (!id_c) continue; + std::string id_s = (const char*)id_c; + std::string gid_s = gid_c ? (const char*)gid_c : ""; + std::string type_s = tp_c ? (const char*)tp_c : ""; + hash_to_entity_id[gf_fnv1a64(id_s.c_str())] = id_s; + if (!gid_s.empty()) entity_to_group_id[id_s] = gid_s; + if (type_s == "Group") group_entities.insert(id_s); + } + sqlite3_finalize(st); + } + sqlite3_close(db); + + // Helper: devuelve true si el entity_id `eid` es un grupo expandido. + auto is_expanded = [&](const std::string& eid) -> bool { + auto it = group_expanded.find(eid); + return it != group_expanded.end() && it->second; + }; + + // Para cada nodo del grafo, decidir: + // - hidden: se elimina del array final + // - effective_node_idx: indice del nodo en el array nuevo, o -1 si oculto + // - redirect_to_group_idx: si oculto y su grupo existe en el grafo y NO + // esta expandido, las aristas se redirigen al indice del grupo en el + // array nuevo. Si el grupo no existe en el grafo (orfano), se deja -1 + // y las aristas se descartan. + // Construimos primero un map hash→old_idx y entity_id→old_idx para + // resolver rapido los grupos. + std::unordered_map entity_id_to_old_idx; + entity_id_to_old_idx.reserve((size_t)g->node_count); + for (int i = 0; i < g->node_count; ++i) { + auto it = hash_to_entity_id.find(g->nodes[i].user_data); + if (it != hash_to_entity_id.end()) { + entity_id_to_old_idx[it->second] = i; + } + } + + // Decidir visibility por nodo. + std::vector hidden((size_t)g->node_count, 0); + std::vector redirect_old(g->node_count, -1); // old_idx → old_idx del grupo (si oculto) + + for (int i = 0; i < g->node_count; ++i) { + auto hit = hash_to_entity_id.find(g->nodes[i].user_data); + if (hit == hash_to_entity_id.end()) continue; + const std::string& eid = hit->second; + auto git = entity_to_group_id.find(eid); + if (git == entity_to_group_id.end()) continue; + const std::string& parent = git->second; + if (is_expanded(parent)) continue; // grupo expandido → visible + // Nodo oculto. Buscar el indice del grupo padre en el grafo. + auto pit = entity_id_to_old_idx.find(parent); + if (pit != entity_id_to_old_idx.end()) { + redirect_old[i] = pit->second; + hidden[i] = 1; + } else { + // Grupo padre no presente en el grafo cargado — ocultamos sin + // redirigir. Las aristas se descartan. + hidden[i] = 1; + } + } + + // Compactar nodos: nuevo array sin los ocultos. Mantenemos un map old→new. + std::vector old_to_new((size_t)g->node_count, -1); + int new_count = 0; + for (int i = 0; i < g->node_count; ++i) { + if (hidden[i]) continue; + old_to_new[i] = new_count; + if (new_count != i) g->nodes[new_count] = g->nodes[i]; + ++new_count; + } + int original_node_count = g->node_count; + g->node_count = new_count; + + // Helper: dado old_idx, devuelve el new_idx efectivo (redirige al grupo + // si oculto). Devuelve -1 si imposible (oculto sin grupo en el grafo). + auto effective_new_idx = [&](int old_idx) -> int { + if (old_idx < 0 || old_idx >= original_node_count) return -1; + if (!hidden[old_idx]) return old_to_new[old_idx]; + int g_old = redirect_old[old_idx]; + if (g_old < 0) return -1; + return old_to_new[g_old]; // el grupo es siempre visible + }; + + // Filtrar/redirigir aristas. + // Para deduplicacion grupo-a-grupo: clave = (min(grp_old_a, grp_old_b), + // max(...), rel_type_id). Asi mantenemos UNA arista por par + tipo. + std::unordered_set seen_grp_pairs; + auto pair_key = [](int a, int b, uint16_t rt) -> uint64_t { + if (a > b) std::swap(a, b); + // a, b en uint32 + rt en 16 bits. Combinamos. + uint64_t k = (uint64_t)(uint32_t)a; + k = (k << 24) ^ (uint64_t)(uint32_t)b; + k = (k << 16) ^ (uint64_t)rt; + return k; + }; + + int new_edge_count = 0; + for (int e = 0; e < g->edge_count; ++e) { + GraphEdge& edge = g->edges[e]; + int s_old = (int)edge.source; + int t_old = (int)edge.target; + if (s_old < 0 || s_old >= original_node_count || + t_old < 0 || t_old >= original_node_count) continue; + + bool s_hidden = hidden[s_old] != 0; + bool t_hidden = hidden[t_old] != 0; + + int s_new = effective_new_idx(s_old); + int t_new = effective_new_idx(t_old); + if (s_new < 0 || t_new < 0) continue; // descarte por orfandad + + // Caso 1: ambos extremos eran hijos de grupo(s) colapsado(s). + if (s_hidden && t_hidden) { + // Si caen en el MISMO grupo → arista interna, se descarta + // (issue 0035 decision 5). + int s_grp_old = redirect_old[s_old]; + int t_grp_old = redirect_old[t_old]; + if (s_grp_old < 0 || t_grp_old < 0) continue; + if (s_grp_old == t_grp_old) continue; + // Distintos grupos → dedup por par + rel_type. + uint64_t k = pair_key(s_grp_old, t_grp_old, edge.type_id); + if (seen_grp_pairs.count(k)) continue; + seen_grp_pairs.insert(k); + edge.source = (uint32_t)s_new; + edge.target = (uint32_t)t_new; + } + else if (s_hidden || t_hidden) { + // Cross-edge: un extremo dentro de grupo colapsado, otro fuera. + // Redirigimos el extremo oculto al grupo. Sin dedup (issue 0035 + // decision 5: una linea por arista cuando es single-cross). + edge.source = (uint32_t)s_new; + edge.target = (uint32_t)t_new; + } + else { + // Ambos visibles — arista normal. + edge.source = (uint32_t)s_new; + edge.target = (uint32_t)t_new; + } + + if (new_edge_count != e) g->edges[new_edge_count] = edge; + ++new_edge_count; + } + g->edge_count = new_edge_count; + g->update_bounds(); + return true; } } // namespace ge diff --git a/data.h b/data.h index 9aa3e14..6d6e096 100644 --- a/data.h +++ b/data.h @@ -1,5 +1,8 @@ #pragma once +#include +#include + #include "viz/graph_sources.h" #include "viz/graph_types.h" @@ -22,6 +25,27 @@ bool load_graph(const InputArgs& args, GraphData* out, graph::GraphLoadStats* st // Reload helper — usa la misma uri que la ultima `load_graph` exitosa. // Llama a `graph_free(out)` y vuelve a invocar `load_graph(args, out, stats)`. -bool reload_graph(const InputArgs& args, GraphData* out, graph::GraphLoadStats* stats); +// Si `group_expanded` no es null, aplica el filtro de grupos (issue 0035b) +// tras la carga: oculta hijos cuyo group_id apunta a un grupo no expandido, +// reescribe extremos de aristas que quedan dentro de grupos colapsados al +// cuadrado del grupo, y deduplica aristas grupo-a-grupo a una linea por par. +bool reload_graph(const InputArgs& args, GraphData* out, graph::GraphLoadStats* stats, + const std::unordered_map* group_expanded = nullptr); + +// Aplica el filtro de grupos in-place sobre `g` consultando `db_path` para +// recuperar `group_id` por entidad. `group_expanded` mapea entity_id (string) +// del nodo Group → bool (true = expandido). Reglas: +// - Nodo con group_id != NULL y grupo padre no expandido → oculto (NF_VISIBLE +// limpiado y removido del array, indices de aristas re-mapeados). +// - Arista cuyo extremo cae en grupo colapsado → extremo redirigido al +// nodo del grupo. Si el otro extremo tambien cae en el MISMO grupo +// colapsado → arista interna, se descarta. +// - Aristas con AMBOS extremos en grupos colapsados distintos → dedup por +// par (group_a, group_b) sin orden, una sola linea por par + relacion. +// Idempotente sobre un grafo ya filtrado (no quedan group_id ocultos). +// Retorna true si tuvo exito; en caso de error de DB devuelve false sin +// alterar el grafo. +bool apply_group_filter(GraphData* g, const char* db_path, + const std::unordered_map& group_expanded); } // namespace ge diff --git a/issues/0035b-renderer-hides-grouped-children.md b/issues/0035b-renderer-hides-grouped-children.md index 02cc6c0..c5ccf45 100644 --- a/issues/0035b-renderer-hides-grouped-children.md +++ b/issues/0035b-renderer-hides-grouped-children.md @@ -1,7 +1,7 @@ --- id: 0035b title: Renderer oculta hijos de grupos colapsados + dedup de aristas grupo-a-grupo -status: pending +status: done priority: high created: 2026-05-03 parent: 0035 diff --git a/main.cpp b/main.cpp index 43cd088..2371e59 100644 --- a/main.cpp +++ b/main.cpp @@ -602,9 +602,12 @@ static bool load_input(bool first_load) { std::fprintf(stderr, "[graph_explorer] load failed: %s\n", stats.error_msg); return false; } + // Filtro de grupos colapsados (issue 0035b). Se aplica tras la carga + // bruta — el loader sigue siendo agnostico al concepto de grupo. + ge::apply_group_filter(&g_graph, g_input.uri, g_app.group_expanded); std::fprintf(stdout, "[graph_explorer] loaded %d nodes, %d edges, %d types, %d rel_types from %s\n", - stats.nodes_loaded, stats.edges_loaded, + g_graph.node_count, g_graph.edge_count, stats.types_discovered, stats.rel_types_discovered, g_input.uri); // types.yaml @@ -1360,7 +1363,7 @@ static void render() { } graph::GraphLoadStats stats{}; - if (ge::reload_graph(g_input, &g_graph, &stats)) { + if (ge::reload_graph(g_input, &g_graph, &stats, &g_app.group_expanded)) { ge::views_reset_visibility(g_app); ge::views_apply_visibility(g_app); @@ -1519,7 +1522,7 @@ static void render() { ge::layout_store_save(g_graph_hash, g_graph); } graph::GraphLoadStats stats{}; - if (!ge::reload_graph(g_input, &g_graph, &stats)) return; + if (!ge::reload_graph(g_input, &g_graph, &stats, &g_app.group_expanded)) return; ge::entity_index_build(g_input.uri, &g_idx); ge::views_reset_visibility(g_app); ge::views_apply_visibility(g_app); @@ -1800,7 +1803,7 @@ static void render() { // Reload del grafo para que cambios de name/type/etc. se reflejen // en el viewport (label, color del tipo, etc.). graph::GraphLoadStats stats{}; - if (ge::reload_graph(g_input, &g_graph, &stats)) { + if (ge::reload_graph(g_input, &g_graph, &stats, &g_app.group_expanded)) { ge::entity_index_build(g_input.uri, &g_idx); ge::views_reset_visibility(g_app); ge::views_apply_visibility(g_app); diff --git a/views.h b/views.h index bf33313..a664f68 100644 --- a/views.h +++ b/views.h @@ -84,6 +84,15 @@ struct AppState { // main.cpp lo escribe tras cargar y los handlers lo leen. std::string input_db_path; + // ---- Grouping (issue 0035b) --------------------------------------------- + // Estado de expansion de nodos `Group` en RAM. Default vacio = todos los + // grupos colapsados. No persiste entre sesiones (fase 1). El filtro del + // loader (apply_group_filter) consulta este map: si una entidad tiene + // `group_id != NULL` y el grupo padre no esta en este map con valor true, + // la entidad se oculta del grafo. Sin UI todavia para togglear; se setean + // valores manualmente desde tests/debug. + std::unordered_map group_expanded; + // Add-node toolbar input. char add_buf[256] = {};