merge: issue/0049k-graph-explorer-app — graph_explorer app
Cierra issue 0049k. Activa feature flag osint_graph_v1 = true (en fn_registry).
This commit is contained in:
@@ -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()
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user