feat: docking host + add-node toolbar + node context menu
- Dockspace host (PassthruCentralNode) bajo la toolbar para que las
ventanas Viewport/Legend/Inspector/Stats puedan dockearse dentro de la
app principal.
- Toolbar: input "Add node" con auto-deteccion de tipo (text/email/
ip_address/url/domain/phone). Insert en operations.db + reload.
- Context menu (right-click sobre nodo): Change type, Duplicate, Delete,
submenu "Run enricher" (placeholder hasta issues 0001-0003).
- Inspector: vecinos ahora muestran etiqueta de relacion ("-> employs",
"<- owns") usando rel_types[].name como label de arista.
- Default relation label k_default_relation_name="RELATED_TO" para
relaciones creadas sin nombre semantico explicito.
- Indice EntityIndex (FNV1a hash -> sql id) reconstruido tras cada load
para resolver mutaciones desde el grafo en memoria.
Issues planteadas para iteraciones siguientes:
- 0001: chat con Claude sobre el grafo (HTTP + tool-use)
- 0002: enricher GLiNER+GLiREL desde nodo texto
- 0003: enricher web (fetch URL/dominio + extract text)
- 0004: vista tabla por tipo de entidad
This commit is contained in:
@@ -22,6 +22,7 @@
|
||||
#include "views.h"
|
||||
#include "types_registry.h"
|
||||
#include "layout_store.h"
|
||||
#include "entity_ops.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
@@ -63,6 +64,9 @@ static auto g_fps_timer = std::chrono::steady_clock::now();
|
||||
// Label policy
|
||||
static graph::LabelPolicy g_label_policy;
|
||||
|
||||
// Indice user_data -> sql id (rebuild en cada load).
|
||||
static ge::EntityIndex g_idx;
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ----------------------------------------------------------------------------
|
||||
@@ -145,6 +149,10 @@ static bool load_input() {
|
||||
}
|
||||
g_graph.update_bounds();
|
||||
|
||||
// Indice user_data -> sql id (para CRUD desde menu contextual).
|
||||
ge::entity_index_build(g_input.uri, &g_idx);
|
||||
g_app.input_db_path = g_input.uri ? g_input.uri : "";
|
||||
|
||||
// Cargar posiciones guardadas para este graph_hash
|
||||
g_graph_hash = ge::compute_graph_hash(g_input.uri);
|
||||
int restored = ge::layout_store_load(g_graph_hash, g_graph);
|
||||
@@ -234,6 +242,88 @@ static void update_fps() {
|
||||
g_last_frame = now;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Context menu callback (right-click sobre nodo)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
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;
|
||||
if (node_idx >= 0 && node_idx < g_graph.node_count) {
|
||||
const GraphNode& n = g_graph.nodes[node_idx];
|
||||
if (n.type_id < (uint16_t)g_graph.type_count && g_graph.types[n.type_id].name) {
|
||||
std::snprintf(g_app.ctx_new_type, sizeof(g_app.ctx_new_type), "%s",
|
||||
g_graph.types[n.type_id].name);
|
||||
} else {
|
||||
g_app.ctx_new_type[0] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Lista de tipos disponibles para "Change type" — se construye desde el grafo
|
||||
// activo. Si esta vacia, se usa una lista por defecto.
|
||||
static const char* k_default_types[] = {
|
||||
"text", "person", "organization", "email", "ip_address", "domain",
|
||||
"url", "phone", "crypto_wallet", "malware", "vulnerability",
|
||||
};
|
||||
constexpr int k_default_types_n = (int)(sizeof(k_default_types) / sizeof(k_default_types[0]));
|
||||
|
||||
static void render_context_menu() {
|
||||
if (g_app.ctx_open_request) {
|
||||
ImGui::OpenPopup("##node_ctx");
|
||||
g_app.ctx_open_request = false;
|
||||
}
|
||||
if (!ImGui::BeginPopup("##node_ctx")) return;
|
||||
|
||||
int idx = g_app.ctx_node;
|
||||
if (idx < 0 || idx >= g_graph.node_count) {
|
||||
ImGui::TextDisabled("(no node)");
|
||||
ImGui::EndPopup();
|
||||
return;
|
||||
}
|
||||
const GraphNode& n = g_graph.nodes[idx];
|
||||
const char* lbl = graph::graph_label(&g_graph, n.label_idx);
|
||||
ImGui::TextDisabled("%s", lbl && *lbl ? lbl : "(unnamed)");
|
||||
ImGui::Separator();
|
||||
|
||||
if (ImGui::BeginMenu("Change type")) {
|
||||
// Tipos del grafo actual
|
||||
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;
|
||||
}
|
||||
}
|
||||
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]);
|
||||
g_app.want_change_type = true;
|
||||
}
|
||||
}
|
||||
ImGui::EndMenu();
|
||||
}
|
||||
|
||||
if (ImGui::MenuItem("Duplicate")) {
|
||||
g_app.want_duplicate_node = true;
|
||||
}
|
||||
if (ImGui::MenuItem("Delete")) {
|
||||
g_app.want_delete_node = true;
|
||||
}
|
||||
|
||||
ImGui::Separator();
|
||||
if (ImGui::BeginMenu("Run enricher")) {
|
||||
ImGui::TextDisabled("(coming soon — issues 0001/0002/0003)");
|
||||
ImGui::EndMenu();
|
||||
}
|
||||
|
||||
ImGui::EndPopup();
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Label callback
|
||||
// ----------------------------------------------------------------------------
|
||||
@@ -249,6 +339,7 @@ static const char* get_label_cb(int node_idx, void* /*user*/) {
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
static fn_ui::PanelToggle g_panels[] = {
|
||||
{"Viewport", nullptr, &g_app.panel_viewport},
|
||||
{"Legend", nullptr, &g_app.panel_legend},
|
||||
{"Inspector", nullptr, &g_app.panel_inspector},
|
||||
{"Stats", nullptr, &g_app.panel_stats},
|
||||
@@ -281,8 +372,29 @@ static void render() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Toolbar superior — usa una ventana sin scroll y sin titulo
|
||||
// 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.
|
||||
ImGuiViewport* vp = ImGui::GetMainViewport();
|
||||
{
|
||||
ImGui::SetNextWindowPos (vp->WorkPos);
|
||||
ImGui::SetNextWindowSize(vp->WorkSize);
|
||||
ImGui::SetNextWindowViewport(vp->ID);
|
||||
ImGuiWindowFlags hostFlags =
|
||||
ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize |
|
||||
ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoCollapse |
|
||||
ImGuiWindowFlags_NoBringToFrontOnFocus | ImGuiWindowFlags_NoNavFocus |
|
||||
ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoDocking |
|
||||
ImGuiWindowFlags_NoSavedSettings;
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0));
|
||||
ImGui::Begin("##dock_host", nullptr, hostFlags);
|
||||
ImGui::PopStyleVar();
|
||||
ImGui::DockSpace(ImGui::GetID("##dockspace"), ImVec2(0, 0),
|
||||
ImGuiDockNodeFlags_PassthruCentralNode);
|
||||
ImGui::End();
|
||||
}
|
||||
|
||||
// Toolbar superior — usa una ventana sin scroll y sin titulo
|
||||
ImGui::SetNextWindowPos(vp->WorkPos);
|
||||
ImGui::SetNextWindowSize(ImVec2(vp->WorkSize.x, 44.0f));
|
||||
ImGui::Begin("##toolbar", nullptr,
|
||||
@@ -335,56 +447,130 @@ static void render() {
|
||||
load_input();
|
||||
}
|
||||
|
||||
// Main work area — viewport central, paneles laterales
|
||||
ImGui::SetNextWindowPos(ImVec2(vp->WorkPos.x, vp->WorkPos.y + 44.0f));
|
||||
ImGui::SetNextWindowSize(ImVec2(vp->WorkSize.x, vp->WorkSize.y - 44.0f));
|
||||
ImGui::Begin("##main", nullptr,
|
||||
ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize |
|
||||
ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoCollapse |
|
||||
ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoSavedSettings);
|
||||
|
||||
ImGui::Columns(3, "##cols", true);
|
||||
static bool s_cols_initialized = false;
|
||||
if (!s_cols_initialized) {
|
||||
ImGui::SetColumnWidth(0, 220.0f);
|
||||
ImGui::SetColumnWidth(1, vp->WorkSize.x - 220.0f - 320.0f);
|
||||
s_cols_initialized = true;
|
||||
}
|
||||
|
||||
// Col izq: Legend
|
||||
ge::views_legend(g_app);
|
||||
ImGui::NextColumn();
|
||||
|
||||
// Col centro: Viewport + force step + labels overlay
|
||||
run_force_step();
|
||||
|
||||
graph_viewport("##gv", g_graph, g_viewport, ImVec2(0, 0));
|
||||
|
||||
// La primera vez que el viewport se dibuja, el renderer existe — bind
|
||||
// del atlas (si tenemos uno).
|
||||
if (!g_atlas_bound && g_viewport.renderer) {
|
||||
if (g_atlas) {
|
||||
graph_renderer_set_icon_atlas(g_viewport.renderer,
|
||||
graph_icons_texture(g_atlas),
|
||||
graph_icons_uv_table(g_atlas),
|
||||
graph_icons_count(g_atlas));
|
||||
// ---- 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;
|
||||
}
|
||||
g_atlas_bound = true;
|
||||
};
|
||||
|
||||
if (g_app.want_add_node && g_app.add_buf[0]) {
|
||||
char new_id[80];
|
||||
if (ge::entity_insert(g_app.input_db_path.c_str(), g_app.add_buf,
|
||||
/*type_ref=*/nullptr, new_id, sizeof(new_id))) {
|
||||
std::fprintf(stdout, "[graph_explorer] added entity %s\n", new_id);
|
||||
g_app.add_buf[0] = 0;
|
||||
reload_after_mutation();
|
||||
} else {
|
||||
std::fprintf(stderr, "[graph_explorer] add_entity failed\n");
|
||||
}
|
||||
g_app.want_add_node = false;
|
||||
}
|
||||
|
||||
if (g_app.labels_enabled) {
|
||||
graph::graph_labels_draw(g_graph, g_viewport, g_label_policy,
|
||||
&get_label_cb, nullptr);
|
||||
}
|
||||
ImGui::NextColumn();
|
||||
auto ctx_id = [&]() -> const char* {
|
||||
if (g_app.ctx_node < 0 || g_app.ctx_node >= g_graph.node_count) return nullptr;
|
||||
return ge::entity_index_lookup(g_idx, g_graph.nodes[g_app.ctx_node].user_data);
|
||||
};
|
||||
|
||||
// Col der: Inspector + Stats
|
||||
if (g_app.want_delete_node) {
|
||||
if (const char* id = ctx_id()) {
|
||||
if (ge::entity_delete(g_app.input_db_path.c_str(), id)) {
|
||||
std::fprintf(stdout, "[graph_explorer] deleted entity %s\n", id);
|
||||
reload_after_mutation();
|
||||
}
|
||||
}
|
||||
g_app.want_delete_node = false;
|
||||
g_app.ctx_node = -1;
|
||||
}
|
||||
|
||||
if (g_app.want_duplicate_node) {
|
||||
if (const char* id = ctx_id()) {
|
||||
char new_id[80];
|
||||
if (ge::entity_duplicate(g_app.input_db_path.c_str(), id,
|
||||
new_id, sizeof(new_id))) {
|
||||
std::fprintf(stdout, "[graph_explorer] duplicated %s -> %s\n", id, new_id);
|
||||
reload_after_mutation();
|
||||
}
|
||||
}
|
||||
g_app.want_duplicate_node = false;
|
||||
}
|
||||
|
||||
if (g_app.want_change_type && g_app.ctx_new_type[0]) {
|
||||
if (const char* id = ctx_id()) {
|
||||
if (ge::entity_update_type(g_app.input_db_path.c_str(), id, g_app.ctx_new_type)) {
|
||||
std::fprintf(stdout, "[graph_explorer] %s -> type %s\n", id, g_app.ctx_new_type);
|
||||
reload_after_mutation();
|
||||
}
|
||||
}
|
||||
g_app.want_change_type = false;
|
||||
}
|
||||
|
||||
// Posiciones iniciales razonables; el usuario puede moverlas y se
|
||||
// persiste via imgui.ini.
|
||||
const float top = vp->WorkPos.y + 44.0f;
|
||||
const float W = vp->WorkSize.x;
|
||||
const float H = vp->WorkSize.y - 44.0f;
|
||||
const float lw = 240.0f; // Legend
|
||||
const float rw = 320.0f; // Inspector / Stats
|
||||
const float sh = H * 0.55f; // Inspector altura
|
||||
|
||||
// Viewport — ventana central
|
||||
if (g_app.panel_viewport) {
|
||||
ImGui::SetNextWindowPos (ImVec2(vp->WorkPos.x + lw, top), ImGuiCond_FirstUseEver);
|
||||
ImGui::SetNextWindowSize(ImVec2(W - lw - rw, H), ImGuiCond_FirstUseEver);
|
||||
if (ImGui::Begin("Viewport", &g_app.panel_viewport)) {
|
||||
run_force_step();
|
||||
|
||||
GraphViewportCallbacks vp_cb{};
|
||||
vp_cb.on_context_menu = &on_context_menu_cb;
|
||||
graph_viewport("##gv", g_graph, g_viewport, ImVec2(0, 0), vp_cb);
|
||||
render_context_menu();
|
||||
|
||||
// La primera vez que el viewport se dibuja, el renderer existe —
|
||||
// bind del atlas (si tenemos uno).
|
||||
if (!g_atlas_bound && g_viewport.renderer) {
|
||||
if (g_atlas) {
|
||||
graph_renderer_set_icon_atlas(g_viewport.renderer,
|
||||
graph_icons_texture(g_atlas),
|
||||
graph_icons_uv_table(g_atlas),
|
||||
graph_icons_count(g_atlas));
|
||||
}
|
||||
g_atlas_bound = true;
|
||||
}
|
||||
|
||||
if (g_app.labels_enabled) {
|
||||
graph::graph_labels_draw(g_graph, g_viewport, g_label_policy,
|
||||
&get_label_cb, nullptr);
|
||||
}
|
||||
}
|
||||
ImGui::End();
|
||||
} else {
|
||||
// Sin ventana visible, igual avanzamos la simulacion para que al
|
||||
// reabrirla el grafo este actualizado.
|
||||
run_force_step();
|
||||
}
|
||||
|
||||
// Legend — izquierda
|
||||
ImGui::SetNextWindowPos (ImVec2(vp->WorkPos.x, top), ImGuiCond_FirstUseEver);
|
||||
ImGui::SetNextWindowSize(ImVec2(lw, H), ImGuiCond_FirstUseEver);
|
||||
ge::views_legend(g_app);
|
||||
|
||||
// Inspector / Stats — derecha (apilados)
|
||||
ImGui::SetNextWindowPos (ImVec2(vp->WorkPos.x + W - rw, top), ImGuiCond_FirstUseEver);
|
||||
ImGui::SetNextWindowSize(ImVec2(rw, sh), ImGuiCond_FirstUseEver);
|
||||
ge::views_inspector(g_app);
|
||||
ge::views_stats(g_app);
|
||||
ImGui::NextColumn();
|
||||
|
||||
ImGui::Columns(1);
|
||||
ImGui::End();
|
||||
ImGui::SetNextWindowPos (ImVec2(vp->WorkPos.x + W - rw, top + sh), ImGuiCond_FirstUseEver);
|
||||
ImGui::SetNextWindowSize(ImVec2(rw, H - sh), ImGuiCond_FirstUseEver);
|
||||
ge::views_stats(g_app);
|
||||
|
||||
g_first_render = false;
|
||||
}
|
||||
@@ -433,6 +619,16 @@ int main(int argc, char** argv) {
|
||||
// SQLite store junto al exe.
|
||||
ge::layout_store_open("graph_explorer.db");
|
||||
|
||||
// Si no llego --input/positional, intentar operations.db en el cwd
|
||||
// (mismo criterio que graph_explorer.db: relativo al directorio de ejecucion).
|
||||
if (g_input_path.empty()) {
|
||||
if (FILE* f = std::fopen("operations.db", "rb")) {
|
||||
std::fclose(f);
|
||||
g_input_path = "operations.db";
|
||||
std::fprintf(stdout, "[graph_explorer] using default ./operations.db\n");
|
||||
}
|
||||
}
|
||||
|
||||
if (!g_input_path.empty()) {
|
||||
load_input();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user