fix: docking gaps + hover radius + node spread + markdown notes

Bug fixes
- ImGui ID conflict en menu Change type: dedup tipos del grafo +
  defaults; PushID/PopID por entrada.
- Dockspace ya no tapa la toolbar: se posiciona 44 px por debajo, asi
  las ventanas dockeadas al borde superior quedan bajo la barra de
  filtros, no detras.
- Hover radius proporcional al tamaño visual del nodo: query espacial
  amplio (24/zoom) + filtro fino por (radio_visual + 2 px) / zoom. El
  tooltip solo se dispara si el raton esta efectivamente sobre el nodo.

Layout
- Default layout = grid (en vez de force) para que los grafos cargados
  se distribuyan ordenadamente al abrir.
- Boton "Reset layout" en la toolbar: limpia NF_PINNED en todos los
  nodos, resetea velocidades y reaplica el layout activo.
- Nodos recien creados (add_node, duplicate) caen en un anillo poisson
  alrededor del centro de la vista, no en el origen. Posicion
  determinista por user_data para que el mismo nodo no salte entre
  reloads.

Notes (markdown)
- Panel "Note" (dockeable) abierto con doble click sobre un nodo.
- entity_get_notes / entity_set_notes en entity_ops sobre la columna
  `notes` de operations.db (ya existente en el schema).
