feat: graph_explorer app — agnostic operations.db viewer (issue 0049k)
App C++ ImGui que abre cualquier operations.db del registry y lo visualiza
como grafo con shapes/iconos/layouts/filtros/labels.
Composicion del registry:
- viz/graph_renderer + graph_force_layout(_gpu) + graph_layouts +
graph_viewport + graph_labels + graph_icons + graph_sources
- core: toolbar, modal_dialog, select, text_input, tree_view, page_header,
fullscreen_window, button, badge, empty_state
Capas:
- data.{h,cpp} — dispatcher GraphLoadFn (operations hoy; json/graphml manana).
- types_registry.{h,cpp} — parser YAML minimal + tabler_codepoint_by_name +
apply_types_yaml + IconAtlas builder.
- views.{h,cpp} — Toolbar, Legend, Inspector, Stats, modal Filters/Open.
- layout_store.{h,cpp} — graph_explorer.db SQLite con tabla layouts(graph_hash,
node_id, x, y, pinned, updated_at). UPSERT por nodo.
- main.cpp — CLI (--input/--types/--layout) + fn::run_app + bucle
force layout (CPU/GPU toggle) + render con 3 columnas (Legend / Viewport /
Inspector+Stats).
examples/types.yaml: 10 entidades OSINT (Person/Email/Domain/Phone/Org/IBAN/
Account/Document/Address/Url) + 5 relaciones (owns/knows/located_in/
transfers_to/member_of) con shapes Tabler reales.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,465 @@
|
||||
#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 <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;
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 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();
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 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[] = {
|
||||
{"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;
|
||||
}
|
||||
|
||||
// Toolbar superior — usa una ventana sin scroll y sin titulo
|
||||
ImGuiViewport* vp = ImGui::GetMainViewport();
|
||||
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();
|
||||
}
|
||||
|
||||
// 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));
|
||||
}
|
||||
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::NextColumn();
|
||||
|
||||
// Col der: Inspector + Stats
|
||||
ge::views_inspector(g_app);
|
||||
ge::views_stats(g_app);
|
||||
ImGui::NextColumn();
|
||||
|
||||
ImGui::Columns(1);
|
||||
ImGui::End();
|
||||
|
||||
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");
|
||||
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user