adde3026ea
Bug fixes - ImGui ID conflict en menu Change type: dedup tipos del grafo + defaults; PushID/PopID por entrada. - Dockspace ya no tapa la toolbar: se posiciona 44 px por debajo, asi las ventanas dockeadas al borde superior quedan bajo la barra de filtros, no detras. - Hover radius proporcional al tamaño visual del nodo: query espacial amplio (24/zoom) + filtro fino por (radio_visual + 2 px) / zoom. El tooltip solo se dispara si el raton esta efectivamente sobre el nodo. Layout - Default layout = grid (en vez de force) para que los grafos cargados se distribuyan ordenadamente al abrir. - Boton "Reset layout" en la toolbar: limpia NF_PINNED en todos los nodos, resetea velocidades y reaplica el layout activo. - Nodos recien creados (add_node, duplicate) caen en un anillo poisson alrededor del centro de la vista, no en el origen. Posicion determinista por user_data para que el mismo nodo no salte entre reloads. Notes (markdown) - Panel "Note" (dockeable) abierto con doble click sobre un nodo. - entity_get_notes / entity_set_notes en entity_ops sobre la columna `notes` de operations.db (ya existente en el schema). - Ctrl+S guarda. Cabecera muestra entity, type, id.
520 lines
18 KiB
C++
520 lines
18 KiB
C++
#include "views.h"
|
|
#include "entity_ops.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;
|
|
}
|
|
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:");
|
|
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;
|
|
}
|
|
if (button(TI_LAYOUT_GRID " Reset layout", ButtonVariant::Subtle)) {
|
|
app.want_unpin_all = true;
|
|
++app.apply_layout_tick;
|
|
}
|
|
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;
|
|
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), "%s %s [%d] %s", arrow, rname, 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();
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// Note editor (markdown)
|
|
// ----------------------------------------------------------------------------
|
|
|
|
void views_note(AppState& app) {
|
|
if (!app.panel_note) return;
|
|
if (!ImGui::Begin(TI_FILE_TEXT " Note", &app.panel_note,
|
|
ImGuiWindowFlags_MenuBar)) {
|
|
ImGui::End();
|
|
return;
|
|
}
|
|
|
|
if (ImGui::BeginMenuBar()) {
|
|
if (ImGui::MenuItem(TI_DEVICE_FLOPPY " Save", "Ctrl+S",
|
|
/*selected=*/false, app.note_dirty)) {
|
|
app.want_save_note = true;
|
|
}
|
|
if (app.note_dirty) {
|
|
ImGui::TextDisabled("(modified)");
|
|
}
|
|
ImGui::EndMenuBar();
|
|
}
|
|
|
|
if (app.note_node < 0) {
|
|
ImGui::TextDisabled("Doble click sobre un nodo para abrir su nota.");
|
|
ImGui::End();
|
|
return;
|
|
}
|
|
|
|
ImGui::PushStyleColor(ImGuiCol_Text, fn_tokens::colors::text_muted);
|
|
ImGui::TextUnformatted("entity:");
|
|
ImGui::PopStyleColor();
|
|
ImGui::SameLine();
|
|
ImGui::TextUnformatted(app.note_entity_label.empty()
|
|
? "(unnamed)" : app.note_entity_label.c_str());
|
|
|
|
ImGui::PushStyleColor(ImGuiCol_Text, fn_tokens::colors::text_muted);
|
|
ImGui::TextUnformatted("type:");
|
|
ImGui::PopStyleColor();
|
|
ImGui::SameLine();
|
|
ImGui::TextUnformatted(app.note_entity_type.empty()
|
|
? "(no-type)" : app.note_entity_type.c_str());
|
|
|
|
ImGui::PushStyleColor(ImGuiCol_Text, fn_tokens::colors::text_muted);
|
|
ImGui::TextUnformatted("id:");
|
|
ImGui::PopStyleColor();
|
|
ImGui::SameLine();
|
|
ImGui::TextUnformatted(app.note_entity_id.c_str());
|
|
|
|
ImGui::Separator();
|
|
|
|
if (app.note_buf.empty()) app.note_buf.resize(1024, 0);
|
|
|
|
// Crece el buffer si esta cerca del limite.
|
|
if (std::strlen(app.note_buf.data()) + 64 >= app.note_buf.size()) {
|
|
app.note_buf.resize(app.note_buf.size() * 2, 0);
|
|
}
|
|
|
|
ImVec2 size = ImGui::GetContentRegionAvail();
|
|
if (ImGui::InputTextMultiline("##note_md", app.note_buf.data(),
|
|
app.note_buf.size(), size,
|
|
ImGuiInputTextFlags_AllowTabInput)) {
|
|
app.note_dirty = true;
|
|
}
|
|
if (ImGui::IsItemHovered() &&
|
|
ImGui::IsKeyChordPressed(ImGuiMod_Ctrl | ImGuiKey_S)) {
|
|
app.want_save_note = true;
|
|
}
|
|
|
|
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
|