87a554da84
Cambios de UX en la toolbar y arranque: - Boton 'Layout: <name>' que abre popup con la lista de layouts (force, grid, circular, radial, hierarchical, fixed) + 'Reset positions (unpin + restart)' + 'Save current layout'. Reemplaza el combo pequeno + los botones Save/Reset que estaban dispersos. - Boton 'Physics: ON/OFF' (Player Play/Pause) toggle visible que reemplaza el checkbox 'Run layout'. Variant Primary cuando ON, Subtle cuando OFF. - Default: layout_mode = 5 (fixed) y layout_running = false. Asi al abrir un proyecto los nodos respetan posiciones guardadas y no se mueven solos. El usuario activa fisicas con el boton Physics y/o cambia el layout desde el dropdown si quiere. Reset layout (boton dentro del popup Layout) sigue activando physics para que el grafo se reasiente; es el flujo natural del 'Reset'.
1422 lines
58 KiB
C++
1422 lines
58 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 "core/icons_tabler.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 "project_manager.h"
|
|
|
|
#include "../../../../cpp/vendor/sqlite3/sqlite3.h"
|
|
|
|
#include "tableview.h"
|
|
#include "duckdb.h"
|
|
|
|
#include <cstdio>
|
|
#include <cstdlib>
|
|
#include <cstring>
|
|
#include <chrono>
|
|
#include <cmath>
|
|
#include <string>
|
|
#include <vector>
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// 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;
|
|
|
|
// Project state — paths derivados del proyecto activo. En modo legacy
|
|
// (--input/positional explicito), `g_active_project` queda vacio y los paths
|
|
// vienen del CLI directamente.
|
|
static std::string g_active_project;
|
|
static std::string g_layout_db_path; // ruta de graph_explorer.db
|
|
|
|
// 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);
|
|
}
|
|
}
|
|
|
|
// Forward decl — definido mas abajo, lo necesita switch_to_project.
|
|
static bool load_input();
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// Project lifecycle
|
|
// ----------------------------------------------------------------------------
|
|
|
|
// Aplica los paths del proyecto `slug` a las globales (g_input_path,
|
|
// g_types_path, g_layout_db_path) y actualiza g_active_project. No abre BDs
|
|
// ni carga el grafo — eso lo hace el caller.
|
|
static void apply_project_paths(const std::string& slug) {
|
|
ge::ProjectPaths p = ge::project_paths(slug.c_str());
|
|
g_active_project = slug;
|
|
g_input_path = p.operations_db;
|
|
g_types_path = p.types_yaml;
|
|
g_layout_db_path = p.layout_db;
|
|
g_app.active_project = slug;
|
|
}
|
|
|
|
// Cambia al proyecto `slug`: cierra layout_store, libera grafo, abre BDs
|
|
// nuevas, carga grafo, persiste como last_active. Devuelve true en exito.
|
|
static bool switch_to_project(const std::string& slug) {
|
|
if (slug.empty()) return false;
|
|
if (!ge::project_exists(slug.c_str())) {
|
|
std::fprintf(stderr, "[graph_explorer] project '%s' no existe\n",
|
|
slug.c_str());
|
|
return false;
|
|
}
|
|
// Cierra estado del proyecto actual
|
|
ge::layout_store_close();
|
|
if (g_loaded) {
|
|
graph::graph_free(&g_graph);
|
|
g_loaded = false;
|
|
}
|
|
if (g_atlas) {
|
|
graph_icons_destroy(g_atlas);
|
|
g_atlas = nullptr;
|
|
}
|
|
g_atlas_bound = false;
|
|
g_viewport.selection.clear();
|
|
g_viewport.hovered_node = -1;
|
|
g_viewport.selected_node = -1;
|
|
|
|
// Aplica paths nuevos y abre BDs
|
|
apply_project_paths(slug);
|
|
ge::views_inspector_clear_draft(g_app);
|
|
g_app.parsed_types = ge::ParsedTypes{};
|
|
if (!ge::layout_store_open(g_layout_db_path.c_str())) {
|
|
std::fprintf(stderr, "[graph_explorer] layout_store_open failed: %s\n",
|
|
g_layout_db_path.c_str());
|
|
}
|
|
bool ok = load_input();
|
|
if (ok) ge::project_settings_touch(slug.c_str());
|
|
return ok;
|
|
}
|
|
|
|
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);
|
|
int total_fields = 0;
|
|
int with_schema = 0;
|
|
for (const auto& e : pt.entities) {
|
|
total_fields += (int)e.fields.size();
|
|
if (!e.fields.empty()) ++with_schema;
|
|
}
|
|
std::fprintf(stdout,
|
|
"[graph_explorer] types.yaml: %zu entities (%d con schema, %d fields totales),"
|
|
" %zu relations, %zu icons\n",
|
|
pt.entities.size(), with_schema, total_fields,
|
|
pt.relations.size(), codepoints.size());
|
|
// Stash en AppState para que el Inspector resuelva schemas (issue 0008).
|
|
g_app.parsed_types = std::move(pt);
|
|
}
|
|
}
|
|
// Inicializar el draft del Type Editor con copia de parsed_types (0007).
|
|
g_app.types_draft = g_app.parsed_types;
|
|
g_app.types_dirty = false;
|
|
g_app.types_save_error.clear();
|
|
|
|
// Restablecer viewport state (preserva camara user-visible). Physics
|
|
// arrancan en pausa para que las posiciones guardadas no se pierdan;
|
|
// el usuario las activa con el boton Physics de la toolbar.
|
|
g_viewport.selection.clear();
|
|
g_viewport.hovered_node = -1;
|
|
g_viewport.selected_node = -1;
|
|
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);
|
|
}
|
|
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);
|
|
|
|
// Cache de conteos de Table nodes (issue 0010).
|
|
if (g_input.uri) {
|
|
ge::tableview_refresh_counts(g_input.uri, &g_app.table_node_counts);
|
|
int64_t total_rows = 0;
|
|
for (auto& kv : g_app.table_node_counts) total_rows += kv.second;
|
|
std::fprintf(stdout,
|
|
"[graph_explorer] table counts refreshed: %zu tables, %lld total rows\n",
|
|
g_app.table_node_counts.size(), (long long)total_rows);
|
|
// Sync de windows expandidas (issue 0011) — reabre las que el
|
|
// usuario tenia abiertas en la sesion previa (metadata.expanded=true).
|
|
ge::views_table_windows_sync(g_app, g_input.uri);
|
|
}
|
|
|
|
// Cache de la vista tabla (issue 0004) — pull bulk + neighbors desde grafo.
|
|
{
|
|
std::vector<ge::EntityRowSnapshot> snap;
|
|
if (g_input.uri && ge::entity_list_rows(g_input.uri, &snap)) {
|
|
g_app.table_rows.clear();
|
|
g_app.table_rows.reserve(snap.size());
|
|
for (auto& s : snap) {
|
|
ge::AppState::TableRow tr;
|
|
tr.id = std::move(s.id);
|
|
tr.name = std::move(s.name);
|
|
tr.type_ref = std::move(s.type_ref);
|
|
tr.status = std::move(s.status);
|
|
tr.updated_at = std::move(s.updated_at);
|
|
g_app.table_rows.push_back(std::move(tr));
|
|
}
|
|
ge::views_table_refresh_indices(g_app);
|
|
g_app.table_cache_dirty = false;
|
|
}
|
|
}
|
|
|
|
// Inspector: refresca caches (tags distintas, lista de tipos) y limpia
|
|
// cualquier draft anterior. El draft se cargara cuando el usuario
|
|
// seleccione un nodo en el render loop.
|
|
ge::views_inspector_clear_draft(g_app);
|
|
ge::views_inspector_refresh_caches(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;
|
|
// Tapa de energia: damping mas agresivo + max_velocity bajo evita que el
|
|
// grafo "explote" al cargar (nodos que arrancan cerca del origen y se
|
|
// dispersan con repulsion alta). Valores tuneados para sentir movimiento
|
|
// suave sin saltos visibles entre frames.
|
|
cfg.damping = 0.7f;
|
|
cfg.max_velocity = 8.0f;
|
|
|
|
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)
|
|
// ----------------------------------------------------------------------------
|
|
|
|
// 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;
|
|
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();
|
|
|
|
// Detectar si el nodo es Table y resolver entity_id para opciones tabla.
|
|
bool is_table = false;
|
|
if (n.type_id < (uint16_t)g_graph.type_count) {
|
|
const EntityType& t = g_graph.types[n.type_id];
|
|
if (t.name && std::strcmp(t.name, "Table") == 0) is_table = true;
|
|
}
|
|
const char* sql_id = ge::entity_index_lookup(g_idx, n.user_data);
|
|
|
|
if (is_table && sql_id) {
|
|
// Determinar estado expanded actual sin ir a BD: mira table_windows.
|
|
bool currently_expanded =
|
|
g_app.table_windows.find(sql_id) != g_app.table_windows.end();
|
|
const char* lbl_exp = currently_expanded
|
|
? TI_X " Collapse table"
|
|
: TI_TABLE " Expand table";
|
|
if (ImGui::MenuItem(lbl_exp)) {
|
|
g_app.want_toggle_expanded = true;
|
|
g_app.toggle_expanded_id = sql_id;
|
|
}
|
|
ImGui::Separator();
|
|
}
|
|
|
|
if (ImGui::BeginMenu("Change type")) {
|
|
// 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) {
|
|
if (g_graph.types[i].name && *g_graph.types[i].name) {
|
|
all.push_back(g_graph.types[i].name);
|
|
}
|
|
}
|
|
for (int i = 0; i < k_default_types_n; ++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();
|
|
}
|
|
|
|
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},
|
|
{"Note", nullptr, &g_app.panel_note},
|
|
{"Types", nullptr, &g_app.panel_type_editor},
|
|
{"Table", nullptr, &g_app.panel_table},
|
|
};
|
|
|
|
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 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 (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 |
|
|
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);
|
|
ge::views_new_project_modal(g_app);
|
|
|
|
// Project switch (desde menu Project, o tras crear proyecto nuevo)
|
|
if (g_app.want_switch_project && !g_app.switch_project_target.empty()) {
|
|
std::string target = g_app.switch_project_target;
|
|
g_app.want_switch_project = false;
|
|
g_app.switch_project_target.clear();
|
|
if (!switch_to_project(target)) {
|
|
std::fprintf(stderr,
|
|
"[graph_explorer] switch_to_project('%s') failed\n", target.c_str());
|
|
}
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
|
|
// Filtro FTS5/tags (issue 0009) — reaplica si el toolbar marco dirty.
|
|
if (g_app.filter_dirty) {
|
|
ge::views_filter_apply(g_app);
|
|
}
|
|
// Centrado del nodo seleccionado desde el dropdown.
|
|
if (g_app.filter_focus_target >= 0
|
|
&& g_app.filter_focus_target < g_graph.node_count) {
|
|
const GraphNode& n = g_graph.nodes[g_app.filter_focus_target];
|
|
g_viewport.cam_x = -n.x;
|
|
g_viewport.cam_y = -n.y;
|
|
g_app.filter_focus_target = -1;
|
|
}
|
|
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();
|
|
}
|
|
|
|
|
|
// ---- Type Editor (issue 0007) ----
|
|
if (g_app.want_types_save) {
|
|
g_app.want_types_save = false;
|
|
g_app.types_save_error.clear();
|
|
if (g_types_path.empty()) {
|
|
g_app.types_save_error =
|
|
"No hay types.yaml asignado (abre un proyecto o usa --types).";
|
|
} else {
|
|
std::string err;
|
|
if (!ge::types_save_yaml(g_types_path.c_str(),
|
|
g_app.types_draft, &err)) {
|
|
g_app.types_save_error = "Save failed: " + err;
|
|
} else {
|
|
std::fprintf(stdout,
|
|
"[graph_explorer] types.yaml saved -> %s\n",
|
|
g_types_path.c_str());
|
|
g_app.parsed_types = g_app.types_draft;
|
|
std::vector<uint16_t> cps =
|
|
ge::apply_types_yaml(g_graph, g_app.parsed_types);
|
|
if (g_atlas) { graph_icons_destroy(g_atlas); g_atlas = nullptr; }
|
|
g_atlas = ge::build_icon_atlas(cps);
|
|
g_atlas_bound = false;
|
|
g_gpu_dirty = true;
|
|
g_app.types_dirty = false;
|
|
ge::views_inspector_refresh_caches(g_app);
|
|
}
|
|
}
|
|
}
|
|
if (g_app.want_types_reload) {
|
|
g_app.want_types_reload = false;
|
|
g_app.types_save_error.clear();
|
|
if (g_types_path.empty()) {
|
|
// Sin types.yaml en disco: descarta el draft a parsed_types actual.
|
|
g_app.types_draft = g_app.parsed_types;
|
|
g_app.types_dirty = false;
|
|
} else {
|
|
ge::ParsedTypes pt;
|
|
std::string err;
|
|
if (!ge::types_load_yaml(g_types_path.c_str(), &pt, &err)) {
|
|
g_app.types_save_error = "Reload failed: " + err;
|
|
} else {
|
|
std::vector<uint16_t> cps = ge::apply_types_yaml(g_graph, pt);
|
|
if (g_atlas) { graph_icons_destroy(g_atlas); g_atlas = nullptr; }
|
|
g_atlas = ge::build_icon_atlas(cps);
|
|
g_atlas_bound = false;
|
|
g_gpu_dirty = true;
|
|
g_app.parsed_types = pt;
|
|
g_app.types_draft = std::move(pt);
|
|
g_app.types_dirty = false;
|
|
ge::views_inspector_refresh_caches(g_app);
|
|
}
|
|
}
|
|
}
|
|
// Conteo de uso para el modal de borrado (entidades activas en BD).
|
|
if (g_app.show_te_delete_modal && g_app.te_delete_use_count == 0
|
|
&& !g_app.input_db_path.empty()) {
|
|
const char* tname = nullptr;
|
|
if (g_app.te_pending_delete_e >= 0
|
|
&& g_app.te_pending_delete_e < (int)g_app.types_draft.entities.size()) {
|
|
tname = g_app.types_draft.entities[g_app.te_pending_delete_e].name.c_str();
|
|
}
|
|
if (tname && *tname) {
|
|
sqlite3* db = nullptr;
|
|
if (sqlite3_open_v2(g_app.input_db_path.c_str(), &db,
|
|
SQLITE_OPEN_READONLY, nullptr) == SQLITE_OK) {
|
|
sqlite3_stmt* st = nullptr;
|
|
if (sqlite3_prepare_v2(db,
|
|
"SELECT COUNT(*) FROM entities WHERE type_ref = ?",
|
|
-1, &st, nullptr) == SQLITE_OK) {
|
|
sqlite3_bind_text(st, 1, tname, -1, SQLITE_TRANSIENT);
|
|
if (sqlite3_step(st) == SQLITE_ROW) {
|
|
g_app.te_delete_use_count = sqlite3_column_int(st, 0);
|
|
}
|
|
sqlite3_finalize(st);
|
|
}
|
|
sqlite3_close(db);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- Mutaciones (add/delete/duplicate/change_type) ----
|
|
auto reload_after_mutation = [&]() {
|
|
graph::GraphLoadStats stats{};
|
|
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);
|
|
|
|
// Reaplica types.yaml + atlas. Sin esto, despues de cualquier
|
|
// mutacion los tipos pierden color/shape/icon (todo nodo vuelve a
|
|
// circulo gris). Issue: al promover desde tableview el Table
|
|
// dejaba de ser cuadrado.
|
|
if (!g_app.parsed_types.entities.empty() ||
|
|
!g_app.parsed_types.relations.empty()) {
|
|
std::vector<uint16_t> cps = ge::apply_types_yaml(g_graph, g_app.parsed_types);
|
|
if (g_atlas) { graph_icons_destroy(g_atlas); g_atlas = nullptr; }
|
|
g_atlas = ge::build_icon_atlas(cps);
|
|
g_atlas_bound = false;
|
|
g_gpu_dirty = true;
|
|
}
|
|
|
|
// Refresh Table node counts (issue 0010).
|
|
ge::tableview_refresh_counts(g_input.uri, &g_app.table_node_counts);
|
|
|
|
// Sincroniza windows (issue 0011) por si una Table aparecio o desaparecio.
|
|
ge::views_table_windows_sync(g_app, g_input.uri);
|
|
|
|
// Refresh table cache (issue 0004).
|
|
std::vector<ge::EntityRowSnapshot> snap;
|
|
if (ge::entity_list_rows(g_input.uri, &snap)) {
|
|
g_app.table_rows.clear();
|
|
g_app.table_rows.reserve(snap.size());
|
|
for (auto& s : snap) {
|
|
ge::AppState::TableRow tr;
|
|
tr.id = std::move(s.id);
|
|
tr.name = std::move(s.name);
|
|
tr.type_ref = std::move(s.type_ref);
|
|
tr.status = std::move(s.status);
|
|
tr.updated_at = std::move(s.updated_at);
|
|
g_app.table_rows.push_back(std::move(tr));
|
|
}
|
|
ge::views_table_refresh_indices(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]) {
|
|
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;
|
|
}
|
|
|
|
// ---- Table node UI fase 2 (issue 0011) ----
|
|
if (g_app.want_toggle_expanded && !g_app.toggle_expanded_id.empty()
|
|
&& !g_input_path.empty()) {
|
|
std::string id = g_app.toggle_expanded_id;
|
|
bool currently = g_app.table_windows.find(id) != g_app.table_windows.end();
|
|
ge::tableview_set_expanded(g_input_path.c_str(), id.c_str(), !currently);
|
|
ge::views_table_windows_sync(g_app, g_input_path.c_str());
|
|
g_app.want_toggle_expanded = false;
|
|
g_app.toggle_expanded_id.clear();
|
|
}
|
|
// Cierre via X de la ventana -> bajar expanded en BD.
|
|
for (auto it = g_app.table_windows.begin(); it != g_app.table_windows.end(); ) {
|
|
if (!it->second.open && !g_input_path.empty()) {
|
|
ge::tableview_set_expanded(g_input_path.c_str(),
|
|
it->first.c_str(), false);
|
|
it = g_app.table_windows.erase(it);
|
|
} else ++it;
|
|
}
|
|
// Refrescar la pagina si alguna window esta dirty.
|
|
for (auto& kv : g_app.table_windows) {
|
|
auto& w = kv.second;
|
|
if (!w.page_dirty) continue;
|
|
const auto& m = w.meta;
|
|
ge::tableview_count(m.duckdb_path_abs.c_str(), m.table_name.c_str(),
|
|
m.filter_sql.empty() ? nullptr : m.filter_sql.c_str(),
|
|
&w.total_rows);
|
|
if (m.columns.empty()) {
|
|
std::vector<std::string> cols;
|
|
if (ge::tableview_list_columns(m.duckdb_path_abs.c_str(),
|
|
m.table_name.c_str(), &cols)) {
|
|
ge::tableview_set_columns(g_input_path.c_str(),
|
|
m.entity_id.c_str(), cols);
|
|
w.meta.columns = cols;
|
|
}
|
|
}
|
|
ge::tableview_page(m.duckdb_path_abs.c_str(), m.table_name.c_str(),
|
|
m.id_column.c_str(), w.meta.columns,
|
|
m.filter_sql.empty() ? nullptr : m.filter_sql.c_str(),
|
|
g_input_path.c_str(), m.row_type.c_str(),
|
|
w.offset, 200, &w.page);
|
|
w.page_dirty = false;
|
|
}
|
|
if (g_app.want_promote_row && !g_app.promote_table_id.empty()
|
|
&& !g_input_path.empty()) {
|
|
ge::TableMetadata m;
|
|
if (ge::tableview_get_metadata(g_input_path.c_str(),
|
|
g_app.promote_table_id.c_str(), &m)) {
|
|
char new_id[128] = {};
|
|
if (ge::tableview_promote_row(g_input_path.c_str(),
|
|
g_app.promote_table_id.c_str(),
|
|
m.duckdb_path_abs.c_str(),
|
|
m.table_name.c_str(),
|
|
g_app.promote_row_id.c_str(),
|
|
m.row_type.c_str(),
|
|
m.label_column.c_str(),
|
|
new_id, sizeof(new_id))) {
|
|
std::fprintf(stdout, "[promote] %s -> %s\n",
|
|
g_app.promote_row_id.c_str(), new_id);
|
|
auto it = g_app.table_windows.find(g_app.promote_table_id);
|
|
if (it != g_app.table_windows.end()) it->second.page_dirty = true;
|
|
reload_after_mutation();
|
|
g_app.want_focus_entity = true;
|
|
g_app.focus_entity_id = new_id;
|
|
}
|
|
}
|
|
g_app.want_promote_row = false;
|
|
g_app.promote_table_id.clear();
|
|
g_app.promote_row_id.clear();
|
|
}
|
|
if (g_app.want_demote_entity && !g_app.demote_entity_id.empty()
|
|
&& !g_input_path.empty()) {
|
|
if (ge::tableview_demote_row(g_input_path.c_str(),
|
|
g_app.demote_entity_id.c_str())) {
|
|
std::fprintf(stdout, "[demote] %s\n", g_app.demote_entity_id.c_str());
|
|
for (auto& kv : g_app.table_windows) kv.second.page_dirty = true;
|
|
reload_after_mutation();
|
|
}
|
|
g_app.want_demote_entity = false;
|
|
g_app.demote_entity_id.clear();
|
|
}
|
|
if (g_app.want_focus_entity && !g_app.focus_entity_id.empty()) {
|
|
for (int i = 0; i < g_graph.node_count; ++i) {
|
|
const char* sid = ge::entity_index_lookup(
|
|
g_idx, g_graph.nodes[i].user_data);
|
|
if (sid && g_app.focus_entity_id == sid) {
|
|
g_app.filter_focus_target = i;
|
|
graph_viewport_clear_selection(g_graph, g_viewport);
|
|
graph_viewport_add_to_selection(g_graph, g_viewport, i);
|
|
g_app.panel_inspector = true;
|
|
ge::views_inspector_load_draft(g_app, i, sid);
|
|
g_app.insp_node_idx = i;
|
|
g_app.insp_entity_id = sid;
|
|
break;
|
|
}
|
|
}
|
|
g_app.want_focus_entity = false;
|
|
g_app.focus_entity_id.clear();
|
|
}
|
|
if (g_app.want_import) {
|
|
g_app.want_import = false;
|
|
g_app.import_error.clear();
|
|
std::string duck_abs = ge::tableview_resolve_path(
|
|
g_input_path.c_str(), g_app.import_duckdb_buf);
|
|
std::string err;
|
|
if (!ge::tableview_ingest_file(duck_abs.c_str(),
|
|
g_app.import_path_buf,
|
|
g_app.import_table_buf,
|
|
ge::INGEST_AUTO, &err)) {
|
|
g_app.import_error = "Ingest failed: " + err;
|
|
} else {
|
|
char new_id[80] = {};
|
|
if (ge::tableview_create(g_input_path.c_str(),
|
|
g_app.import_table_buf,
|
|
g_app.import_duckdb_buf,
|
|
g_app.import_table_buf,
|
|
g_app.import_row_type_buf,
|
|
new_id, sizeof(new_id))) {
|
|
std::fprintf(stdout, "[import] %s -> %s\n",
|
|
g_app.import_path_buf, new_id);
|
|
g_app.show_import_modal = false;
|
|
reload_after_mutation();
|
|
} else {
|
|
g_app.import_error = "Tabla DuckDB creada pero no se pudo registrar el nodo.";
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- Inspector (issue 0008): sync draft con seleccion + save/discard ----
|
|
{
|
|
const auto& sel = g_viewport.selection;
|
|
if (sel.size() == 1) {
|
|
int sidx = sel.front();
|
|
if (sidx >= 0 && sidx < g_graph.node_count
|
|
&& sidx != g_app.insp_node_idx
|
|
&& !g_app.insp_dirty) {
|
|
const char* sql_id = ge::entity_index_lookup(
|
|
g_idx, g_graph.nodes[sidx].user_data);
|
|
ge::views_inspector_load_draft(g_app, sidx, sql_id);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (g_app.want_inspector_save && !g_app.insp_entity_id.empty()) {
|
|
ge::EntityRecord rec = ge::views_inspector_build_record(g_app);
|
|
if (ge::entity_update(g_app.input_db_path.c_str(), rec)) {
|
|
std::fprintf(stdout, "[graph_explorer] saved entity %s\n",
|
|
rec.id.c_str());
|
|
// 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)) {
|
|
ge::entity_index_build(g_input.uri, &g_idx);
|
|
ge::views_reset_visibility(g_app);
|
|
ge::views_apply_visibility(g_app);
|
|
int restored = ge::layout_store_load(g_graph_hash, g_graph);
|
|
(void)restored;
|
|
g_atlas_bound = false;
|
|
g_gpu_dirty = true;
|
|
}
|
|
ge::views_inspector_refresh_caches(g_app);
|
|
// Re-cargar draft tras el reload (los node_idx pueden haber cambiado
|
|
// por reordenamiento de la BD). Buscamos el nuevo idx por sql_id.
|
|
int new_idx = -1;
|
|
for (int i = 0; i < g_graph.node_count; ++i) {
|
|
const char* sid = ge::entity_index_lookup(
|
|
g_idx, g_graph.nodes[i].user_data);
|
|
if (sid && rec.id == sid) { new_idx = i; break; }
|
|
}
|
|
if (new_idx >= 0) {
|
|
ge::views_inspector_load_draft(g_app, new_idx, rec.id.c_str());
|
|
graph_viewport_clear_selection(g_graph, g_viewport);
|
|
graph_viewport_add_to_selection(g_graph, g_viewport, new_idx);
|
|
} else {
|
|
ge::views_inspector_clear_draft(g_app);
|
|
}
|
|
} else {
|
|
std::fprintf(stderr, "[graph_explorer] entity_update failed for %s\n",
|
|
rec.id.c_str());
|
|
}
|
|
g_app.want_inspector_save = false;
|
|
}
|
|
|
|
if (g_app.want_inspector_discard && !g_app.insp_entity_id.empty()) {
|
|
int idx = g_app.insp_node_idx;
|
|
std::string id = g_app.insp_entity_id;
|
|
ge::views_inspector_load_draft(g_app, idx, id.c_str());
|
|
g_app.want_inspector_discard = 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;
|
|
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;
|
|
vp_cb.on_double_click = &on_double_click_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);
|
|
}
|
|
// Table node overlay (issue 0010) — encima de las labels.
|
|
ge::views_table_overlay(g_app);
|
|
}
|
|
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);
|
|
|
|
// 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);
|
|
|
|
// Type Editor (issue 0007) — flotante, dockeable.
|
|
ImGui::SetNextWindowPos (ImVec2(vp->WorkPos.x + W * 0.20f, top + 40.0f),
|
|
ImGuiCond_FirstUseEver);
|
|
ImGui::SetNextWindowSize(ImVec2(720.0f, 500.0f), ImGuiCond_FirstUseEver);
|
|
ge::views_type_editor(g_app);
|
|
ge::views_type_editor_delete_modal(g_app);
|
|
|
|
// Table view (issue 0004) — flotante, dockeable.
|
|
ImGui::SetNextWindowPos (ImVec2(vp->WorkPos.x + W * 0.15f, top + 60.0f),
|
|
ImGuiCond_FirstUseEver);
|
|
ImGui::SetNextWindowSize(ImVec2(820.0f, 520.0f), ImGuiCond_FirstUseEver);
|
|
ge::views_table(g_app);
|
|
|
|
// Table node windows (issue 0011) — una por Table expandida.
|
|
ge::views_table_window(g_app);
|
|
ge::views_import_dataset_modal(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"
|
|
" graph_explorer --project <slug>\n"
|
|
" graph_explorer --test-types-yaml <path> (load+save+reload smoke test)\n"
|
|
" graph_explorer --test-duckdb <path> (open + SELECT 42 smoke test)\n"
|
|
" graph_explorer --test-tableview <path> (1M rows count + page test)\n");
|
|
}
|
|
|
|
// Smoke test del parser+writer (issue 0005 round-trip): carga `path`,
|
|
// serializa a un temporal y vuelve a cargar. Compara campos clave de
|
|
// ParsedTypes. Devuelve exit code 0 si OK, 1 si discrepancia, 2 si error.
|
|
static int test_types_yaml_roundtrip(const char* path) {
|
|
ge::ParsedTypes pt1;
|
|
std::string err;
|
|
if (!ge::types_load_yaml(path, &pt1, &err)) {
|
|
std::fprintf(stderr, "[test] load1 fail: %s\n", err.c_str());
|
|
return 2;
|
|
}
|
|
std::string tmp = std::string(path) + ".roundtrip.yaml";
|
|
if (!ge::types_save_yaml(tmp.c_str(), pt1, &err)) {
|
|
std::fprintf(stderr, "[test] save fail: %s\n", err.c_str());
|
|
return 2;
|
|
}
|
|
ge::ParsedTypes pt2;
|
|
if (!ge::types_load_yaml(tmp.c_str(), &pt2, &err)) {
|
|
std::fprintf(stderr, "[test] load2 fail: %s\n", err.c_str());
|
|
return 2;
|
|
}
|
|
|
|
auto cmp = [&]() -> bool {
|
|
if (pt1.entities.size() != pt2.entities.size()) return false;
|
|
if (pt1.relations.size() != pt2.relations.size()) return false;
|
|
for (size_t i = 0; i < pt1.entities.size(); ++i) {
|
|
const auto& a = pt1.entities[i];
|
|
const auto& b = pt2.entities[i];
|
|
if (a.name != b.name) return false;
|
|
if (a.color != b.color) return false;
|
|
if (a.icon_name != b.icon_name) return false;
|
|
if (a.principal_field != b.principal_field) return false;
|
|
if (a.fields.size() != b.fields.size()) return false;
|
|
for (size_t j = 0; j < a.fields.size(); ++j) {
|
|
const auto& fa = a.fields[j];
|
|
const auto& fb = b.fields[j];
|
|
if (fa.name != fb.name) return false;
|
|
if (fa.kind != fb.kind) return false;
|
|
if (fa.required != fb.required) return false;
|
|
if (fa.enum_values != fb.enum_values) return false;
|
|
}
|
|
}
|
|
for (size_t i = 0; i < pt1.relations.size(); ++i) {
|
|
const auto& a = pt1.relations[i];
|
|
const auto& b = pt2.relations[i];
|
|
if (a.name != b.name) return false;
|
|
if (a.color != b.color) return false;
|
|
if (a.style != b.style) return false;
|
|
}
|
|
return true;
|
|
};
|
|
|
|
int total_fields = 0;
|
|
for (const auto& e : pt1.entities) total_fields += (int)e.fields.size();
|
|
|
|
if (cmp()) {
|
|
std::fprintf(stdout,
|
|
"[test] PASS — %zu entities, %d fields, %zu relations (round-trip estable)\n",
|
|
pt1.entities.size(), total_fields, pt1.relations.size());
|
|
std::remove(tmp.c_str());
|
|
return 0;
|
|
}
|
|
std::fprintf(stderr,
|
|
"[test] FAIL — discrepancia tras round-trip. dump preservado en %s\n",
|
|
tmp.c_str());
|
|
return 1;
|
|
}
|
|
|
|
int main(int argc, char** argv) {
|
|
bool legacy_mode = false; // --input / positional dado: NO usar proyecto
|
|
std::string project_arg; // --project <slug> (puede estar vacio)
|
|
|
|
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;
|
|
legacy_mode = true;
|
|
} 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, "--project") == 0 && i + 1 < argc) {
|
|
project_arg = argv[++i];
|
|
} else if (std::strcmp(a, "--test-types-yaml") == 0 && i + 1 < argc) {
|
|
return test_types_yaml_roundtrip(argv[++i]);
|
|
} else if (std::strcmp(a, "--test-duckdb") == 0 && i + 1 < argc) {
|
|
const char* p = argv[++i];
|
|
if (!ge::tableview_smoke_test(p)) {
|
|
std::fprintf(stderr, "[duckdb] smoke test FAILED for %s\n", p);
|
|
return 2;
|
|
}
|
|
std::fprintf(stdout, "[duckdb] smoke test OK (SELECT 42 -> 42) on %s\n", p);
|
|
return 0;
|
|
} else if (std::strcmp(a, "--test-tableview") == 0 && i + 1 < argc) {
|
|
// Crea 1M filas en duckdb_path/people, cuenta y pagina.
|
|
const char* p = argv[++i];
|
|
std::remove(p); // empezar desde cero
|
|
duckdb_database db = nullptr;
|
|
duckdb_connection cn = nullptr;
|
|
if (duckdb_open(p, &db) == DuckDBError) { std::fprintf(stderr, "open fail\n"); return 2; }
|
|
duckdb_connect(db, &cn);
|
|
duckdb_result r;
|
|
if (duckdb_query(cn,
|
|
"CREATE TABLE people AS "
|
|
"SELECT range AS id, 'name_' || CAST(range AS VARCHAR) AS name, "
|
|
" (range * 7) % 100 AS age FROM range(1000000)", &r) == DuckDBError) {
|
|
std::fprintf(stderr, "create fail: %s\n",
|
|
duckdb_result_error(&r) ? duckdb_result_error(&r) : "?");
|
|
duckdb_destroy_result(&r);
|
|
duckdb_disconnect(&cn); duckdb_close(&db); return 2;
|
|
}
|
|
duckdb_destroy_result(&r);
|
|
duckdb_disconnect(&cn); duckdb_close(&db);
|
|
|
|
int64_t total = 0;
|
|
if (!ge::tableview_count(p, "people", nullptr, &total) || total != 1000000) {
|
|
std::fprintf(stderr, "[tableview_count] expected 1000000, got %lld\n",
|
|
(long long)total);
|
|
return 2;
|
|
}
|
|
std::vector<std::string> cols = { "name", "age" };
|
|
std::vector<ge::TablePageRow> page;
|
|
if (!ge::tableview_page(p, "people", "id", cols, nullptr,
|
|
nullptr, nullptr, 500000, 10, &page)) {
|
|
std::fprintf(stderr, "[tableview_page] failed\n");
|
|
return 2;
|
|
}
|
|
if (page.size() != 10) {
|
|
std::fprintf(stderr, "[tableview_page] expected 10 rows, got %zu\n",
|
|
page.size());
|
|
return 2;
|
|
}
|
|
std::fprintf(stdout,
|
|
"[tableview] OK — count=%lld, page[0]={id=%s, name=%s, age=%s}\n",
|
|
(long long)total, page[0].id.c_str(),
|
|
page[0].values.size() > 0 ? page[0].values[0].c_str() : "",
|
|
page[0].values.size() > 1 ? page[0].values[1].c_str() : "");
|
|
return 0;
|
|
} 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 (legacy)
|
|
if (g_input_path.empty()) {
|
|
g_input_path = a;
|
|
legacy_mode = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (legacy_mode) {
|
|
// Modo legacy: paths sueltos junto al exe (compat con flujo anterior)
|
|
ge::layout_store_open("graph_explorer.db");
|
|
g_layout_db_path = "graph_explorer.db";
|
|
if (!g_input_path.empty()) {
|
|
load_input();
|
|
}
|
|
} else {
|
|
// Modo proyecto: migra layout legacy si aplica, decide proyecto activo,
|
|
// crea default si no existe ninguno.
|
|
ge::projects_migrate_legacy_layout();
|
|
|
|
std::string target = project_arg;
|
|
if (target.empty()) {
|
|
ge::ProjectSettings ps;
|
|
ge::project_settings_load(&ps);
|
|
target = ps.last_active;
|
|
}
|
|
if (target.empty()) target = ge::k_default_project;
|
|
|
|
if (!ge::project_exists(target.c_str())) {
|
|
std::string err;
|
|
if (!ge::project_create(target.c_str(), &err)) {
|
|
std::fprintf(stderr,
|
|
"[graph_explorer] no se pudo crear el proyecto '%s': %s\n",
|
|
target.c_str(), err.c_str());
|
|
return 1;
|
|
}
|
|
std::fprintf(stdout,
|
|
"[graph_explorer] proyecto creado: projects/%s/\n", target.c_str());
|
|
}
|
|
|
|
apply_project_paths(target);
|
|
ge::layout_store_open(g_layout_db_path.c_str());
|
|
ge::project_settings_touch(target.c_str());
|
|
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;
|
|
}
|