Files
graph_explorer/main.cpp
T
egutierrez a36530bb6f feat: docking host + add-node toolbar + node context menu
- Dockspace host (PassthruCentralNode) bajo la toolbar para que las
  ventanas Viewport/Legend/Inspector/Stats puedan dockearse dentro de la
  app principal.
- Toolbar: input "Add node" con auto-deteccion de tipo (text/email/
  ip_address/url/domain/phone). Insert en operations.db + reload.
- Context menu (right-click sobre nodo): Change type, Duplicate, Delete,
  submenu "Run enricher" (placeholder hasta issues 0001-0003).
- Inspector: vecinos ahora muestran etiqueta de relacion ("-> employs",
  "<- owns") usando rel_types[].name como label de arista.
- Default relation label k_default_relation_name="RELATED_TO" para
  relaciones creadas sin nombre semantico explicito.
- Indice EntityIndex (FNV1a hash -> sql id) reconstruido tras cada load
  para resolver mutaciones desde el grafo en memoria.

Issues planteadas para iteraciones siguientes:
- 0001: chat con Claude sobre el grafo (HTTP + tool-use)
- 0002: enricher GLiNER+GLiREL desde nodo texto
- 0003: enricher web (fetch URL/dominio + extract text)
- 0004: vista tabla por tipo de entidad
2026-04-30 22:55:30 +02:00

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;
}