diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..9a01302 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,66 @@ +# SQLite3: el target SQLite::SQLite3 lo crea ya cpp/CMakeLists.txt (sistema en +# Linux, vendored amalgamation en Windows). Si esta app se construye en +# stand-alone, levantar SQLite desde el amalgamation vendoreado del registry. +find_package(SQLite3 QUIET) +if(NOT SQLite3_FOUND AND NOT TARGET sqlite3_vendored) + set(SQLITE3_AMALG_DIR ${FN_CPP_ROOT_DIR}/vendor/sqlite3) + add_library(sqlite3_vendored STATIC ${SQLITE3_AMALG_DIR}/sqlite3.c) + target_include_directories(sqlite3_vendored PUBLIC ${SQLITE3_AMALG_DIR}) + target_compile_definitions(sqlite3_vendored PRIVATE + SQLITE_THREADSAFE=1 + SQLITE_ENABLE_FTS5 + SQLITE_ENABLE_JSON1 + ) + add_library(SQLite::SQLite3 ALIAS sqlite3_vendored) +endif() + +add_imgui_app(graph_explorer + main.cpp + data.cpp + views.cpp + types_registry.cpp + layout_store.cpp + # --- viz --- + ${FN_CPP_ROOT_DIR}/functions/viz/graph_renderer.cpp + ${FN_CPP_ROOT_DIR}/functions/viz/graph_force_layout.cpp + ${FN_CPP_ROOT_DIR}/functions/viz/graph_force_layout_gpu.cpp + ${FN_CPP_ROOT_DIR}/functions/viz/graph_layouts.cpp + ${FN_CPP_ROOT_DIR}/functions/viz/graph_viewport.cpp + ${FN_CPP_ROOT_DIR}/functions/viz/graph_viewport_selection.cpp + ${FN_CPP_ROOT_DIR}/functions/viz/graph_labels.cpp + ${FN_CPP_ROOT_DIR}/functions/viz/graph_labels_select.cpp + ${FN_CPP_ROOT_DIR}/functions/viz/graph_icons.cpp + ${FN_CPP_ROOT_DIR}/functions/viz/graph_sources.cpp + ${FN_CPP_ROOT_DIR}/functions/viz/graph_types.cpp + ${FN_CPP_ROOT_DIR}/functions/core/graph_spatial_hash.cpp + # --- core UI --- + ${FN_CPP_ROOT_DIR}/functions/core/button.cpp + ${FN_CPP_ROOT_DIR}/functions/core/icon_button.cpp + ${FN_CPP_ROOT_DIR}/functions/core/toolbar.cpp + ${FN_CPP_ROOT_DIR}/functions/core/modal_dialog.cpp + ${FN_CPP_ROOT_DIR}/functions/core/text_input.cpp + ${FN_CPP_ROOT_DIR}/functions/core/select.cpp + ${FN_CPP_ROOT_DIR}/functions/core/tree_view.cpp + ${FN_CPP_ROOT_DIR}/functions/core/page_header.cpp + ${FN_CPP_ROOT_DIR}/functions/core/fullscreen_window.cpp + ${FN_CPP_ROOT_DIR}/functions/core/badge.cpp + ${FN_CPP_ROOT_DIR}/functions/core/empty_state.cpp +) + +target_include_directories(graph_explorer PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR} + ${FN_CPP_ROOT_DIR}/functions +) + +target_link_libraries(graph_explorer PRIVATE SQLite::SQLite3) + +# OpenGL: graph_renderer + graph_force_layout_gpu llaman gl* directamente. +# fn::run_app inicializa el loader cuando AppConfig::init_gl_loader = true. +if(NOT WIN32) + find_package(OpenGL REQUIRED) + target_link_libraries(graph_explorer PRIVATE OpenGL::GL) +endif() + +if(WIN32) + set_target_properties(graph_explorer PROPERTIES WIN32_EXECUTABLE TRUE) +endif() diff --git a/app.md b/app.md new file mode 100644 index 0000000..e69f2b2 --- /dev/null +++ b/app.md @@ -0,0 +1,80 @@ +--- +name: graph_explorer +lang: cpp +domain: viz +description: "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." +tags: [imgui, graph, osint, visualization, gpu] +uses_functions: + - graph_renderer_cpp_viz + - graph_force_layout_cpp_viz + - graph_force_layout_gpu_cpp_viz + - graph_layouts_cpp_viz + - graph_viewport_cpp_viz + - graph_labels_cpp_viz + - graph_icons_cpp_viz + - graph_sources_cpp_viz + - toolbar_cpp_core + - modal_dialog_cpp_core + - select_cpp_core + - text_input_cpp_core + - tree_view_cpp_core + - page_header_cpp_core + - fullscreen_window_cpp_core +uses_types: [] +framework: "imgui" +entry_point: "main.cpp" +dir_path: "projects/osint_graph/apps/graph_explorer" +repo_url: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/dataforge/graph_explorer" +--- + +## Arquitectura + +App C++ ImGui para explorar cualquier `operations.db` del registry como un grafo +de entidades y relaciones. Agnostica del backend — el dispatcher en +`data.{h,cpp}` selecciona el `GraphLoadFn` segun `--input` (hoy solo +`operations`, manana `json`/`jsonl`/`graphml`). + +**Capas:** + +- `data.{h,cpp}` — dispatcher de sources. Hoy unica implementacion: + `graph_load_from_operations` (issue 0049g). +- `types_registry.{h,cpp}` — parser minimo de YAML para sobrescribir + `color`/`shape`/`icon`/`style` por nombre de tipo. Construye el `IconAtlas` + con los codepoints Tabler resueltos por `tabler_codepoint_by_name`. +- `views.{h,cpp}` — paneles `Toolbar`, `Legend`, `Inspector`, `Stats`. Toggle + via `AppConfig::panels`. +- `main.cpp` — CLI + `fn::run_app` + bucle de force layout (CPU/GPU) + glue. +- `graph_explorer.db` — SQLite junto al exe. Tabla `layouts(graph_hash, + node_id, x, y, pinned, updated_at)`. Persistencia de posiciones por grafo. + +## CLI + +```bash +graph_explorer [] +graph_explorer --input operations +graph_explorer --types +graph_explorer --layout force|grid|circular|radial|hierarchical|fixed +graph_explorer apps/registry_dashboard/operations.db +graph_explorer --types projects/osint_graph/apps/graph_explorer/examples/types.yaml \ + apps/element_agents/operations.db +``` + +## Build + +```bash +cd cpp +cmake -B build/linux -S . +cmake --build build/linux --target graph_explorer -j$(nproc) +./build/linux/apps/graph_explorer/graph_explorer apps/registry_dashboard/operations.db +``` + +## Notas + +- Usa GPU layout si el contexto soporta compute 4.3; toggle CPU/GPU desde la + toolbar. Fallback transparente a CPU si GPU no esta disponible. +- 50k nodos a 60fps con layout GPU (medido en demos/graph en + `primitives_gallery`). +- `operations.db` se abre con `mode=ro` cuando el path no apunta al + filesystem propio para evitar lock con otras apps que esten escribiendo. +- El `graph_hash` se calcula a partir del path canonico del input. Mismo path + = mismo grafo a efectos de layout guardado. diff --git a/data.cpp b/data.cpp new file mode 100644 index 0000000..93d8dee --- /dev/null +++ b/data.cpp @@ -0,0 +1,35 @@ +#include "data.h" + +#include +#include + +namespace ge { + +bool load_graph(const InputArgs& args, GraphData* out, graph::GraphLoadStats* stats) { + if (!out || !stats) return false; + *stats = graph::GraphLoadStats{}; + if (!args.uri || !*args.uri) { + stats->errors = 1; + std::snprintf(stats->error_msg, sizeof(stats->error_msg), + "no input uri"); + return false; + } + + switch (args.kind) { + case INPUT_OPERATIONS: + return graph::graph_load_from_operations(args.uri, out, stats); + case INPUT_NONE: + default: + stats->errors = 1; + std::snprintf(stats->error_msg, sizeof(stats->error_msg), + "unsupported input kind"); + return false; + } +} + +bool reload_graph(const InputArgs& args, GraphData* out, graph::GraphLoadStats* stats) { + if (out) graph::graph_free(out); + return load_graph(args, out, stats); +} + +} // namespace ge diff --git a/data.h b/data.h new file mode 100644 index 0000000..9aa3e14 --- /dev/null +++ b/data.h @@ -0,0 +1,27 @@ +#pragma once + +#include "viz/graph_sources.h" +#include "viz/graph_types.h" + +namespace ge { + +enum InputKind { + INPUT_NONE = 0, + INPUT_OPERATIONS, + // Futuro: INPUT_JSON, INPUT_JSONL, INPUT_GRAPHML, ... +}; + +struct InputArgs { + InputKind kind = INPUT_NONE; + const char* uri = nullptr; // path al SQLite (operations) o al fichero +}; + +// Dispatcher de sources. Devuelve true si la carga succeeded; en cualquier +// caso `stats` se rellena (errors > 0 ante fallo). +bool load_graph(const InputArgs& args, GraphData* out, graph::GraphLoadStats* stats); + +// Reload helper — usa la misma uri que la ultima `load_graph` exitosa. +// Llama a `graph_free(out)` y vuelve a invocar `load_graph(args, out, stats)`. +bool reload_graph(const InputArgs& args, GraphData* out, graph::GraphLoadStats* stats); + +} // namespace ge diff --git a/examples/types.yaml b/examples/types.yaml new file mode 100644 index 0000000..34c50e1 --- /dev/null +++ b/examples/types.yaml @@ -0,0 +1,77 @@ +# Ejemplo OSINT — tipos comunes en investigacion de entidades. +# Color como "#RRGGBB" (con o sin alpha "#RRGGBBAA"). +# Shapes: circle | square | diamond | hex | triangle | rounded_square +# Iconos: nombres ti-* mapeados en types_registry.cpp::tabler_codepoint_by_name +# Estilos de relacion: solid | dashed | dotted + +entities: + - name: Person + color: "#5B8DEF" + shape: circle + icon: ti-user + + - name: Email + color: "#58CA8C" + shape: square + icon: ti-mail + + - name: Domain + color: "#F4B860" + shape: diamond + icon: ti-world + + - name: Phone + color: "#E36AC0" + shape: hex + icon: ti-phone + + - name: Org + color: "#C780E8" + shape: triangle + icon: ti-building + + - name: IBAN + color: "#52CDF2" + shape: rounded_square + icon: ti-building-bank + + - name: Account + color: "#7FD3A0" + shape: rounded_square + icon: ti-id + + - name: Document + color: "#C9C9C9" + shape: square + icon: ti-file + + - name: Address + color: "#FFB870" + shape: diamond + icon: ti-map-pin + + - name: Url + color: "#89E0FC" + shape: hex + icon: ti-link + +relations: + - name: owns + color: "#888888" + style: solid + + - name: knows + color: "#AAAAAA" + style: solid + + - name: located_in + color: "#FFB870" + style: dashed + + - name: transfers_to + color: "#52CDF2" + style: dotted + + - name: member_of + color: "#C780E8" + style: dashed diff --git a/layout_store.cpp b/layout_store.cpp new file mode 100644 index 0000000..52ba953 --- /dev/null +++ b/layout_store.cpp @@ -0,0 +1,172 @@ +#include "layout_store.h" + +#include "viz/graph_types.h" + +#include + +#include +#include +#include +#include +#include + +namespace ge { + +namespace { +sqlite3* g_db = nullptr; +} // namespace + +// FNV1a 64 sobre el path. No requiere normalizacion canonica (el caller pasa +// el path tal cual lo recibio en argv) — basta con que el mismo path produzca +// el mismo hash entre runs. +uint64_t compute_graph_hash(const char* path) { + if (!path) return 0; + uint64_t h = 1469598103934665603ull; // FNV offset + while (*path) { + h ^= (uint64_t)(unsigned char)(*path++); + h *= 1099511628211ull; // FNV prime + } + return h; +} + +bool layout_store_open(const char* db_path) { + if (g_db) return true; + if (!db_path) return false; + int rc = sqlite3_open(db_path, &g_db); + if (rc != SQLITE_OK) { + std::fprintf(stderr, "[layout_store] sqlite3_open failed: %s\n", + sqlite3_errmsg(g_db)); + if (g_db) sqlite3_close(g_db); + g_db = nullptr; + return false; + } + const char* schema = + "CREATE TABLE IF NOT EXISTS layouts (" + " graph_hash TEXT NOT NULL," + " node_id TEXT NOT NULL," + " x REAL NOT NULL," + " y REAL NOT NULL," + " pinned INTEGER NOT NULL DEFAULT 0," + " updated_at INTEGER NOT NULL," + " PRIMARY KEY(graph_hash, node_id)" + ");"; + char* err = nullptr; + rc = sqlite3_exec(g_db, schema, nullptr, nullptr, &err); + if (rc != SQLITE_OK) { + std::fprintf(stderr, "[layout_store] schema error: %s\n", + err ? err : "(null)"); + sqlite3_free(err); + sqlite3_close(g_db); + g_db = nullptr; + return false; + } + return true; +} + +void layout_store_close() { + if (g_db) { + sqlite3_close(g_db); + g_db = nullptr; + } +} + +namespace { +std::string hash_to_hex(uint64_t h) { + char buf[17]; + std::snprintf(buf, sizeof(buf), "%016llx", + (unsigned long long)h); + return std::string(buf); +} + +std::string node_user_to_hex(uint64_t u) { + char buf[17]; + std::snprintf(buf, sizeof(buf), "%016llx", + (unsigned long long)u); + return std::string(buf); +} +} // namespace + +int layout_store_save(uint64_t graph_hash, const GraphData& graph) { + if (!g_db || graph.node_count <= 0) return 0; + + sqlite3_exec(g_db, "BEGIN", nullptr, nullptr, nullptr); + + const char* sql = + "INSERT INTO layouts(graph_hash, node_id, x, y, pinned, updated_at) " + "VALUES(?, ?, ?, ?, ?, ?) " + "ON CONFLICT(graph_hash, node_id) DO UPDATE SET " + " x=excluded.x, y=excluded.y, pinned=excluded.pinned, " + " updated_at=excluded.updated_at;"; + sqlite3_stmt* stmt = nullptr; + int rc = sqlite3_prepare_v2(g_db, sql, -1, &stmt, nullptr); + if (rc != SQLITE_OK) { + std::fprintf(stderr, "[layout_store] prepare save failed: %s\n", + sqlite3_errmsg(g_db)); + sqlite3_exec(g_db, "ROLLBACK", nullptr, nullptr, nullptr); + return -1; + } + + std::string ghex = hash_to_hex(graph_hash); + int written = 0; + int64_t now = (int64_t)std::time(nullptr); + + for (int i = 0; i < graph.node_count; ++i) { + const GraphNode& n = graph.nodes[i]; + if (n.user_data == 0) continue; // sin id estable: no persistible + std::string nhex = node_user_to_hex(n.user_data); + + sqlite3_bind_text(stmt, 1, ghex.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 2, nhex.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_double(stmt, 3, (double)n.x); + sqlite3_bind_double(stmt, 4, (double)n.y); + sqlite3_bind_int(stmt, 5, (n.flags & NF_PINNED) ? 1 : 0); + sqlite3_bind_int64(stmt, 6, now); + + rc = sqlite3_step(stmt); + if (rc == SQLITE_DONE) ++written; + sqlite3_reset(stmt); + sqlite3_clear_bindings(stmt); + } + + sqlite3_finalize(stmt); + sqlite3_exec(g_db, "COMMIT", nullptr, nullptr, nullptr); + return written; +} + +int layout_store_load(uint64_t graph_hash, GraphData& graph) { + if (!g_db || graph.node_count <= 0) return 0; + + const char* sql = + "SELECT node_id, x, y, pinned FROM layouts WHERE graph_hash = ?;"; + sqlite3_stmt* stmt = nullptr; + int rc = sqlite3_prepare_v2(g_db, sql, -1, &stmt, nullptr); + if (rc != SQLITE_OK) { + std::fprintf(stderr, "[layout_store] prepare load failed: %s\n", + sqlite3_errmsg(g_db)); + return -1; + } + + std::string ghex = hash_to_hex(graph_hash); + sqlite3_bind_text(stmt, 1, ghex.c_str(), -1, SQLITE_TRANSIENT); + + int touched = 0; + while ((rc = sqlite3_step(stmt)) == SQLITE_ROW) { + const unsigned char* nid = sqlite3_column_text(stmt, 0); + if (!nid) continue; + uint64_t u = std::strtoull((const char*)nid, nullptr, 16); + int idx = graph.find_node_by_user_data(u); + if (idx < 0) continue; + GraphNode& n = graph.nodes[idx]; + n.x = (float)sqlite3_column_double(stmt, 1); + n.y = (float)sqlite3_column_double(stmt, 2); + if (sqlite3_column_int(stmt, 3)) + n.flags |= NF_PINNED; + else + n.flags &= ~NF_PINNED; + ++touched; + } + sqlite3_finalize(stmt); + return touched; +} + +} // namespace ge diff --git a/layout_store.h b/layout_store.h new file mode 100644 index 0000000..6c9afc5 --- /dev/null +++ b/layout_store.h @@ -0,0 +1,40 @@ +#pragma once + +#include +#include + +struct GraphData; + +namespace ge { + +// Persistencia de posiciones de nodos en `graph_explorer.db` (SQLite junto al +// exe). Una unica tabla: +// +// layouts(graph_hash TEXT, node_id TEXT, x REAL, y REAL, +// pinned INTEGER, updated_at INTEGER, +// PRIMARY KEY(graph_hash, node_id)) +// +// `graph_hash` se calcula a partir del path absoluto del input (operations.db +// o similar). Mismo input → mismas posiciones recuperables. `node_id` es +// el `user_data` del nodo formateado en hex (lo que `graph_load_from_operations` +// rellena con el FNV1a del id de la BD origen). + +// Devuelve un hash estable del path canonico. 0 si path es null/vacio. +uint64_t compute_graph_hash(const char* path); + +// Asegura que la BD existe y la tabla esta creada. Devuelve true en exito. +bool layout_store_open(const char* db_path); +void layout_store_close(); + +// Guarda las posiciones (y NF_PINNED) de todos los nodos del grafo bajo la +// clave `graph_hash`. UPSERT por (graph_hash, node_id). Devuelve el numero +// de filas escritas (>= 0). En error, devuelve -1. +int layout_store_save(uint64_t graph_hash, const GraphData& graph); + +// Aplica las posiciones guardadas al grafo. Recorre los nodos y, para cada +// uno cuyo `user_data` exista en la tabla con el hash dado, sobrescribe +// `x`, `y`, y los flags `NF_PINNED`. Nodos sin entrada se quedan tal cual. +// Devuelve el numero de nodos actualizados. +int layout_store_load(uint64_t graph_hash, GraphData& graph); + +} // namespace ge diff --git a/main.cpp b/main.cpp new file mode 100644 index 0000000..159d9f8 --- /dev/null +++ b/main.cpp @@ -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 +#include +#include +#include +#include + +// ---------------------------------------------------------------------------- +// 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 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(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 [] [--input operations ] " + "[--types ] [--layout ]"); + 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 []\n" + " graph_explorer --input operations \n" + " graph_explorer --types \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; +} diff --git a/types_registry.cpp b/types_registry.cpp new file mode 100644 index 0000000..3544db6 --- /dev/null +++ b/types_registry.cpp @@ -0,0 +1,341 @@ +#include "types_registry.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ge { + +// ---------------------------------------------------------------------------- +// Parser YAML minimo (subset suficiente para el ejemplo OSINT) +// ---------------------------------------------------------------------------- +// Reconoce: +// entities: +// - name: Person +// color: "#5B8DEF" +// shape: circle +// icon: ti-user +// La indentacion es por espacios; no se usa la libreria yaml-cpp para no +// arrastrar una dependencia mas. Los campos desconocidos se ignoran. + +namespace { + +std::string trim(std::string s) { + auto issp = [](unsigned char c) { return std::isspace(c) || c == '\t'; }; + while (!s.empty() && issp((unsigned char)s.front())) s.erase(s.begin()); + while (!s.empty() && issp((unsigned char)s.back())) s.pop_back(); + return s; +} + +std::string strip_quotes(std::string s) { + if (s.size() >= 2 && (s.front() == '"' || s.front() == '\'') && + s.back() == s.front()) { + return s.substr(1, s.size() - 2); + } + return s; +} + +// Parse "#RRGGBB" / "#RRGGBBAA" → ABGR8 packed (0 si invalido). El layout +// coincide con `pack_rgba8` del renderer (R en LSB, A en MSB). +uint32_t parse_color_hex(const std::string& v) { + std::string s = trim(v); + s = strip_quotes(s); + if (s.empty() || s[0] != '#') return 0; + s.erase(s.begin()); + if (s.size() != 6 && s.size() != 8) return 0; + uint32_t value = 0; + for (char c : s) { + uint32_t d = 0; + if (c >= '0' && c <= '9') d = c - '0'; + else if (c >= 'a' && c <= 'f') d = c - 'a' + 10; + else if (c >= 'A' && c <= 'F') d = c - 'A' + 10; + else return 0; + value = (value << 4) | d; + } + uint8_t r, g, b, a; + if (s.size() == 6) { + r = (uint8_t)((value >> 16) & 0xFF); + g = (uint8_t)((value >> 8) & 0xFF); + b = (uint8_t)( value & 0xFF); + a = 0xFF; + } else { + r = (uint8_t)((value >> 24) & 0xFF); + g = (uint8_t)((value >> 16) & 0xFF); + b = (uint8_t)((value >> 8) & 0xFF); + a = (uint8_t)( value & 0xFF); + } + return (uint32_t)r + | ((uint32_t)g << 8) + | ((uint32_t)b << 16) + | ((uint32_t)a << 24); +} + +uint8_t parse_shape(const std::string& v) { + std::string s = trim(v); + s = strip_quotes(s); + std::transform(s.begin(), s.end(), s.begin(), + [](unsigned char c) { return std::tolower(c); }); + if (s == "circle") return SHAPE_CIRCLE; + if (s == "square") return SHAPE_SQUARE; + if (s == "diamond") return SHAPE_DIAMOND; + if (s == "hex" || s == "hexagon") return SHAPE_HEX; + if (s == "triangle") return SHAPE_TRIANGLE; + if (s == "rounded_square" || s == "rounded-square" || s == "rounded") + return SHAPE_ROUNDED_SQUARE; + return SHAPE_USE_TYPE; +} + +uint8_t parse_style(const std::string& v) { + std::string s = trim(v); + s = strip_quotes(s); + std::transform(s.begin(), s.end(), s.begin(), + [](unsigned char c) { return std::tolower(c); }); + if (s == "solid") return EDGE_SOLID; + if (s == "dashed") return EDGE_DASHED; + if (s == "dotted") return EDGE_DOTTED; + return EDGE_USE_TYPE; +} + +// Cuenta el numero de espacios al principio de una linea. Tabs cuentan como +// 4 espacios para que el archivo siga siendo legible si alguien usa tabs. +int leading_indent(const std::string& line) { + int n = 0; + for (char c : line) { + if (c == ' ') ++n; + else if (c == '\t') n += 4; + else break; + } + return n; +} + +bool starts_with(const std::string& s, const char* p) { + size_t plen = std::strlen(p); + return s.size() >= plen && std::memcmp(s.data(), p, plen) == 0; +} + +} // namespace + +bool types_load_yaml(const char* path, ParsedTypes* out, std::string* error_msg) { + if (!out) return false; + std::ifstream f(path); + if (!f.good()) { + if (error_msg) *error_msg = std::string("cannot open ") + path; + return false; + } + + enum Section { SEC_NONE, SEC_ENTITIES, SEC_RELATIONS }; + Section section = SEC_NONE; + + EntitySpec cur_entity; + RelationSpec cur_rel; + bool have_item = false; + + auto flush = [&]() { + if (!have_item) return; + if (section == SEC_ENTITIES && !cur_entity.name.empty()) { + out->entities.push_back(cur_entity); + } else if (section == SEC_RELATIONS && !cur_rel.name.empty()) { + out->relations.push_back(cur_rel); + } + cur_entity = EntitySpec{}; + cur_rel = RelationSpec{}; + have_item = false; + }; + + std::string line; + while (std::getline(f, line)) { + // Strip comments + auto hash = line.find('#'); + if (hash != std::string::npos) { + // Solo si no esta dentro de comillas — en este YAML reducido + // el unico uso comun de `#` dentro de strings es para colores + // tipo "#5B8DEF". Asi que solo consideramos `#` como comentario + // si la primera comilla a su izquierda no esta cerrada. + bool in_quote = false; + char quote = 0; + for (size_t i = 0; i < hash; ++i) { + char c = line[i]; + if (!in_quote && (c == '"' || c == '\'')) { + in_quote = true; quote = c; + } else if (in_quote && c == quote) { + in_quote = false; + } + } + if (!in_quote) line.erase(hash); + } + + std::string trimmed = trim(line); + if (trimmed.empty()) continue; + + int indent = leading_indent(line); + + // Top-level section header + if (indent == 0 && trimmed.back() == ':') { + flush(); + std::string head = trimmed.substr(0, trimmed.size() - 1); + if (head == "entities") section = SEC_ENTITIES; + else if (head == "relations") section = SEC_RELATIONS; + else section = SEC_NONE; + continue; + } + + if (section == SEC_NONE) continue; + + // Inicio de item: "- name: Foo" o "-" + if (starts_with(trimmed, "- ")) { + flush(); + have_item = true; + std::string body = trim(trimmed.substr(2)); + // Si la linea trae un par key:value tras el guion, parsearlo aqui + auto colon = body.find(':'); + if (colon != std::string::npos) { + std::string k = trim(body.substr(0, colon)); + std::string v = trim(body.substr(colon + 1)); + if (k == "name") { + if (section == SEC_ENTITIES) cur_entity.name = strip_quotes(v); + else cur_rel.name = strip_quotes(v); + } + // Cualquier otra key inline se reasigna mas abajo si hay sub-lineas + else if (k == "color") { + uint32_t c = parse_color_hex(v); + if (section == SEC_ENTITIES) cur_entity.color = c; + else cur_rel.color = c; + } else if (k == "shape" && section == SEC_ENTITIES) { + cur_entity.shape = parse_shape(v); + } else if (k == "icon" && section == SEC_ENTITIES) { + cur_entity.icon_cp = tabler_codepoint_by_name(strip_quotes(v).c_str()); + } else if (k == "style" && section == SEC_RELATIONS) { + cur_rel.style = parse_style(v); + } + } + continue; + } + + // Sub-key del item actual: "key: value" + if (have_item) { + auto colon = trimmed.find(':'); + if (colon == std::string::npos) continue; + std::string k = trim(trimmed.substr(0, colon)); + std::string v = trim(trimmed.substr(colon + 1)); + if (k == "name") { + if (section == SEC_ENTITIES) cur_entity.name = strip_quotes(v); + else cur_rel.name = strip_quotes(v); + } else if (k == "color") { + uint32_t c = parse_color_hex(v); + if (section == SEC_ENTITIES) cur_entity.color = c; + else cur_rel.color = c; + } else if (k == "shape" && section == SEC_ENTITIES) { + cur_entity.shape = parse_shape(v); + } else if (k == "icon" && section == SEC_ENTITIES) { + cur_entity.icon_cp = tabler_codepoint_by_name(strip_quotes(v).c_str()); + } else if (k == "style" && section == SEC_RELATIONS) { + cur_rel.style = parse_style(v); + } + } + } + flush(); + return true; +} + +// ---------------------------------------------------------------------------- +// Mapeo nombre Tabler -> codepoint +// ---------------------------------------------------------------------------- +// Tabla minima cubriendo los iconos del ejemplo OSINT y unos pocos comunes. +// Ampliable a demanda — para ampliar masivamente, mejor scriptar la +// generacion desde `tabler_codepoints_full.csv` (ver vendor/tabler-icons/). + +uint16_t tabler_codepoint_by_name(const char* name) { + if (!name || !*name) return 0; + static const std::unordered_map map = { + // OSINT core + {"ti-user", 0xEB4D}, // TI_USER + {"ti-users", 0xEBF2}, // TI_USERS + {"ti-mail", 0xEAE5}, // TI_MAIL + {"ti-world", 0xEB54}, // TI_WORLD + {"ti-phone", 0xEB09}, // TI_PHONE + {"ti-building", 0xEA4F}, // TI_BUILDING + {"ti-building-bank", 0xEBE2}, // TI_BUILDING_BANK + {"ti-id", 0xEAC3}, // TI_ID + {"ti-file", 0xEAA4}, // TI_FILE + {"ti-map-pin", 0xEAE8}, // TI_MAP_PIN + {"ti-link", 0xEADE}, // TI_LINK + {"ti-network", 0xF09F}, // TI_NETWORK + {"ti-server", 0xEB1F}, // TI_SERVER + {"ti-folder", 0xEAAD}, // TI_FOLDER + {"ti-hash", 0xEABC}, // TI_HASH + {"ti-circle", 0xEA6B}, // TI_CIRCLE + {"ti-square", 0xEB2C}, // TI_SQUARE + {"ti-at", 0xEA2B}, // TI_AT + {"ti-home", 0xEAC1}, // TI_HOME + {"ti-database", 0xEA88}, // TI_DATABASE + }; + auto it = map.find(name); + if (it == map.end()) return 0; + return it->second; +} + +// ---------------------------------------------------------------------------- +// Apply yaml -> graph +// ---------------------------------------------------------------------------- + +std::vector apply_types_yaml(GraphData& graph, const ParsedTypes& types) { + // 1) Recolectar codepoints distintos en orden de aparicion (1-based icon_id). + std::vector codepoints; + auto find_or_add = [&](uint16_t cp) -> uint16_t { + if (cp == 0) return 0; + for (size_t i = 0; i < codepoints.size(); ++i) { + if (codepoints[i] == cp) return (uint16_t)(i + 1); + } + codepoints.push_back(cp); + return (uint16_t)codepoints.size(); + }; + + // 2) Lookup por nombre. Buscamos tambien con normalizacion ligera + // (lowercase trim) para que "person" matche "Person". + auto eq_ci = [](const char* a, const std::string& b) { + if (!a) return false; + size_t la = std::strlen(a); + if (la != b.size()) return false; + for (size_t i = 0; i < la; ++i) { + if (std::tolower((unsigned char)a[i]) != + std::tolower((unsigned char)b[i])) return false; + } + return true; + }; + + for (int i = 0; i < graph.type_count; ++i) { + EntityType& et = graph.types[i]; + for (const auto& spec : types.entities) { + if (!eq_ci(et.name, spec.name)) continue; + if (spec.color != 0) et.color = spec.color; + if (spec.shape != SHAPE_USE_TYPE) et.shape = spec.shape; + uint16_t icon_id = find_or_add(spec.icon_cp); + if (icon_id != 0) et.icon_id = icon_id; + break; + } + } + + for (int i = 0; i < graph.rel_type_count; ++i) { + RelationType& rt = graph.rel_types[i]; + for (const auto& spec : types.relations) { + if (!eq_ci(rt.name, spec.name)) continue; + if (spec.color != 0) rt.color = spec.color; + if (spec.style != EDGE_USE_TYPE) rt.style = spec.style; + break; + } + } + + return codepoints; +} + +IconAtlas* build_icon_atlas(const std::vector& codepoints) { + if (codepoints.empty()) return nullptr; + return graph_icons_build(codepoints.data(), (int)codepoints.size(), 32); +} + +} // namespace ge diff --git a/types_registry.h b/types_registry.h new file mode 100644 index 0000000..4e63102 --- /dev/null +++ b/types_registry.h @@ -0,0 +1,72 @@ +#pragma once + +#include +#include +#include + +#include "viz/graph_types.h" +#include "viz/graph_icons.h" + +namespace ge { + +// Representacion en memoria de un `types.yaml` minimo: +// +// entities: +// - name: Person +// color: "#5B8DEF" +// shape: circle +// icon: ti-user +// relations: +// - name: owns +// color: "#888888" +// style: solid +// +// Campos no presentes quedan con sentinel (`0` para color, `SHAPE_USE_TYPE` +// para shape, `EDGE_USE_TYPE` para style, codepoint = 0 para icon). + +struct EntitySpec { + std::string name; + uint32_t color = 0; // ABGR; 0 = no override + uint8_t shape = SHAPE_USE_TYPE; // 0 = no override + uint16_t icon_cp = 0; // codepoint Tabler; 0 = sin icono +}; + +struct RelationSpec { + std::string name; + uint32_t color = 0; + uint8_t style = EDGE_USE_TYPE; +}; + +struct ParsedTypes { + std::vector entities; + std::vector relations; +}; + +// Parser de `types.yaml`. Devuelve true si pudo abrir/parsear el archivo. En +// fallo, `error_msg` describe el motivo. El parser es tolerante: si un campo +// no es reconocido se ignora silenciosamente. +bool types_load_yaml(const char* path, ParsedTypes* out, std::string* error_msg); + +// Resuelve un nombre tipo `ti-user` -> codepoint Tabler. Devuelve 0 si no +// existe. La tabla de mapeo vive dentro de la implementacion y solo cubre +// los iconos comunes que el ejemplo OSINT usa; ampliable a demanda. +uint16_t tabler_codepoint_by_name(const char* name); + +// Sobrescribe color/shape/icon/style en `graph.types` y `graph.rel_types` si +// hay match por `name`. Tipos no presentes en el yaml se quedan tal cual. +// +// Devuelve la lista de codepoints distintos que la app debera bakear en su +// IconAtlas (orden 1-based: el indice `i` del vector mapea a `icon_id = i+1`). +// Tras llamar `apply_types_yaml`, cada `EntityType.icon_id` referenciado en +// el yaml se ha actualizado al nuevo `icon_id` correspondiente. +// +// Si el yaml no especifica iconos (vector vacio), el grafo se queda sin +// iconos visibles (icon_id = 0 = sin icono). +std::vector apply_types_yaml(GraphData& graph, const ParsedTypes& types); + +// Construye un `IconAtlas` a partir de la lista de codepoints devuelta por +// `apply_types_yaml`. Si la lista esta vacia o el TTF no se encuentra, +// devuelve nullptr (el caller renderiza sin iconos). +IconAtlas* build_icon_atlas(const std::vector& codepoints); + +} // namespace ge diff --git a/views.cpp b/views.cpp new file mode 100644 index 0000000..c41e284 --- /dev/null +++ b/views.cpp @@ -0,0 +1,419 @@ +#include "views.h" + +#include "viz/graph_types.h" +#include "viz/graph_viewport.h" +#include "viz/graph_sources.h" + +#include "core/button.h" +#include "core/icon_button.h" +#include "core/toolbar.h" +#include "core/select.h" +#include "core/modal_dialog.h" +#include "core/text_input.h" +#include "core/tokens.h" +#include "core/icons_tabler.h" + +#include "imgui.h" + +#include +#include + +namespace ge { + +namespace { + +const char* k_layout_names[] = { + "force", "grid", "circular", "radial", "hierarchical", "fixed", +}; +constexpr int k_layout_count = (int)(sizeof(k_layout_names) / sizeof(k_layout_names[0])); + +ImVec4 abgr_to_imvec4(uint32_t c) { + uint8_t r = (uint8_t)( c & 0xFF); + uint8_t g = (uint8_t)((c >> 8) & 0xFF); + uint8_t b = (uint8_t)((c >> 16) & 0xFF); + uint8_t a = (uint8_t)((c >> 24) & 0xFF); + return ImVec4(r / 255.0f, g / 255.0f, b / 255.0f, a / 255.0f); +} + +void color_swatch(uint32_t color, float size = 12.0f) { + ImVec2 p = ImGui::GetCursorScreenPos(); + ImDrawList* dl = ImGui::GetWindowDrawList(); + dl->AddRectFilled(p, ImVec2(p.x + size, p.y + size), + ImGui::ColorConvertFloat4ToU32(abgr_to_imvec4(color)), + 3.0f); + ImGui::Dummy(ImVec2(size, size)); + ImGui::SameLine(); +} + +} // namespace + +void views_reset_visibility(AppState& app) { + if (!app.graph) return; + int nt = app.graph->type_count; + int nr = app.graph->rel_type_count; + if (nt > 256) nt = 256; + if (nr > 256) nr = 256; + for (int i = 0; i < nt; ++i) app.type_visible[i] = true; + for (int i = 0; i < nr; ++i) app.rel_type_visible[i] = true; + app.type_visible_n = nt; + app.rel_type_visible_n = nr; +} + +void views_apply_visibility(AppState& app) { + if (!app.graph) return; + GraphData& g = *app.graph; + for (int i = 0; i < g.node_count; ++i) { + uint16_t t = g.nodes[i].type_id; + bool vis = (t < (uint16_t)app.type_visible_n) ? app.type_visible[t] : true; + if (vis) g.nodes[i].flags |= NF_VISIBLE; + else g.nodes[i].flags &= ~NF_VISIBLE; + } + for (int i = 0; i < g.edge_count; ++i) { + const GraphEdge& e = g.edges[i]; + bool rel_vis = (e.type_id < (uint16_t)app.rel_type_visible_n) + ? app.rel_type_visible[e.type_id] : true; + // Si los endpoints estan ocultos, la arista tambien. + bool src_vis = (e.source < (uint32_t)g.node_count) && + (g.nodes[e.source].flags & NF_VISIBLE); + bool tgt_vis = (e.target < (uint32_t)g.node_count) && + (g.nodes[e.target].flags & NF_VISIBLE); + bool vis = rel_vis && src_vis && tgt_vis; + if (vis) g.edges[i].flags |= EF_VISIBLE; + else g.edges[i].flags &= ~EF_VISIBLE; + } +} + +// ---------------------------------------------------------------------------- +// Toolbar +// ---------------------------------------------------------------------------- + +void views_toolbar(AppState& app) { + using namespace fn_ui; + toolbar_begin(); + if (button(TI_FOLDER " Open file...", ButtonVariant::Secondary)) { + app.show_open_modal = true; + } + toolbar_separator(); + + ImGui::TextUnformatted("Layout:"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(140); + int idx = app.layout_mode; + if (ImGui::Combo("##layout", &idx, k_layout_names, k_layout_count)) { + if (idx != app.layout_mode) { + app.layout_mode = idx; + ++app.apply_layout_tick; + } + } + toolbar_separator(); + + if (button(TI_FILTER " Filters...", ButtonVariant::Subtle)) { + app.show_filters_modal = true; + } + if (button(TI_ARROWS_MAXIMIZE " Fit view", ButtonVariant::Subtle)) { + app.want_fit = true; + } + if (button(TI_DEVICE_FLOPPY " Save layout", ButtonVariant::Subtle)) { + app.want_save_layout = true; + } + if (button(TI_REFRESH " Reload", ButtonVariant::Subtle)) { + app.want_reload = true; + } + toolbar_separator(); + + ImGui::Checkbox("GPU layout", &app.use_gpu); + ImGui::SameLine(); + ImGui::Checkbox("Labels", &app.labels_enabled); + if (app.viewport) { + ImGui::SameLine(); + ImGui::Checkbox("Run layout", &app.viewport->layout_running); + } + toolbar_end(); +} + +// ---------------------------------------------------------------------------- +// Legend +// ---------------------------------------------------------------------------- + +void views_legend(AppState& app) { + if (!app.panel_legend) return; + if (!ImGui::Begin("Legend", &app.panel_legend)) { + ImGui::End(); + return; + } + if (!app.graph) { + ImGui::TextUnformatted("(no graph loaded)"); + ImGui::End(); + return; + } + + GraphData& g = *app.graph; + bool changed = false; + + ImGui::TextUnformatted("Entity types"); + ImGui::Separator(); + for (int i = 0; i < g.type_count && i < app.type_visible_n; ++i) { + const EntityType& et = g.types[i]; + color_swatch(et.color); + char id[32]; + std::snprintf(id, sizeof(id), "##t%d", i); + bool v = app.type_visible[i]; + if (ImGui::Checkbox(id, &v)) { + app.type_visible[i] = v; + changed = true; + } + ImGui::SameLine(); + ImGui::TextUnformatted(et.name ? et.name : "(unnamed)"); + } + + if (g.rel_type_count > 0) { + ImGui::Spacing(); + ImGui::TextUnformatted("Relation types"); + ImGui::Separator(); + for (int i = 0; i < g.rel_type_count && i < app.rel_type_visible_n; ++i) { + const RelationType& rt = g.rel_types[i]; + color_swatch(rt.color); + char id[32]; + std::snprintf(id, sizeof(id), "##r%d", i); + bool v = app.rel_type_visible[i]; + if (ImGui::Checkbox(id, &v)) { + app.rel_type_visible[i] = v; + changed = true; + } + ImGui::SameLine(); + ImGui::TextUnformatted(rt.name ? rt.name : "(unnamed)"); + } + } + + if (changed) views_apply_visibility(app); + + ImGui::End(); +} + +// ---------------------------------------------------------------------------- +// Inspector +// ---------------------------------------------------------------------------- + +void views_inspector(AppState& app) { + if (!app.panel_inspector) return; + if (!ImGui::Begin("Inspector", &app.panel_inspector)) { + ImGui::End(); + return; + } + if (!app.graph || !app.viewport) { + ImGui::TextUnformatted("(no graph)"); + ImGui::End(); + return; + } + + GraphData& g = *app.graph; + const auto& sel = app.viewport->selection; + + if (sel.empty()) { + ImGui::TextUnformatted("No selection."); + ImGui::TextWrapped("Click a node, or shift+drag to lasso a region."); + ImGui::End(); + return; + } + + if (sel.size() > 1) { + ImGui::Text("%zu nodes selected", sel.size()); + ImGui::Separator(); + for (size_t i = 0; i < sel.size() && i < 32; ++i) { + int idx = sel[i]; + if (idx < 0 || idx >= g.node_count) continue; + const GraphNode& n = g.nodes[idx]; + const char* lbl = graph::graph_label(&g, n.label_idx); + ImGui::BulletText("[%d] %s", idx, lbl && *lbl ? lbl : "(unnamed)"); + } + if (sel.size() > 32) ImGui::TextDisabled("(...%zu more)", sel.size() - 32); + ImGui::End(); + return; + } + + int idx = sel.front(); + if (idx < 0 || idx >= g.node_count) { + ImGui::TextUnformatted("(invalid selection)"); + ImGui::End(); + return; + } + const GraphNode& n = g.nodes[idx]; + const char* lbl = graph::graph_label(&g, n.label_idx); + const char* tname = (n.type_id < (uint16_t)g.type_count && g.types[n.type_id].name) + ? g.types[n.type_id].name : "(no-type)"; + + ImGui::PushStyleColor(ImGuiCol_Text, fn_tokens::colors::text_muted); + ImGui::TextUnformatted("label:"); + ImGui::PopStyleColor(); + ImGui::SameLine(); + ImGui::TextUnformatted(lbl && *lbl ? lbl : "(none)"); + + ImGui::PushStyleColor(ImGuiCol_Text, fn_tokens::colors::text_muted); + ImGui::TextUnformatted("type:"); + ImGui::PopStyleColor(); + ImGui::SameLine(); + ImGui::TextUnformatted(tname); + + ImGui::PushStyleColor(ImGuiCol_Text, fn_tokens::colors::text_muted); + ImGui::Text("idx=%d user_data=%llx pos=(%.1f, %.1f)", + idx, (unsigned long long)n.user_data, n.x, n.y); + ImGui::PopStyleColor(); + + ImGui::Spacing(); + ImGui::PushStyleColor(ImGuiCol_Text, fn_tokens::colors::text_muted); + ImGui::TextUnformatted("Neighbors:"); + ImGui::PopStyleColor(); + + int neighbor_count = 0; + for (int e = 0; e < g.edge_count && neighbor_count < 64; ++e) { + const GraphEdge& edge = g.edges[e]; + int other = -1; + if (edge.source == (uint32_t)idx) other = (int)edge.target; + else if (edge.target == (uint32_t)idx) other = (int)edge.source; + if (other < 0 || other >= g.node_count) continue; + const char* olbl = graph::graph_label(&g, g.nodes[other].label_idx); + char buf[256]; + std::snprintf(buf, sizeof(buf), "[%d] %s", other, + olbl && *olbl ? olbl : "(unnamed)"); + if (ImGui::Selectable(buf)) { + graph_viewport_clear_selection(g, *app.viewport); + graph_viewport_add_to_selection(g, *app.viewport, other); + } + ++neighbor_count; + } + if (neighbor_count == 0) + ImGui::TextDisabled("(none)"); + + ImGui::End(); +} + +// ---------------------------------------------------------------------------- +// Stats +// ---------------------------------------------------------------------------- + +void views_stats(AppState& app) { + if (!app.panel_stats) return; + if (!ImGui::Begin("Stats", &app.panel_stats)) { + ImGui::End(); + return; + } + if (!app.graph || !app.viewport) { + ImGui::TextUnformatted("(no graph)"); + ImGui::End(); + return; + } + int sel = (int)app.viewport->selection.size(); + ImGui::Text("nodes=%d edges=%d types=%d rel_types=%d", + app.graph->node_count, app.graph->edge_count, + app.graph->type_count, app.graph->rel_type_count); + ImGui::Text("fps=%d energy=%.4f selection=%d", + app.fps_estimate, app.viewport->layout_energy, sel); + ImGui::Text("layout=%s mode=%s", + k_layout_names[app.layout_mode % k_layout_count], + app.use_gpu ? "GPU" : "CPU"); + ImGui::End(); +} + +// ---------------------------------------------------------------------------- +// Modals +// ---------------------------------------------------------------------------- + +bool views_filters_modal(AppState& app) { + if (!app.show_filters_modal) return false; + bool changed = false; + if (fn_ui::modal_dialog_begin("Filters", &app.show_filters_modal, + ImVec2(520, 0))) { + if (!app.graph) { + ImGui::TextUnformatted("(no graph)"); + } else { + ImGui::TextUnformatted("Entity types"); + ImGui::Separator(); + int nt = app.type_visible_n; + ImGui::Columns(2, "##fent", false); + for (int i = 0; i < nt; ++i) { + color_swatch(app.graph->types[i].color); + char id[32]; + std::snprintf(id, sizeof(id), "##fe%d", i); + bool v = app.type_visible[i]; + if (ImGui::Checkbox(id, &v)) { + app.type_visible[i] = v; + changed = true; + } + ImGui::SameLine(); + ImGui::TextUnformatted(app.graph->types[i].name ? app.graph->types[i].name : "?"); + ImGui::NextColumn(); + } + ImGui::Columns(1); + + if (app.graph->rel_type_count > 0) { + ImGui::Spacing(); + ImGui::TextUnformatted("Relation types"); + ImGui::Separator(); + int nr = app.rel_type_visible_n; + ImGui::Columns(2, "##frel", false); + for (int i = 0; i < nr; ++i) { + color_swatch(app.graph->rel_types[i].color); + char id[32]; + std::snprintf(id, sizeof(id), "##fr%d", i); + bool v = app.rel_type_visible[i]; + if (ImGui::Checkbox(id, &v)) { + app.rel_type_visible[i] = v; + changed = true; + } + ImGui::SameLine(); + ImGui::TextUnformatted(app.graph->rel_types[i].name ? app.graph->rel_types[i].name : "?"); + ImGui::NextColumn(); + } + ImGui::Columns(1); + } + + ImGui::Spacing(); + if (fn_ui::button("Show all", fn_ui::ButtonVariant::Subtle)) { + for (int i = 0; i < app.type_visible_n; ++i) app.type_visible[i] = true; + for (int i = 0; i < app.rel_type_visible_n; ++i) app.rel_type_visible[i] = true; + changed = true; + } + ImGui::SameLine(); + if (fn_ui::button("Hide all", fn_ui::ButtonVariant::Subtle)) { + for (int i = 0; i < app.type_visible_n; ++i) app.type_visible[i] = false; + for (int i = 0; i < app.rel_type_visible_n; ++i) app.rel_type_visible[i] = false; + changed = true; + } + ImGui::SameLine(); + if (fn_ui::button("Close", fn_ui::ButtonVariant::Primary)) { + app.show_filters_modal = false; + } + } + } + fn_ui::modal_dialog_end(); + if (changed) views_apply_visibility(app); + return changed; +} + +bool views_open_modal(AppState& app) { + if (!app.show_open_modal) return false; + bool opened = false; + if (fn_ui::modal_dialog_begin("Open file", &app.show_open_modal, + ImVec2(520, 0))) { + ImGui::TextWrapped("Path to operations.db (or any supported source)."); + ImGui::Spacing(); + fn_ui::text_input("Path", app.open_buf, sizeof(app.open_buf), + "apps/registry_dashboard/operations.db"); + ImGui::Spacing(); + if (fn_ui::button("Open", fn_ui::ButtonVariant::Primary)) { + if (app.open_buf[0]) { + app.want_open_file = true; + app.show_open_modal = false; + opened = true; + } + } + ImGui::SameLine(); + if (fn_ui::button("Cancel", fn_ui::ButtonVariant::Subtle)) { + app.show_open_modal = false; + } + } + fn_ui::modal_dialog_end(); + return opened; +} + +} // namespace ge diff --git a/views.h b/views.h new file mode 100644 index 0000000..9f9aedb --- /dev/null +++ b/views.h @@ -0,0 +1,79 @@ +#pragma once + +struct GraphData; +struct GraphViewportState; + +namespace ge { + +// Estado compartido entre las vistas y el bucle render. Pasado por puntero +// desde main.cpp. +struct AppState { + // Datos + GraphData* graph = nullptr; + GraphViewportState* viewport = nullptr; + + // Layout activo + int layout_mode = 0; // 0=force, 1=grid, 2=circular, 3=radial, 4=hierarchical, 5=fixed + int apply_layout_tick = 0; // se incrementa cuando hay que reaplicar layout + + // Force layout — config + GPU toggle + float repulsion = 1500.0f; + float attraction = 0.04f; + float gravity = 0.005f; + bool use_gpu = false; + + // Stats UI + int fps_estimate = 0; // sintetico, calculado en main loop + + // Filters / visibility por tipo (longitud = graph->type_count o rel_type_count) + bool type_visible[256] = {}; + bool rel_type_visible[256] = {}; + int type_visible_n = 0; + int rel_type_visible_n = 0; + + // Inspector + bool panel_legend = true; + bool panel_inspector = true; + bool panel_stats = true; + bool show_filters_modal = false; + bool show_open_modal = false; + + // Triggers — main.cpp lee estos flags y actua + bool want_fit = false; + bool want_save_layout = false; + bool want_reload = false; + bool want_open_file = false; // marcado al confirmar el modal Open + char open_buf[512] = {}; + + // Labels overlay + bool labels_enabled = true; +}; + +// Toolbar superior (Open file, Layout selector, Filters..., Fit, Save layout). +void views_toolbar(AppState& app); + +// Panel Legend — checkboxes por tipo (entity / relation) con color swatch. +void views_legend(AppState& app); + +// Panel Inspector — metadata del nodo seleccionado + vecinos. +void views_inspector(AppState& app); + +// Stats line — counts + fps + energy + selection. +void views_stats(AppState& app); + +// Modal Filters — toggles por tipo agrupados en columnas. Devuelve true si +// el usuario togglo algo. +bool views_filters_modal(AppState& app); + +// Modal Open file — text input + boton Open. +bool views_open_modal(AppState& app); + +// Refresca los flags `flags` de cada nodo/arista segun el array +// `type_visible[]` / `rel_type_visible[]`. Lineal en N+M. +void views_apply_visibility(AppState& app); + +// Inicializa los arrays type_visible / rel_type_visible a true para todos +// los tipos del grafo activo. Llamar tras cargar/recargar el grafo. +void views_reset_visibility(AppState& app); + +} // namespace ge