a36530bb6f
- 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
662 lines
24 KiB
C++
662 lines
24 KiB
C++
#include "app_base.h"
|
|
#include "imgui.h"
|
|
|
|
#include "core/fullscreen_window.h"
|
|
#include "core/app_about.h"
|
|
#include "core/app_settings.h"
|
|
#include "core/panel_menu.h"
|
|
#include "core/button.h"
|
|
#include "core/tokens.h"
|
|
|
|
#include "viz/graph_types.h"
|
|
#include "viz/graph_viewport.h"
|
|
#include "viz/graph_renderer.h"
|
|
#include "viz/graph_force_layout.h"
|
|
#include "viz/graph_force_layout_gpu.h"
|
|
#include "viz/graph_layouts.h"
|
|
#include "viz/graph_labels.h"
|
|
#include "viz/graph_icons.h"
|
|
#include "viz/graph_sources.h"
|
|
|
|
#include "data.h"
|
|
#include "views.h"
|
|
#include "types_registry.h"
|
|
#include "layout_store.h"
|
|
#include "entity_ops.h"
|
|
|
|
#include <cstdio>
|
|
#include <cstdlib>
|
|
#include <cstring>
|
|
#include <chrono>
|
|
#include <string>
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// Estado global de la app
|
|
// ----------------------------------------------------------------------------
|
|
|
|
static GraphData g_graph{};
|
|
static GraphViewportState g_viewport;
|
|
static ge::AppState g_app;
|
|
|
|
static ge::InputArgs g_input;
|
|
static std::string g_input_path; // copia para que .uri sea estable
|
|
static std::string g_types_path;
|
|
static std::string g_layout_initial; // --layout flag
|
|
static uint64_t g_graph_hash = 0;
|
|
static bool g_loaded = false;
|
|
|
|
// Force layout GPU context (lazy init).
|
|
static ForceLayoutGPU* g_gpu_ctx = nullptr;
|
|
static bool g_gpu_dirty = true;
|
|
|
|
// Icon atlas (de types.yaml)
|
|
static IconAtlas* g_atlas = nullptr;
|
|
static bool g_atlas_bound = false;
|
|
|
|
// Para detectar primera invocacion de viewport (necesitamos el renderer creado)
|
|
static bool g_first_render = true;
|
|
|
|
// FPS estimate
|
|
static auto g_last_frame = std::chrono::steady_clock::now();
|
|
static int g_frames_acc = 0;
|
|
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
|
|
// ----------------------------------------------------------------------------
|
|
|
|
static int layout_name_to_index(const std::string& s) {
|
|
if (s == "force") return 0;
|
|
if (s == "grid") return 1;
|
|
if (s == "circular") return 2;
|
|
if (s == "radial") return 3;
|
|
if (s == "hierarchical") return 4;
|
|
if (s == "fixed") return 5;
|
|
return -1;
|
|
}
|
|
|
|
static void apply_static_layout(int mode) {
|
|
if (g_graph.node_count == 0) return;
|
|
switch (mode) {
|
|
case 1: graph::layout_grid(g_graph, 20.0f); break;
|
|
case 2: graph::layout_circular(g_graph, 200.0f); break;
|
|
case 3: graph::layout_radial(g_graph, 0, 80.0f); break;
|
|
case 4: graph::layout_hierarchical(g_graph, 0, 120.0f, 60.0f); break;
|
|
case 5: graph::layout_fixed(g_graph); break;
|
|
case 0: default: break; // force: no-op (lo mueve el bucle)
|
|
}
|
|
g_gpu_dirty = true;
|
|
if (mode != 0) {
|
|
g_graph.update_bounds();
|
|
graph_viewport_fit(g_graph, g_viewport);
|
|
}
|
|
}
|
|
|
|
static bool load_input() {
|
|
g_input.kind = ge::INPUT_OPERATIONS;
|
|
g_input.uri = g_input_path.c_str();
|
|
|
|
graph::GraphLoadStats stats{};
|
|
bool ok = ge::load_graph(g_input, &g_graph, &stats);
|
|
if (!ok) {
|
|
std::fprintf(stderr, "[graph_explorer] load failed: %s\n", stats.error_msg);
|
|
return false;
|
|
}
|
|
std::fprintf(stdout,
|
|
"[graph_explorer] loaded %d nodes, %d edges, %d types, %d rel_types from %s\n",
|
|
stats.nodes_loaded, stats.edges_loaded,
|
|
stats.types_discovered, stats.rel_types_discovered, g_input.uri);
|
|
|
|
// types.yaml
|
|
if (!g_types_path.empty()) {
|
|
ge::ParsedTypes pt;
|
|
std::string err;
|
|
if (!ge::types_load_yaml(g_types_path.c_str(), &pt, &err)) {
|
|
std::fprintf(stderr, "[graph_explorer] types.yaml: %s\n", err.c_str());
|
|
} else {
|
|
std::vector<uint16_t> codepoints = ge::apply_types_yaml(g_graph, pt);
|
|
// Reset atlas — la prox vez que el viewport tenga renderer, se baja
|
|
g_atlas_bound = false;
|
|
if (g_atlas) { graph_icons_destroy(g_atlas); g_atlas = nullptr; }
|
|
g_atlas = ge::build_icon_atlas(codepoints);
|
|
std::fprintf(stdout,
|
|
"[graph_explorer] types.yaml: %zu entities, %zu relations, %zu icons\n",
|
|
pt.entities.size(), pt.relations.size(), codepoints.size());
|
|
}
|
|
}
|
|
|
|
// Restablecer viewport state (preserva camara user-visible)
|
|
g_viewport.selection.clear();
|
|
g_viewport.hovered_node = -1;
|
|
g_viewport.selected_node = -1;
|
|
g_viewport.layout_running = true;
|
|
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);
|
|
}
|
|
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);
|
|
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);
|
|
g_gpu_dirty = true;
|
|
|
|
// App state — visibility por tipo
|
|
g_app.graph = &g_graph;
|
|
g_app.viewport = &g_viewport;
|
|
ge::views_reset_visibility(g_app);
|
|
ge::views_apply_visibility(g_app);
|
|
|
|
// --layout inicial (si llego del CLI)
|
|
int idx = layout_name_to_index(g_layout_initial);
|
|
if (idx >= 0) {
|
|
g_app.layout_mode = idx;
|
|
apply_static_layout(idx);
|
|
}
|
|
|
|
g_loaded = true;
|
|
return true;
|
|
}
|
|
|
|
static void run_force_step() {
|
|
if (!g_viewport.layout_running) return;
|
|
if (g_app.layout_mode != 0) return; // force solo en mode 0
|
|
|
|
ForceLayoutConfig cfg;
|
|
cfg.repulsion = g_app.repulsion;
|
|
cfg.attraction = g_app.attraction;
|
|
cfg.gravity = g_app.gravity;
|
|
cfg.iterations = 1;
|
|
|
|
if (g_app.use_gpu) {
|
|
if (!g_gpu_ctx) {
|
|
g_gpu_ctx = graph_force_layout_gpu_create(g_graph.node_count + 1024,
|
|
g_graph.edge_count + 1024);
|
|
g_gpu_dirty = true;
|
|
}
|
|
if (g_gpu_ctx) {
|
|
if (g_gpu_dirty) {
|
|
graph_force_layout_gpu_upload(g_gpu_ctx, g_graph);
|
|
g_gpu_dirty = false;
|
|
}
|
|
g_viewport.layout_energy = graph_force_layout_gpu_step(g_gpu_ctx, cfg);
|
|
graph_force_layout_gpu_readback(g_gpu_ctx, g_graph, /*include_velocities=*/true);
|
|
} else {
|
|
g_app.use_gpu = false;
|
|
g_viewport.layout_energy = graph_force_layout_step(g_graph, cfg);
|
|
}
|
|
} else {
|
|
g_viewport.layout_energy = graph_force_layout_step(g_graph, cfg);
|
|
}
|
|
|
|
// Auto-pause heuristica: si energia/nodo es muy baja durante muchos
|
|
// frames, apagar simulacion. El usuario puede reanudarla con el toggle.
|
|
static int low = 0;
|
|
const float k_pause_per_node = 0.001f;
|
|
const int k_pause_after = 60;
|
|
float per = g_graph.node_count > 0
|
|
? g_viewport.layout_energy / (float)g_graph.node_count
|
|
: 0.0f;
|
|
if (per < k_pause_per_node) ++low;
|
|
else low = 0;
|
|
if (graph_force_layout_should_pause(low, k_pause_after)) {
|
|
g_viewport.layout_running = false;
|
|
low = 0;
|
|
}
|
|
}
|
|
|
|
// FPS estimate sintetico (por segundo).
|
|
static void update_fps() {
|
|
using namespace std::chrono;
|
|
auto now = steady_clock::now();
|
|
++g_frames_acc;
|
|
if (duration_cast<milliseconds>(now - g_fps_timer).count() >= 1000) {
|
|
g_app.fps_estimate = g_frames_acc;
|
|
g_frames_acc = 0;
|
|
g_fps_timer = now;
|
|
}
|
|
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
|
|
// ----------------------------------------------------------------------------
|
|
|
|
static const char* get_label_cb(int node_idx, void* /*user*/) {
|
|
if (node_idx < 0 || node_idx >= g_graph.node_count) return "";
|
|
const GraphNode& n = g_graph.nodes[node_idx];
|
|
return graph::graph_label(&g_graph, n.label_idx);
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// Render
|
|
// ----------------------------------------------------------------------------
|
|
|
|
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},
|
|
};
|
|
|
|
static void render() {
|
|
update_fps();
|
|
|
|
// No tenemos menu propio — fn::run_app llamara al app_menubar via panels[].
|
|
|
|
if (!g_loaded) {
|
|
fullscreen_window_begin("##empty");
|
|
ImGui::TextColored(ImVec4(1, 0.7f, 0.3f, 1),
|
|
"graph_explorer — no input loaded");
|
|
ImGui::Spacing();
|
|
ImGui::TextWrapped(
|
|
"Usage: graph_explorer [<operations.db>] [--input operations <path>] "
|
|
"[--types <yaml>] [--layout <name>]");
|
|
ImGui::Spacing();
|
|
ge::views_open_modal(g_app);
|
|
if (g_app.want_open_file) {
|
|
g_input_path = g_app.open_buf;
|
|
g_app.want_open_file = false;
|
|
load_input();
|
|
}
|
|
if (fn_ui::button("Open file...", fn_ui::ButtonVariant::Primary)) {
|
|
g_app.show_open_modal = true;
|
|
}
|
|
fullscreen_window_end();
|
|
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.
|
|
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,
|
|
ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize |
|
|
ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoCollapse |
|
|
ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse |
|
|
ImGuiWindowFlags_NoSavedSettings);
|
|
ge::views_toolbar(g_app);
|
|
ImGui::End();
|
|
|
|
// Modals
|
|
ge::views_open_modal(g_app);
|
|
ge::views_filters_modal(g_app);
|
|
|
|
// Si el usuario aplico nuevo layout en la toolbar
|
|
if (g_app.apply_layout_tick > 0) {
|
|
apply_static_layout(g_app.layout_mode);
|
|
g_app.apply_layout_tick = 0;
|
|
}
|
|
|
|
// Triggers desde la toolbar
|
|
if (g_app.want_fit) {
|
|
graph_viewport_fit(g_graph, g_viewport);
|
|
g_app.want_fit = false;
|
|
}
|
|
if (g_app.want_reload) {
|
|
g_app.want_reload = false;
|
|
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);
|
|
int restored = ge::layout_store_load(g_graph_hash, g_graph);
|
|
if (restored > 0) g_graph.update_bounds();
|
|
g_atlas_bound = false; // re-bind atlas tras reload
|
|
g_gpu_dirty = true;
|
|
}
|
|
}
|
|
if (g_app.want_save_layout) {
|
|
int n = ge::layout_store_save(g_graph_hash, g_graph);
|
|
std::fprintf(stdout, "[graph_explorer] saved %d node positions\n", n);
|
|
g_app.want_save_layout = false;
|
|
}
|
|
if (g_app.want_open_file) {
|
|
g_input_path = g_app.open_buf;
|
|
g_app.want_open_file = false;
|
|
// Cleanup viejo grafo
|
|
graph::graph_free(&g_graph);
|
|
load_input();
|
|
}
|
|
|
|
// ---- 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 (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;
|
|
}
|
|
|
|
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);
|
|
};
|
|
|
|
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);
|
|
|
|
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;
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// CLI parsing
|
|
// ----------------------------------------------------------------------------
|
|
|
|
static void usage() {
|
|
std::fprintf(stderr,
|
|
"Usage: graph_explorer [<operations.db>]\n"
|
|
" graph_explorer --input operations <path>\n"
|
|
" graph_explorer --types <types.yaml>\n"
|
|
" graph_explorer --layout force|grid|circular|radial|hierarchical|fixed\n");
|
|
}
|
|
|
|
int main(int argc, char** argv) {
|
|
for (int i = 1; i < argc; ++i) {
|
|
const char* a = argv[i];
|
|
if (std::strcmp(a, "--input") == 0 && i + 2 < argc) {
|
|
const char* kind = argv[++i];
|
|
const char* path = argv[++i];
|
|
if (std::strcmp(kind, "operations") == 0) {
|
|
g_input_path = path;
|
|
} else {
|
|
std::fprintf(stderr, "[graph_explorer] unsupported input kind: %s\n", kind);
|
|
return 1;
|
|
}
|
|
} else if (std::strcmp(a, "--types") == 0 && i + 1 < argc) {
|
|
g_types_path = argv[++i];
|
|
} else if (std::strcmp(a, "--layout") == 0 && i + 1 < argc) {
|
|
g_layout_initial = argv[++i];
|
|
} else if (std::strcmp(a, "--help") == 0 || std::strcmp(a, "-h") == 0) {
|
|
usage();
|
|
return 0;
|
|
} else if (a[0] == '-') {
|
|
std::fprintf(stderr, "[graph_explorer] unknown flag: %s\n", a);
|
|
usage();
|
|
return 1;
|
|
} else {
|
|
// Positional: tratado como operations.db
|
|
if (g_input_path.empty()) g_input_path = a;
|
|
}
|
|
}
|
|
|
|
// 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();
|
|
}
|
|
|
|
fn_ui::about_window_set_info(
|
|
"graph_explorer",
|
|
"0.1.0",
|
|
"Visor de grafos GPU-accelerated agnostico del backend. Lee operations.db de "
|
|
"cualquier app del registry y permite explorar entidades/relaciones con "
|
|
"shapes/iconos/layouts/filtros.");
|
|
|
|
int rc = fn::run_app(
|
|
{.title = "graph_explorer",
|
|
.width = 1600,
|
|
.height = 1000,
|
|
.viewports = true,
|
|
.panels = g_panels,
|
|
.panel_count = sizeof(g_panels) / sizeof(g_panels[0]),
|
|
.init_gl_loader = true},
|
|
render);
|
|
|
|
// Cleanup
|
|
if (g_gpu_ctx) graph_force_layout_gpu_destroy(g_gpu_ctx);
|
|
if (g_atlas) graph_icons_destroy(g_atlas);
|
|
graph_viewport_destroy(g_viewport);
|
|
graph::graph_free(&g_graph);
|
|
ge::layout_store_close();
|
|
|
|
return rc;
|
|
}
|