- Ctrl+S guarda. Cabecera muestra entity, type, id.
This commit is contained in:
2026-04-30 23:11:48 +02:00
parent 02eef6e339
commit adde3026ea
5 changed files with 273 additions and 27 deletions
+126 -25
View File
@@ -7,6 +7,7 @@
#include "core/panel_menu.h"
#include "core/button.h"
#include "core/tokens.h"
#include "core/icons_tabler.h"
#include "viz/graph_types.h"
#include "viz/graph_viewport.h"
@@ -28,7 +29,9 @@
#include <cstdlib>
#include <cstring>
#include <chrono>
#include <cmath>
#include <string>
#include <vector>
// ----------------------------------------------------------------------------
// Estado global de la app
@@ -246,6 +249,13 @@ static void update_fps() {
// Context menu callback (right-click sobre nodo)
// ----------------------------------------------------------------------------
// Doble click sobre nodo: solicita abrir el panel Note. main.cpp procesa
// despues (necesita acceso al EntityIndex para resolver el sql id).
static void on_double_click_cb(int node_idx, void* /*user*/) {
g_app.want_open_note = true;
g_app.open_note_target = node_idx;
}
static void on_context_menu_cb(int node_idx, ImVec2 /*screen_pos*/, void* /*user*/) {
g_app.ctx_node = node_idx;
g_app.ctx_open_request = true;
@@ -287,23 +297,28 @@ static void render_context_menu() {
ImGui::Separator();
if (ImGui::BeginMenu("Change type")) {
// Tipos del grafo actual
// Construye un set ordenado y deduplicado: tipos del grafo + defaults.
// Asi evitamos colisiones de ID en ImGui ("person" en grafo y default).
std::vector<const char*> all;
all.reserve(g_graph.type_count + k_default_types_n);
for (int i = 0; i < g_graph.type_count; ++i) {
const char* name = g_graph.types[i].name;
if (!name) continue;
if (ImGui::MenuItem(name)) {
std::snprintf(g_app.ctx_new_type, sizeof(g_app.ctx_new_type), "%s", name);
g_app.want_change_type = true;
if (g_graph.types[i].name && *g_graph.types[i].name) {
all.push_back(g_graph.types[i].name);
}
}
ImGui::Separator();
// Defaults extra (por si no estan presentes en el grafo cargado)
for (int i = 0; i < k_default_types_n; ++i) {
if (ImGui::MenuItem(k_default_types[i])) {
std::snprintf(g_app.ctx_new_type, sizeof(g_app.ctx_new_type), "%s",
k_default_types[i]);
const char* d = k_default_types[i];
bool dup = false;
for (const char* x : all) { if (std::strcmp(x, d) == 0) { dup = true; break; } }
if (!dup) all.push_back(d);
}
for (size_t i = 0; i < all.size(); ++i) {
ImGui::PushID((int)i);
if (ImGui::MenuItem(all[i])) {
std::snprintf(g_app.ctx_new_type, sizeof(g_app.ctx_new_type), "%s", all[i]);
g_app.want_change_type = true;
}
ImGui::PopID();
}
ImGui::EndMenu();
}
@@ -343,6 +358,7 @@ static fn_ui::PanelToggle g_panels[] = {
{"Legend", nullptr, &g_app.panel_legend},
{"Inspector", nullptr, &g_app.panel_inspector},
{"Stats", nullptr, &g_app.panel_stats},
{"Note", nullptr, &g_app.panel_note},
};
static void render() {
@@ -372,13 +388,13 @@ static void render() {
return;
}
// Dockspace host: ocupa el area de trabajo bajo la menubar y permite
// que cualquier ventana (Viewport, Legend, Inspector, Stats, Table) se
// arrastre a un lateral o pestañas dentro de la app.
// Dockspace host: ocupa el area BAJO la toolbar (44 px) para que las
// ventanas dockeadas no queden detras de la barra superior.
ImGuiViewport* vp = ImGui::GetMainViewport();
const float k_toolbar_h = 44.0f;
{
ImGui::SetNextWindowPos (vp->WorkPos);
ImGui::SetNextWindowSize(vp->WorkSize);
ImGui::SetNextWindowPos (ImVec2(vp->WorkPos.x, vp->WorkPos.y + k_toolbar_h));
ImGui::SetNextWindowSize(ImVec2(vp->WorkSize.x, vp->WorkSize.y - k_toolbar_h));
ImGui::SetNextWindowViewport(vp->ID);
ImGuiWindowFlags hostFlags =
ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize |
@@ -450,16 +466,38 @@ static void render() {
// ---- Mutaciones (add/delete/duplicate/change_type) ----
auto reload_after_mutation = [&]() {
graph::GraphLoadStats stats{};
if (ge::reload_graph(g_input, &g_graph, &stats)) {
ge::entity_index_build(g_input.uri, &g_idx);
ge::views_reset_visibility(g_app);
ge::views_apply_visibility(g_app);
g_graph.update_bounds();
int restored = ge::layout_store_load(g_graph_hash, g_graph);
if (restored > 0) g_graph.update_bounds();
g_atlas_bound = false;
g_gpu_dirty = true;
if (!ge::reload_graph(g_input, &g_graph, &stats)) return;
ge::entity_index_build(g_input.uri, &g_idx);
ge::views_reset_visibility(g_app);
ge::views_apply_visibility(g_app);
// Restablece posiciones guardadas. Los nodos nuevos no tienen
// posicion en el layout_store y caen en (0,0).
int restored = ge::layout_store_load(g_graph_hash, g_graph);
(void)restored;
// Centro del area visible en world coords (para que los nuevos nodos
// aparezcan donde el usuario esta mirando, no en el origen).
float cx = -g_viewport.cam_x;
float cy = -g_viewport.cam_y;
float spread_r = 80.0f / (g_viewport.zoom > 0.01f ? g_viewport.zoom : 0.01f);
// Reparte los nodos sin posicion en un anillo poisson alrededor del
// centro visible. Determinista por user_data para que el mismo nodo
// caiga siempre en el mismo sitio entre reloads.
for (int i = 0; i < g_graph.node_count; ++i) {
GraphNode& n = g_graph.nodes[i];
if (n.x != 0.0f || n.y != 0.0f) continue;
uint64_t h = n.user_data ? n.user_data : (uint64_t)i * 2654435761ull;
float a = (float)((h >> 0) & 0xFFFF) / 65535.0f * 6.2831853f;
float r = spread_r * (0.4f + (float)((h >> 16) & 0xFFFF) / 65535.0f * 0.6f);
n.x = cx + std::cos(a) * r;
n.y = cy + std::sin(a) * r;
n.vx = n.vy = 0.0f;
}
g_graph.update_bounds();
g_atlas_bound = false;
g_gpu_dirty = true;
};
if (g_app.want_add_node && g_app.add_buf[0]) {
@@ -513,6 +551,61 @@ static void render() {
g_app.want_change_type = false;
}
// Reset layout: limpia NF_PINNED en todos los nodos. El layout activo se
// reaplica via apply_layout_tick (la toolbar ya lo incrementa).
if (g_app.want_unpin_all) {
for (int i = 0; i < g_graph.node_count; ++i) {
g_graph.nodes[i].flags &= ~NF_PINNED;
g_graph.nodes[i].vx = 0.0f;
g_graph.nodes[i].vy = 0.0f;
}
g_viewport.layout_running = true;
g_app.want_unpin_all = false;
}
// Note editor — abrir / guardar.
if (g_app.want_open_note && g_app.open_note_target >= 0
&& g_app.open_note_target < g_graph.node_count) {
int n = g_app.open_note_target;
const char* sql_id = ge::entity_index_lookup(g_idx, g_graph.nodes[n].user_data);
if (sql_id) {
std::string md;
ge::entity_get_notes(g_app.input_db_path.c_str(), sql_id, &md);
g_app.note_node = n;
g_app.note_entity_id = sql_id;
const char* lbl = graph::graph_label(&g_graph, g_graph.nodes[n].label_idx);
g_app.note_entity_label = lbl ? lbl : "";
uint16_t tid = g_graph.nodes[n].type_id;
g_app.note_entity_type = (tid < (uint16_t)g_graph.type_count
&& g_graph.types[tid].name)
? g_graph.types[tid].name : "";
// Asegura buffer >= max(64KB, contenido + holgura).
size_t need = md.size() + 4096;
if (need < 65536) need = 65536;
g_app.note_buf.assign(need, 0);
std::memcpy(g_app.note_buf.data(), md.data(), md.size());
g_app.note_dirty = false;
g_app.panel_note = true;
ImGui::SetWindowFocus(TI_FILE_TEXT " Note");
}
g_app.want_open_note = false;
g_app.open_note_target = -1;
}
if (g_app.want_save_note && !g_app.note_entity_id.empty()) {
if (ge::entity_set_notes(g_app.input_db_path.c_str(),
g_app.note_entity_id.c_str(),
g_app.note_buf.data())) {
g_app.note_dirty = false;
std::fprintf(stdout, "[graph_explorer] saved note for %s (%zu bytes)\n",
g_app.note_entity_id.c_str(),
std::strlen(g_app.note_buf.data()));
} else {
std::fprintf(stderr, "[graph_explorer] save note failed\n");
}
g_app.want_save_note = false;
}
// Posiciones iniciales razonables; el usuario puede moverlas y se
// persiste via imgui.ini.
const float top = vp->WorkPos.y + 44.0f;
@@ -531,6 +624,7 @@ static void render() {
GraphViewportCallbacks vp_cb{};
vp_cb.on_context_menu = &on_context_menu_cb;
vp_cb.on_double_click = &on_double_click_cb;
graph_viewport("##gv", g_graph, g_viewport, ImVec2(0, 0), vp_cb);
render_context_menu();
@@ -572,6 +666,13 @@ static void render() {
ImGui::SetNextWindowSize(ImVec2(rw, H - sh), ImGuiCond_FirstUseEver);
ge::views_stats(g_app);
// Note editor — al abrirse por primera vez se posiciona como ventana
// centrada. El usuario la puede dockear donde prefiera.
ImGui::SetNextWindowPos (ImVec2(vp->WorkPos.x + W * 0.25f, top + 40.0f),
ImGuiCond_FirstUseEver);
ImGui::SetNextWindowSize(ImVec2(700.0f, 480.0f), ImGuiCond_FirstUseEver);
ge::views_note(g_app);
g_first_render = false;
}