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:
2026-04-30 00:13:59 +02:00
parent 11bf6f94cd
commit b767b5b85e
12 changed files with 1873 additions and 0 deletions
+66
View File
@@ -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()
+80
View File
@@ -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 [<operations.db>]
graph_explorer --input operations <path>
graph_explorer --types <yaml>
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.
+35
View File
@@ -0,0 +1,35 @@
#include "data.h"
#include <cstdio>
#include <cstring>
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
+27
View File
@@ -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
+77
View File
@@ -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
+172
View File
@@ -0,0 +1,172 @@
#include "layout_store.h"
#include "viz/graph_types.h"
#include <sqlite3.h>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <ctime>
#include <string>
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
+40
View File
@@ -0,0 +1,40 @@
#pragma once
#include <cstdint>
#include <string>
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
+465
View File
@@ -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;
}
+341
View File
@@ -0,0 +1,341 @@
#include "types_registry.h"
#include <algorithm>
#include <cctype>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <fstream>
#include <sstream>
#include <unordered_map>
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<std::string, uint16_t> 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<uint16_t> apply_types_yaml(GraphData& graph, const ParsedTypes& types) {
// 1) Recolectar codepoints distintos en orden de aparicion (1-based icon_id).
std::vector<uint16_t> 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<uint16_t>& codepoints) {
if (codepoints.empty()) return nullptr;
return graph_icons_build(codepoints.data(), (int)codepoints.size(), 32);
}
} // namespace ge
+72
View File
@@ -0,0 +1,72 @@
#pragma once
#include <cstdint>
#include <string>
#include <vector>
#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<EntitySpec> entities;
std::vector<RelationSpec> 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<uint16_t> 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<uint16_t>& codepoints);
} // namespace ge
+419
View File
@@ -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 <cstdio>
#include <cstring>
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
+79
View File
@@ -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