merge: quick/iter1 — docking + add-node + context menu + issues

This commit is contained in:
2026-04-30 22:55:34 +02:00
10 changed files with 844 additions and 48 deletions
+1
View File
@@ -20,6 +20,7 @@ add_imgui_app(graph_explorer
views.cpp
types_registry.cpp
layout_store.cpp
entity_ops.cpp
# --- viz ---
${FN_CPP_ROOT_DIR}/functions/viz/graph_renderer.cpp
${FN_CPP_ROOT_DIR}/functions/viz/graph_force_layout.cpp
+322
View File
@@ -0,0 +1,322 @@
#include "entity_ops.h"
#include "../../../../cpp/vendor/sqlite3/sqlite3.h"
#include <chrono>
#include <cstdio>
#include <cstring>
#include <cstdlib>
#include <cctype>
namespace ge {
// ----------------------------------------------------------------------------
// FNV1a-64 — debe coincidir con graph_sources.cpp
// ----------------------------------------------------------------------------
static uint64_t fnv1a64(const char* s) {
uint64_t h = 1469598103934665603ULL;
for (; s && *s; ++s) {
h ^= (uint8_t)*s;
h *= 1099511628211ULL;
}
return h;
}
// ----------------------------------------------------------------------------
// Heuristicas — sin <regex> para evitar peso. Inspeccion lineal del string.
// ----------------------------------------------------------------------------
static bool is_email(const char* s) {
if (!s || !*s) return false;
const char* at = std::strchr(s, '@');
if (!at || at == s) return false;
if (std::strchr(at + 1, '@')) return false; // dos @
const char* dot = std::strchr(at + 1, '.');
if (!dot || dot[1] == 0) return false;
if (std::strchr(s, ' ')) return false;
return true;
}
static bool is_ipv4(const char* s) {
if (!s || !*s) return false;
int parts = 0, digits = 0, n = 0;
for (const char* p = s; ; ++p) {
if (*p >= '0' && *p <= '9') {
n = n * 10 + (*p - '0');
if (++digits > 3 || n > 255) return false;
} else if (*p == '.' || *p == 0) {
if (digits == 0) return false;
++parts; digits = 0; n = 0;
if (*p == 0) break;
} else {
return false;
}
}
return parts == 4;
}
static bool is_url(const char* s) {
if (!s) return false;
return std::strncmp(s, "http://", 7) == 0 || std::strncmp(s, "https://", 8) == 0;
}
static bool is_domain(const char* s) {
if (!s || !*s) return false;
if (std::strchr(s, ' ') || std::strchr(s, '@') || std::strchr(s, '/')) return false;
const char* dot = std::strchr(s, '.');
if (!dot || dot == s || dot[1] == 0) return false;
// El TLD debe ser al menos 2 caracteres alfabeticos
int tld = 0;
for (const char* p = std::strrchr(s, '.') + 1; *p; ++p) {
if (!std::isalpha((unsigned char)*p)) return false;
++tld;
}
return tld >= 2;
}
static bool is_phone(const char* s) {
if (!s || !*s) return false;
int digits = 0;
for (const char* p = s; *p; ++p) {
if (*p >= '0' && *p <= '9') ++digits;
else if (*p == '+' || *p == ' ' || *p == '-' || *p == '(' || *p == ')' || *p == '.') {}
else return false;
}
return digits >= 7 && digits <= 15;
}
DetectedType detect_type(const char* text) {
if (!text || !*text) return DT_TEXT;
if (is_email(text)) return DT_EMAIL;
if (is_ipv4(text)) return DT_IP_ADDRESS;
if (is_url(text)) return DT_URL;
if (is_phone(text)) return DT_PHONE; // antes que domain (numeros con puntos)
if (is_domain(text)) return DT_DOMAIN;
return DT_TEXT;
}
const char* detected_type_name(DetectedType dt) {
switch (dt) {
case DT_EMAIL: return "email";
case DT_IP_ADDRESS: return "ip_address";
case DT_URL: return "url";
case DT_DOMAIN: return "domain";
case DT_PHONE: return "phone";
case DT_TEXT:
default: return "text";
}
}
// ----------------------------------------------------------------------------
// SQLite helpers
// ----------------------------------------------------------------------------
static bool exec_one(sqlite3* db, const char* sql,
const char** params, int n_params)
{
sqlite3_stmt* st = nullptr;
if (sqlite3_prepare_v2(db, sql, -1, &st, nullptr) != SQLITE_OK) return false;
for (int i = 0; i < n_params; ++i) {
sqlite3_bind_text(st, i + 1, params[i] ? params[i] : "", -1, SQLITE_TRANSIENT);
}
int rc = sqlite3_step(st);
sqlite3_finalize(st);
return rc == SQLITE_DONE;
}
static std::string now_iso() {
using namespace std::chrono;
auto t = system_clock::to_time_t(system_clock::now());
char buf[32];
std::strftime(buf, sizeof(buf), "%Y-%m-%dT%H:%M:%S", std::gmtime(&t));
return std::string(buf) + "Z";
}
static long long now_ms() {
using namespace std::chrono;
return duration_cast<milliseconds>(system_clock::now().time_since_epoch()).count();
}
// ----------------------------------------------------------------------------
// CRUD
// ----------------------------------------------------------------------------
bool entity_insert(const char* db_path, const char* name, const char* type_ref,
char* out_id, size_t out_id_n)
{
if (!db_path || !name || !*name || !out_id || out_id_n < 32) return false;
std::string tref;
if (type_ref && *type_ref) {
tref = type_ref;
} else {
tref = detected_type_name(detect_type(name));
}
std::snprintf(out_id, out_id_n, "%s_%lld", tref.c_str(), now_ms());
std::string ts = now_iso();
sqlite3* db = nullptr;
if (sqlite3_open_v2(db_path, &db, SQLITE_OPEN_READWRITE, nullptr) != SQLITE_OK) {
if (db) sqlite3_close(db);
return false;
}
const char* sql =
"INSERT INTO entities (id, name, type_ref, source, created_at, updated_at) "
"VALUES (?, ?, ?, 'manual', ?, ?)";
const char* params[5] = { out_id, name, tref.c_str(), ts.c_str(), ts.c_str() };
bool ok = exec_one(db, sql, params, 5);
sqlite3_close(db);
return ok;
}
bool entity_delete(const char* db_path, const char* id) {
if (!db_path || !id) return false;
sqlite3* db = nullptr;
if (sqlite3_open_v2(db_path, &db, SQLITE_OPEN_READWRITE, nullptr) != SQLITE_OK) {
if (db) sqlite3_close(db);
return false;
}
const char* p_rel[2] = { id, id };
const char* p_ent[1] = { id };
exec_one(db, "DELETE FROM relations WHERE from_entity = ? OR to_entity = ?", p_rel, 2);
bool ok = exec_one(db, "DELETE FROM entities WHERE id = ?", p_ent, 1);
sqlite3_close(db);
return ok;
}
bool entity_update_type(const char* db_path, const char* id, const char* new_type) {
if (!db_path || !id || !new_type) return false;
sqlite3* db = nullptr;
if (sqlite3_open_v2(db_path, &db, SQLITE_OPEN_READWRITE, nullptr) != SQLITE_OK) {
if (db) sqlite3_close(db);
return false;
}
std::string ts = now_iso();
const char* p[3] = { new_type, ts.c_str(), id };
bool ok = exec_one(db, "UPDATE entities SET type_ref = ?, updated_at = ? WHERE id = ?", p, 3);
sqlite3_close(db);
return ok;
}
bool entity_duplicate(const char* db_path, const char* id,
char* out_id, size_t out_id_n)
{
if (!db_path || !id || !out_id || out_id_n < 32) return false;
sqlite3* db = nullptr;
if (sqlite3_open_v2(db_path, &db, SQLITE_OPEN_READWRITE, nullptr) != SQLITE_OK) {
if (db) sqlite3_close(db);
return false;
}
sqlite3_stmt* st = nullptr;
const char* sel = "SELECT name, type_ref, description, domain, tags, source, metadata, notes "
"FROM entities WHERE id = ?";
if (sqlite3_prepare_v2(db, sel, -1, &st, nullptr) != SQLITE_OK) {
sqlite3_close(db);
return false;
}
sqlite3_bind_text(st, 1, id, -1, SQLITE_TRANSIENT);
if (sqlite3_step(st) != SQLITE_ROW) {
sqlite3_finalize(st);
sqlite3_close(db);
return false;
}
auto col = [&](int i) {
const unsigned char* p = sqlite3_column_text(st, i);
return std::string(p ? (const char*)p : "");
};
std::string name = col(0) + " (copia)";
std::string tref = col(1);
std::string desc = col(2);
std::string dom = col(3);
std::string tags = col(4);
std::string src = col(5);
std::string meta = col(6);
std::string notes = col(7);
sqlite3_finalize(st);
std::snprintf(out_id, out_id_n, "%s_%lld", tref.c_str(), now_ms());
std::string ts = now_iso();
const char* ins =
"INSERT INTO entities (id, name, type_ref, description, domain, tags, source, "
"metadata, notes, created_at, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
sqlite3_stmt* ist = nullptr;
if (sqlite3_prepare_v2(db, ins, -1, &ist, nullptr) != SQLITE_OK) {
sqlite3_close(db);
return false;
}
sqlite3_bind_text(ist, 1, out_id, -1, SQLITE_TRANSIENT);
sqlite3_bind_text(ist, 2, name.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(ist, 3, tref.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(ist, 4, desc.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(ist, 5, dom.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(ist, 6, tags.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(ist, 7, src.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(ist, 8, meta.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(ist, 9, notes.c_str(),-1, SQLITE_TRANSIENT);
sqlite3_bind_text(ist, 10, ts.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(ist, 11, ts.c_str(), -1, SQLITE_TRANSIENT);
bool ok = sqlite3_step(ist) == SQLITE_DONE;
sqlite3_finalize(ist);
sqlite3_close(db);
return ok;
}
bool relation_insert(const char* db_path, const char* from_id, const char* to_id,
const char* name)
{
if (!db_path || !from_id || !to_id) return false;
const char* rel = (name && *name) ? name : k_default_relation_name;
sqlite3* db = nullptr;
if (sqlite3_open_v2(db_path, &db, SQLITE_OPEN_READWRITE, nullptr) != SQLITE_OK) {
if (db) sqlite3_close(db);
return false;
}
char id[64];
std::snprintf(id, sizeof(id), "rel_%lld", now_ms());
std::string ts = now_iso();
const char* sql =
"INSERT INTO relations (id, name, from_entity, to_entity, created_at, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?)";
const char* params[6] = { id, rel, from_id, to_id, ts.c_str(), ts.c_str() };
bool ok = exec_one(db, sql, params, 6);
sqlite3_close(db);
return ok;
}
// ----------------------------------------------------------------------------
// Index user_data -> sql id
// ----------------------------------------------------------------------------
bool entity_index_build(const char* db_path, EntityIndex* idx) {
if (!db_path || !idx) return false;
idx->by_hash.clear();
sqlite3* db = nullptr;
if (sqlite3_open_v2(db_path, &db, SQLITE_OPEN_READONLY, nullptr) != SQLITE_OK) {
if (db) sqlite3_close(db);
return false;
}
sqlite3_stmt* st = nullptr;
if (sqlite3_prepare_v2(db, "SELECT id FROM entities", -1, &st, nullptr) != SQLITE_OK) {
sqlite3_close(db);
return false;
}
while (sqlite3_step(st) == SQLITE_ROW) {
const unsigned char* p = sqlite3_column_text(st, 0);
if (!p) continue;
const char* id = (const char*)p;
idx->by_hash.emplace(fnv1a64(id), std::string(id));
}
sqlite3_finalize(st);
sqlite3_close(db);
return true;
}
const char* entity_index_lookup(const EntityIndex& idx, uint64_t user_data) {
auto it = idx.by_hash.find(user_data);
return (it == idx.by_hash.end()) ? nullptr : it->second.c_str();
}
} // namespace ge
+66
View File
@@ -0,0 +1,66 @@
#pragma once
#include <cstdint>
#include <cstddef>
#include <string>
#include <unordered_map>
// Operaciones CRUD sobre operations.db (entities + relations) y deteccion
// heuristica de tipo a partir de texto libre. Pensado para que la toolbar y
// el menu contextual del viewport puedan modificar el grafo y luego pedir
// reload (issue 0049g flow).
//
// Convencion edge labels: si el caller no pasa nombre de relacion, se usa
// k_default_relation_name = "RELATED_TO". Los enrichers deben pasar siempre
// un nombre semantico (ej: "EXTRACTED_FROM", "RESOLVES_TO", ...).
namespace ge {
constexpr const char* k_default_relation_name = "RELATED_TO";
enum DetectedType {
DT_TEXT = 0,
DT_EMAIL,
DT_IP_ADDRESS,
DT_URL,
DT_DOMAIN,
DT_PHONE,
};
DetectedType detect_type(const char* text);
const char* detected_type_name(DetectedType dt);
// Inserta una entidad nueva. Si type_ref es NULL/vacio se infiere via
// detect_type(name). Genera un id unico ("<type>_<unix_ms>"). Devuelve el id
// en out_id (caller-owned buffer >= 64). Retorna false si SQLite falla.
bool entity_insert(const char* db_path,
const char* name,
const char* type_ref,
char* out_id, size_t out_id_n);
bool entity_delete(const char* db_path, const char* id);
bool entity_update_type(const char* db_path, const char* id, const char* new_type);
// Duplica una entidad existente. Mismo type/metadata, sufijo "_copy" en id
// y "(copia)" en name. Devuelve el nuevo id en out_id.
bool entity_duplicate(const char* db_path, const char* id,
char* out_id, size_t out_id_n);
// Inserta una relacion. Si name es NULL/vacio usa k_default_relation_name.
bool relation_insert(const char* db_path,
const char* from_id, const char* to_id,
const char* name);
// Mapa user_data (FNV1a hash) -> sql id. Se reconstruye despues de cada
// carga del grafo (graph_sources usa FNV1a sobre id como user_data).
struct EntityIndex {
std::unordered_map<uint64_t, std::string> by_hash;
};
// Escanea operations.db y rellena el indice. Reentrante (clear+repoblar).
bool entity_index_build(const char* db_path, EntityIndex* idx);
// Resuelve user_data a sql id. NULL si no existe.
const char* entity_index_lookup(const EntityIndex& idx, uint64_t user_data);
} // namespace ge
+48
View File
@@ -0,0 +1,48 @@
---
id: 0001
title: Chat con Claude sobre el grafo
status: pending
priority: high
created: 2026-04-30
---
## Objetivo
Panel "Chat" dentro de graph_explorer que permita conversar con Claude
(Anthropic API) usando el grafo activo como contexto. El agente debe poder:
- Leer el grafo (entidades, relaciones, metadata) via tool-use sobre operations.db.
- Responder preguntas tipo: "muestrame los nodos relacionados con X", "que
patrones ves en estas conexiones", "que falta investigar".
- Proponer mutaciones (crear nodo, etiquetar relacion) que el usuario aprueba
con un click antes de aplicarse.
## Alcance tecnico
- Cliente HTTP minimo (libcurl o WinHTTP) → POST a `https://api.anthropic.com/v1/messages`.
- Modelo por defecto: `claude-sonnet-4-6` (revisar al implementar).
- API key desde env var `ANTHROPIC_API_KEY` o `~/.fn_anthropic_key`.
- Tool-use: definir tools `query_entities`, `query_relations`, `propose_node`,
`propose_relation`. Las "propose_*" no mutan: insertan en una cola que el
usuario revisa antes de aplicar.
- Estado de conversacion en memoria (lista de messages). Persistencia opcional
en `graph_explorer.db` tabla `chat_sessions`.
- Streaming SSE para feedback en vivo (puede dejarse para v2 — primer hit
bloqueante esta bien).
## Decisiones a tomar
- Renderizado de markdown en ImGui (TextWrapped basico vs lib externa).
- Threading: bloqueante en hilo aparte → cola de mensajes → main thread lee.
## Trabajo previo
Ya existe en el registry `python/functions/agents/anthropic_chat_py_agents.py`
para inspiracion (usa el SDK Python). En C++ usaremos HTTP directo — sin SDK.
## Definicion de hecho
- Panel "Chat" dockeable.
- Conversacion con tool-use sobre operations.db funciona.
- Las mutaciones propuestas por el agente se confirman desde la UI antes de
llegar a la BD.
+54
View File
@@ -0,0 +1,54 @@
---
id: 0002
title: Enricher GLiNER + GLiREL — emitir entidades/relaciones desde un nodo texto
status: pending
priority: high
created: 2026-04-30
---
## Objetivo
Right-click sobre un nodo de tipo `text` → "Run enricher → Extract entities
(GLiNER+GLiREL)". El enricher procesa el texto del nodo y crea:
- Nuevas entidades (person, org, email, location, ...) con tipos detectados.
- Relaciones entre el nodo origen y las nuevas, etiquetadas con `EXTRACTED_FROM`.
- Relaciones entre las nuevas entidades cuando GLiREL las detecte, etiquetadas
con el tipo predicho por el modelo (`employed_by`, `located_in`, ...).
## Trabajo previo en el registry
Ya existen las funciones Python:
- `python/functions/extraction/gliner_extract_*` (varios)
- `python/functions/extraction/glirel_extract_*`
- Pipeline `extract_graph_hybrid_py_pipelines` (issue 0040 cerrado, ver commit
1a353878) que ya hace exactamente esto sobre un texto y devuelve un grafo
estructurado.
El analysis `analysis/retrieving_graphs/` lo usa en notebooks.
## Alcance tecnico (C++ side)
- Definir interfaz de enricher: `enricher_run(node_id, db_path) -> int n_added`.
- Implementacion `enricher_gliner_glirel`:
- Spawn `python/.venv/bin/python3` con un script wrapper que recibe el texto
por stdin (JSON `{"text": "..."}`) y devuelve por stdout el grafo
estructurado (JSON `{"entities": [...], "relations": [...]}`).
- Wrapper Python en `projects/osint_graph/apps/graph_explorer/enrichers/gliner_glirel.py`.
- C++ usa `popen` o `CreateProcess` segun plataforma.
- Insertar las entidades nuevas (entity_insert) y relaciones (relation_insert)
con etiquetas semanticas.
- UI: spinner en el menu mientras corre (cold start del modelo ~5s).
## Riesgos / decisiones
- Modelos pesados → cold start lento. Considerar pre-cargar al primer uso.
- Streaming de progreso desde Python via stderr line-by-line.
- Fallback si el venv no existe: mostrar mensaje en el menu en vez de fallar.
## Definicion de hecho
- Click derecho en nodo `text` → "Extract entities" muestra opcion.
- Tras correr, el grafo se recarga con las nuevas entidades visibles y
conectadas con `EXTRACTED_FROM`.
- Las relaciones entre entidades extraidas llevan el tipo que GLiREL predice.
+38
View File
@@ -0,0 +1,38 @@
---
id: 0003
title: Enricher web — descargar URL/dominio y extraer texto
status: pending
priority: medium
created: 2026-04-30
---
## Objetivo
Right-click sobre un nodo `url` o `domain` → "Run enricher → Fetch & extract
text". Descarga el HTML, extrae el texto principal, crea un nodo `text`
conectado al origen con relacion `FETCHED_FROM`.
Despues el usuario puede encadenar: sobre ese nodo `text`, ejecutar el enricher
GLiNER+GLiREL (issue 0002) para extraer entidades.
## Alcance
- HTTP GET con timeout (libcurl o sys WinHTTP).
- Extraccion de texto: regex/strip de tags simple en v1; v2 usa una lib
(htmlparser2 / lexbor / boost.url + algo de heuristica).
- User-agent identificativo, respeto de robots.txt opcional (out-of-scope v1).
- Limite de tamaño descargable (1 MB) para evitar bloqueos.
## Modelo de etiquetado
- Nodo origen (url/domain) → arista `FETCHED_FROM` → nodo nuevo (text con
metadata={fetched_at, status_code, content_type, length}).
- Nombre del nodo text: titulo de la pagina (si <title> existe) o primeros
120 caracteres del cuerpo.
## Definicion de hecho
- Funciona contra una URL real (https con TLS).
- Maneja errores (404, timeout, redirects basicos) sin tumbar la app.
- El nodo creado es visible y el texto se puede consumir por el enricher
GLiNER+GLiREL del issue 0002.
+26
View File
@@ -0,0 +1,26 @@
---
id: 0004
title: Vista tabla — entidades agrupadas por tipo
status: pending
priority: medium
created: 2026-04-30
---
## Objetivo
Ventana "Table" dockeable con una tabla por cada tipo de entidad presente en el
grafo. Filas = entidades. Columnas: id, name, status, updated_at, neighbors
count. Clickar una fila selecciona el nodo en el viewport.
## UI
- ImGui::BeginTable con sorting + clipper para >10k filas.
- Tabs en la cabecera de la ventana, una tab por type_ref ordenado alfabetico.
- Selector global "show all types" que apila todos en una sola tabla.
## Definicion de hecho
- Tabla escala a 10k entidades sin lag perceptible.
- Click en fila selecciona nodo en viewport (mismo flujo que Inspector
Selectable).
- Filtro de busqueda por substring sobre name/id.
+241 -45
View File
@@ -22,6 +22,7 @@
#include "views.h"
#include "types_registry.h"
#include "layout_store.h"
#include "entity_ops.h"
#include <cstdio>
#include <cstdlib>
@@ -63,6 +64,9 @@ static auto g_fps_timer = std::chrono::steady_clock::now();
// Label policy
static graph::LabelPolicy g_label_policy;
// Indice user_data -> sql id (rebuild en cada load).
static ge::EntityIndex g_idx;
// ----------------------------------------------------------------------------
// Helpers
// ----------------------------------------------------------------------------
@@ -145,6 +149,10 @@ static bool load_input() {
}
g_graph.update_bounds();
// Indice user_data -> sql id (para CRUD desde menu contextual).
ge::entity_index_build(g_input.uri, &g_idx);
g_app.input_db_path = g_input.uri ? g_input.uri : "";
// Cargar posiciones guardadas para este graph_hash
g_graph_hash = ge::compute_graph_hash(g_input.uri);
int restored = ge::layout_store_load(g_graph_hash, g_graph);
@@ -234,6 +242,88 @@ static void update_fps() {
g_last_frame = now;
}
// ----------------------------------------------------------------------------
// Context menu callback (right-click sobre nodo)
// ----------------------------------------------------------------------------
static void on_context_menu_cb(int node_idx, ImVec2 /*screen_pos*/, void* /*user*/) {
g_app.ctx_node = node_idx;
g_app.ctx_open_request = true;
if (node_idx >= 0 && node_idx < g_graph.node_count) {
const GraphNode& n = g_graph.nodes[node_idx];
if (n.type_id < (uint16_t)g_graph.type_count && g_graph.types[n.type_id].name) {
std::snprintf(g_app.ctx_new_type, sizeof(g_app.ctx_new_type), "%s",
g_graph.types[n.type_id].name);
} else {
g_app.ctx_new_type[0] = 0;
}
}
}
// Lista de tipos disponibles para "Change type" — se construye desde el grafo
// activo. Si esta vacia, se usa una lista por defecto.
static const char* k_default_types[] = {
"text", "person", "organization", "email", "ip_address", "domain",
"url", "phone", "crypto_wallet", "malware", "vulnerability",
};
constexpr int k_default_types_n = (int)(sizeof(k_default_types) / sizeof(k_default_types[0]));
static void render_context_menu() {
if (g_app.ctx_open_request) {
ImGui::OpenPopup("##node_ctx");
g_app.ctx_open_request = false;
}
if (!ImGui::BeginPopup("##node_ctx")) return;
int idx = g_app.ctx_node;
if (idx < 0 || idx >= g_graph.node_count) {
ImGui::TextDisabled("(no node)");
ImGui::EndPopup();
return;
}
const GraphNode& n = g_graph.nodes[idx];
const char* lbl = graph::graph_label(&g_graph, n.label_idx);
ImGui::TextDisabled("%s", lbl && *lbl ? lbl : "(unnamed)");
ImGui::Separator();
if (ImGui::BeginMenu("Change type")) {
// Tipos del grafo actual
for (int i = 0; i < g_graph.type_count; ++i) {
const char* name = g_graph.types[i].name;
if (!name) continue;
if (ImGui::MenuItem(name)) {
std::snprintf(g_app.ctx_new_type, sizeof(g_app.ctx_new_type), "%s", name);
g_app.want_change_type = true;
}
}
ImGui::Separator();
// Defaults extra (por si no estan presentes en el grafo cargado)
for (int i = 0; i < k_default_types_n; ++i) {
if (ImGui::MenuItem(k_default_types[i])) {
std::snprintf(g_app.ctx_new_type, sizeof(g_app.ctx_new_type), "%s",
k_default_types[i]);
g_app.want_change_type = true;
}
}
ImGui::EndMenu();
}
if (ImGui::MenuItem("Duplicate")) {
g_app.want_duplicate_node = true;
}
if (ImGui::MenuItem("Delete")) {
g_app.want_delete_node = true;
}
ImGui::Separator();
if (ImGui::BeginMenu("Run enricher")) {
ImGui::TextDisabled("(coming soon — issues 0001/0002/0003)");
ImGui::EndMenu();
}
ImGui::EndPopup();
}
// ----------------------------------------------------------------------------
// Label callback
// ----------------------------------------------------------------------------
@@ -249,6 +339,7 @@ static const char* get_label_cb(int node_idx, void* /*user*/) {
// ----------------------------------------------------------------------------
static fn_ui::PanelToggle g_panels[] = {
{"Viewport", nullptr, &g_app.panel_viewport},
{"Legend", nullptr, &g_app.panel_legend},
{"Inspector", nullptr, &g_app.panel_inspector},
{"Stats", nullptr, &g_app.panel_stats},
@@ -281,8 +372,29 @@ static void render() {
return;
}
// Toolbar superior — usa una ventana sin scroll y sin titulo
// Dockspace host: ocupa el area de trabajo bajo la menubar y permite
// que cualquier ventana (Viewport, Legend, Inspector, Stats, Table) se
// arrastre a un lateral o pestañas dentro de la app.
ImGuiViewport* vp = ImGui::GetMainViewport();
{
ImGui::SetNextWindowPos (vp->WorkPos);
ImGui::SetNextWindowSize(vp->WorkSize);
ImGui::SetNextWindowViewport(vp->ID);
ImGuiWindowFlags hostFlags =
ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize |
ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoCollapse |
ImGuiWindowFlags_NoBringToFrontOnFocus | ImGuiWindowFlags_NoNavFocus |
ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoDocking |
ImGuiWindowFlags_NoSavedSettings;
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0));
ImGui::Begin("##dock_host", nullptr, hostFlags);
ImGui::PopStyleVar();
ImGui::DockSpace(ImGui::GetID("##dockspace"), ImVec2(0, 0),
ImGuiDockNodeFlags_PassthruCentralNode);
ImGui::End();
}
// Toolbar superior — usa una ventana sin scroll y sin titulo
ImGui::SetNextWindowPos(vp->WorkPos);
ImGui::SetNextWindowSize(ImVec2(vp->WorkSize.x, 44.0f));
ImGui::Begin("##toolbar", nullptr,
@@ -335,56 +447,130 @@ static void render() {
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));
// ---- Mutaciones (add/delete/duplicate/change_type) ----
auto reload_after_mutation = [&]() {
graph::GraphLoadStats stats{};
if (ge::reload_graph(g_input, &g_graph, &stats)) {
ge::entity_index_build(g_input.uri, &g_idx);
ge::views_reset_visibility(g_app);
ge::views_apply_visibility(g_app);
g_graph.update_bounds();
int restored = ge::layout_store_load(g_graph_hash, g_graph);
if (restored > 0) g_graph.update_bounds();
g_atlas_bound = false;
g_gpu_dirty = true;
}
g_atlas_bound = true;
};
if (g_app.want_add_node && g_app.add_buf[0]) {
char new_id[80];
if (ge::entity_insert(g_app.input_db_path.c_str(), g_app.add_buf,
/*type_ref=*/nullptr, new_id, sizeof(new_id))) {
std::fprintf(stdout, "[graph_explorer] added entity %s\n", new_id);
g_app.add_buf[0] = 0;
reload_after_mutation();
} else {
std::fprintf(stderr, "[graph_explorer] add_entity failed\n");
}
g_app.want_add_node = false;
}
if (g_app.labels_enabled) {
graph::graph_labels_draw(g_graph, g_viewport, g_label_policy,
&get_label_cb, nullptr);
}
ImGui::NextColumn();
auto ctx_id = [&]() -> const char* {
if (g_app.ctx_node < 0 || g_app.ctx_node >= g_graph.node_count) return nullptr;
return ge::entity_index_lookup(g_idx, g_graph.nodes[g_app.ctx_node].user_data);
};
// Col der: Inspector + Stats
if (g_app.want_delete_node) {
if (const char* id = ctx_id()) {
if (ge::entity_delete(g_app.input_db_path.c_str(), id)) {
std::fprintf(stdout, "[graph_explorer] deleted entity %s\n", id);
reload_after_mutation();
}
}
g_app.want_delete_node = false;
g_app.ctx_node = -1;
}
if (g_app.want_duplicate_node) {
if (const char* id = ctx_id()) {
char new_id[80];
if (ge::entity_duplicate(g_app.input_db_path.c_str(), id,
new_id, sizeof(new_id))) {
std::fprintf(stdout, "[graph_explorer] duplicated %s -> %s\n", id, new_id);
reload_after_mutation();
}
}
g_app.want_duplicate_node = false;
}
if (g_app.want_change_type && g_app.ctx_new_type[0]) {
if (const char* id = ctx_id()) {
if (ge::entity_update_type(g_app.input_db_path.c_str(), id, g_app.ctx_new_type)) {
std::fprintf(stdout, "[graph_explorer] %s -> type %s\n", id, g_app.ctx_new_type);
reload_after_mutation();
}
}
g_app.want_change_type = false;
}
// Posiciones iniciales razonables; el usuario puede moverlas y se
// persiste via imgui.ini.
const float top = vp->WorkPos.y + 44.0f;
const float W = vp->WorkSize.x;
const float H = vp->WorkSize.y - 44.0f;
const float lw = 240.0f; // Legend
const float rw = 320.0f; // Inspector / Stats
const float sh = H * 0.55f; // Inspector altura
// Viewport — ventana central
if (g_app.panel_viewport) {
ImGui::SetNextWindowPos (ImVec2(vp->WorkPos.x + lw, top), ImGuiCond_FirstUseEver);
ImGui::SetNextWindowSize(ImVec2(W - lw - rw, H), ImGuiCond_FirstUseEver);
if (ImGui::Begin("Viewport", &g_app.panel_viewport)) {
run_force_step();
GraphViewportCallbacks vp_cb{};
vp_cb.on_context_menu = &on_context_menu_cb;
graph_viewport("##gv", g_graph, g_viewport, ImVec2(0, 0), vp_cb);
render_context_menu();
// La primera vez que el viewport se dibuja, el renderer existe —
// bind del atlas (si tenemos uno).
if (!g_atlas_bound && g_viewport.renderer) {
if (g_atlas) {
graph_renderer_set_icon_atlas(g_viewport.renderer,
graph_icons_texture(g_atlas),
graph_icons_uv_table(g_atlas),
graph_icons_count(g_atlas));
}
g_atlas_bound = true;
}
if (g_app.labels_enabled) {
graph::graph_labels_draw(g_graph, g_viewport, g_label_policy,
&get_label_cb, nullptr);
}
}
ImGui::End();
} else {
// Sin ventana visible, igual avanzamos la simulacion para que al
// reabrirla el grafo este actualizado.
run_force_step();
}
// Legend — izquierda
ImGui::SetNextWindowPos (ImVec2(vp->WorkPos.x, top), ImGuiCond_FirstUseEver);
ImGui::SetNextWindowSize(ImVec2(lw, H), ImGuiCond_FirstUseEver);
ge::views_legend(g_app);
// Inspector / Stats — derecha (apilados)
ImGui::SetNextWindowPos (ImVec2(vp->WorkPos.x + W - rw, top), ImGuiCond_FirstUseEver);
ImGui::SetNextWindowSize(ImVec2(rw, sh), ImGuiCond_FirstUseEver);
ge::views_inspector(g_app);
ge::views_stats(g_app);
ImGui::NextColumn();
ImGui::Columns(1);
ImGui::End();
ImGui::SetNextWindowPos (ImVec2(vp->WorkPos.x + W - rw, top + sh), ImGuiCond_FirstUseEver);
ImGui::SetNextWindowSize(ImVec2(rw, H - sh), ImGuiCond_FirstUseEver);
ge::views_stats(g_app);
g_first_render = false;
}
@@ -433,6 +619,16 @@ int main(int argc, char** argv) {
// SQLite store junto al exe.
ge::layout_store_open("graph_explorer.db");
// Si no llego --input/positional, intentar operations.db en el cwd
// (mismo criterio que graph_explorer.db: relativo al directorio de ejecucion).
if (g_input_path.empty()) {
if (FILE* f = std::fopen("operations.db", "rb")) {
std::fclose(f);
g_input_path = "operations.db";
std::fprintf(stdout, "[graph_explorer] using default ./operations.db\n");
}
}
if (!g_input_path.empty()) {
load_input();
}
+27 -3
View File
@@ -1,4 +1,5 @@
#include "views.h"
#include "entity_ops.h"
#include "viz/graph_types.h"
#include "viz/graph_viewport.h"
@@ -93,6 +94,24 @@ void views_toolbar(AppState& app) {
if (button(TI_FOLDER " Open file...", ButtonVariant::Secondary)) {
app.show_open_modal = true;
}
ImGui::SameLine();
// Add node — input + auto-deteccion de tipo. Enter o boton "Add" lo
// confirman; main.cpp inserta en operations.db y dispara reload.
ImGui::SetNextItemWidth(220);
DetectedType dt = detect_type(app.add_buf);
ImGuiInputTextFlags flags = ImGuiInputTextFlags_EnterReturnsTrue;
char hint[64];
std::snprintf(hint, sizeof(hint), "Add node (%s)...", detected_type_name(dt));
if (ImGui::InputTextWithHint("##addnode", hint, app.add_buf, sizeof(app.add_buf), flags)) {
app.want_add_node = true;
}
ImGui::SameLine();
if (button(TI_PLUS " Add", ButtonVariant::Primary)) {
app.want_add_node = true;
}
ImGui::SameLine();
ImGui::TextDisabled("[%s]", detected_type_name(dt));
toolbar_separator();
ImGui::TextUnformatted("Layout:");
@@ -268,12 +287,17 @@ void views_inspector(AppState& app) {
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;
const char* arrow = " ";
if (edge.source == (uint32_t)idx) { other = (int)edge.target; arrow = "->"; }
else if (edge.target == (uint32_t)idx) { other = (int)edge.source; arrow = "<-"; }
if (other < 0 || other >= g.node_count) continue;
const char* olbl = graph::graph_label(&g, g.nodes[other].label_idx);
const char* rname = (edge.type_id < (uint16_t)g.rel_type_count &&
g.rel_types[edge.type_id].name)
? g.rel_types[edge.type_id].name
: k_default_relation_name;
char buf[256];
std::snprintf(buf, sizeof(buf), "[%d] %s", other,
std::snprintf(buf, sizeof(buf), "%s %s [%d] %s", arrow, rname, other,
olbl && *olbl ? olbl : "(unnamed)");
if (ImGui::Selectable(buf)) {
graph_viewport_clear_selection(g, *app.viewport);
+21
View File
@@ -1,5 +1,7 @@
#pragma once
#include <string>
struct GraphData;
struct GraphViewportState;
@@ -35,6 +37,7 @@ struct AppState {
bool panel_legend = true;
bool panel_inspector = true;
bool panel_stats = true;
bool panel_viewport = true;
bool show_filters_modal = false;
bool show_open_modal = false;
@@ -47,6 +50,24 @@ struct AppState {
// Labels overlay
bool labels_enabled = true;
// Path activo de operations.db (para CRUD desde toolbar / contextmenu).
// main.cpp lo escribe tras cargar y los handlers lo leen.
std::string input_db_path;
// Add-node toolbar input.
char add_buf[256] = {};
// Triggers de mutacion — main.cpp los procesa y dispara reload.
bool want_add_node = false; // commit del input add_buf
bool want_delete_node = false; // delete del nodo en ctx_node
bool want_duplicate_node = false;
bool want_change_type = false; // a ctx_new_type
int ctx_node = -1; // node_idx objetivo
char ctx_new_type[64] = {};
// Context menu state — popup global identificado por nombre.
bool ctx_open_request = false; // se setea en on_context_menu
};
// Toolbar superior (Open file, Layout selector, Filters..., Fit, Save layout).