feat(skill_tree): canvas ImDrawList + ring layout + cards (0109b)
Render anillos concentricos + sectores radiales por dominio con ImDrawList. 166 nodos pintados con label ID, tooltip on hover, picking O(N), pan+zoom. Decisiones: - Pivote desde graph_renderer GPU a ImDrawList CPU. 166 nodos no justifican pipeline GPU; ahorra ~120 LOC y init_gl_loader. - 5 rings: done / in-progress / unlocked / locked / deferred. - 18 sectores = 18 dominios canonicos (dev/TAXONOMY.md), labels en aro exterior. - Issues = circulos, flows = rombos para diferencial visual. - Lock-unlock derivado: pendiente_unlocked vs pendiente_locked segun depends. - Animacion lerp 1s ease-in-out entre prev/current position en cambios de status entre reloads. - HUD strip: LV + XP + conteos por bucket. - Pan: drag derecho/medio. Zoom: rueda centrada en cursor. uses_functions: - parse_md_frontmatter_cpp_core (ya existia desde 0109a) - compute_ring_layout_cpp_core (NUEVA, pure, 10/10 tests, 142 assertions) Self-test: 166 nodes, parse_errors=0, unmapped=0, exit 0. Breakdown: done=77 in-progress=2 unlocked=64 locked=22 deferred=1. Sigue: 0109c (Inspector con DoD parseado y uses_functions linkables). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
add_imgui_app(skill_tree
|
add_imgui_app(skill_tree
|
||||||
main.cpp
|
main.cpp
|
||||||
${CMAKE_SOURCE_DIR}/functions/core/parse_md_frontmatter.cpp
|
${CMAKE_SOURCE_DIR}/functions/core/parse_md_frontmatter.cpp
|
||||||
|
${CMAKE_SOURCE_DIR}/functions/core/compute_ring_layout.cpp
|
||||||
)
|
)
|
||||||
target_include_directories(skill_tree PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
|
target_include_directories(skill_tree PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ icon:
|
|||||||
accent: "#c026d3"
|
accent: "#c026d3"
|
||||||
uses_functions:
|
uses_functions:
|
||||||
- parse_md_frontmatter_cpp_core
|
- parse_md_frontmatter_cpp_core
|
||||||
|
- compute_ring_layout_cpp_core
|
||||||
uses_types: []
|
uses_types: []
|
||||||
framework: "imgui"
|
framework: "imgui"
|
||||||
entry_point: "main.cpp"
|
entry_point: "main.cpp"
|
||||||
|
|||||||
@@ -4,8 +4,11 @@
|
|||||||
#include "core/icons_tabler.h"
|
#include "core/icons_tabler.h"
|
||||||
#include "core/logger.h"
|
#include "core/logger.h"
|
||||||
#include "core/parse_md_frontmatter.h"
|
#include "core/parse_md_frontmatter.h"
|
||||||
|
#include "core/compute_ring_layout.h"
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
#include <chrono>
|
||||||
|
#include <cmath>
|
||||||
#include <cstdlib>
|
#include <cstdlib>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
@@ -13,6 +16,8 @@
|
|||||||
#include <map>
|
#include <map>
|
||||||
#include <sstream>
|
#include <sstream>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <unordered_map>
|
||||||
|
#include <unordered_set>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
namespace fs = std::filesystem;
|
namespace fs = std::filesystem;
|
||||||
@@ -22,26 +27,83 @@ namespace fs = std::filesystem;
|
|||||||
enum class NodeKind { Issue, Flow };
|
enum class NodeKind { Issue, Flow };
|
||||||
|
|
||||||
struct Node {
|
struct Node {
|
||||||
NodeKind kind;
|
NodeKind kind;
|
||||||
std::string id; // "0109" / "0109a" / "0001" (flow)
|
std::string id;
|
||||||
std::string title;
|
std::string title;
|
||||||
std::string status; // pendiente|in-progress|completado|bloqueado|deferred|completed|pending
|
std::string status_raw; // status as read from frontmatter
|
||||||
std::string type; // epic|feature|bugfix|... (issues only)
|
std::string status_eff; // effective status after lock derivation
|
||||||
std::string priority;
|
std::string type;
|
||||||
|
std::string priority;
|
||||||
std::vector<std::string> domain;
|
std::vector<std::string> domain;
|
||||||
std::vector<std::string> depends;
|
std::vector<std::string> depends; // for issues
|
||||||
std::vector<std::string> related;
|
std::vector<std::string> related;
|
||||||
std::string file_path;
|
std::string file_path;
|
||||||
|
int mtime_rank = 0; // for recency 0..1
|
||||||
|
|
||||||
|
float x = 0.0f;
|
||||||
|
float y = 0.0f;
|
||||||
|
int ring = -1;
|
||||||
|
int sector = 0;
|
||||||
|
|
||||||
|
// animation
|
||||||
|
float prev_x = 0.0f;
|
||||||
|
float prev_y = 0.0f;
|
||||||
|
double anim_start = 0.0; // seconds since epoch when last status change
|
||||||
|
std::string anim_prev_status; // status when anim_start was set
|
||||||
};
|
};
|
||||||
|
|
||||||
struct ScanResult {
|
struct ScanResult {
|
||||||
std::vector<Node> nodes;
|
std::vector<Node> nodes;
|
||||||
std::map<std::string, int> count_by_status;
|
std::map<std::string, int> count_by_status;
|
||||||
std::map<std::string, int> count_by_domain;
|
std::map<std::string, int> count_by_domain;
|
||||||
std::map<std::string, int> count_by_kind;
|
std::map<std::string, int> count_by_kind;
|
||||||
int parse_errors = 0;
|
int parse_errors = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ---- Constants ----------------------------------------------------------
|
||||||
|
|
||||||
|
// Canonical domain order from dev/TAXONOMY.md (18 sectors = 18 domains).
|
||||||
|
static const std::vector<std::string> kDomainOrder = {
|
||||||
|
"meta", "cpp-stack", "kanban", "trading", "gamedev", "osint",
|
||||||
|
"data-ingest", "registry-quality", "notify", "imagegen",
|
||||||
|
"apps-infra", "dev-ux", "deploy", "frontend", "mcp",
|
||||||
|
"browser", "telemetry", "docs",
|
||||||
|
};
|
||||||
|
|
||||||
|
// status_eff buckets -> ring index
|
||||||
|
static const fn_ring::StatusRingMap kStatusMap = {
|
||||||
|
{"completado", 0},
|
||||||
|
{"completed", 0},
|
||||||
|
{"in-progress", 1},
|
||||||
|
{"pendiente_unlocked", 2},
|
||||||
|
{"pendiente_locked", 3},
|
||||||
|
{"deferred", 4},
|
||||||
|
{"bloqueado", 4},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Colors per ring (RGBA u32, ABGR memory order for IM_COL32)
|
||||||
|
static ImU32 ring_color(int ring) {
|
||||||
|
switch (ring) {
|
||||||
|
case 0: return IM_COL32( 76, 175, 80, 230); // green completado
|
||||||
|
case 1: return IM_COL32(255, 193, 7, 230); // amber in-progress
|
||||||
|
case 2: return IM_COL32(192, 38, 211, 230); // fuchsia unlocked (accent)
|
||||||
|
case 3: return IM_COL32(120, 120, 130, 180); // gray locked
|
||||||
|
case 4: return IM_COL32( 80, 80, 90, 110); // dim deferred / bloqueado
|
||||||
|
default: return IM_COL32(150, 150, 150, 120);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static const char* ring_label(int ring) {
|
||||||
|
switch (ring) {
|
||||||
|
case 0: return "done";
|
||||||
|
case 1: return "in-progress";
|
||||||
|
case 2: return "unlocked";
|
||||||
|
case 3: return "locked";
|
||||||
|
case 4: return "deferred";
|
||||||
|
default: return "?";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Registry root discovery -------------------------------------------
|
// ---- Registry root discovery -------------------------------------------
|
||||||
|
|
||||||
static fs::path discover_registry_root() {
|
static fs::path discover_registry_root() {
|
||||||
@@ -49,7 +111,6 @@ static fs::path discover_registry_root() {
|
|||||||
fs::path p(env);
|
fs::path p(env);
|
||||||
if (fs::exists(p / "registry.db")) return p;
|
if (fs::exists(p / "registry.db")) return p;
|
||||||
}
|
}
|
||||||
// walk up from cwd
|
|
||||||
for (auto p = fs::current_path(); !p.empty() && p != p.root_path(); p = p.parent_path()) {
|
for (auto p = fs::current_path(); !p.empty() && p != p.root_path(); p = p.parent_path()) {
|
||||||
if (fs::exists(p / "registry.db") && fs::exists(p / "dev" / "issues")) return p;
|
if (fs::exists(p / "registry.db") && fs::exists(p / "dev" / "issues")) return p;
|
||||||
}
|
}
|
||||||
@@ -82,6 +143,11 @@ static std::vector<std::string> get_list(const fn_md::Frontmatter& fm, const cha
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static double now_seconds() {
|
||||||
|
using namespace std::chrono;
|
||||||
|
return duration_cast<duration<double>>(steady_clock::now().time_since_epoch()).count();
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Scanner ------------------------------------------------------------
|
// ---- Scanner ------------------------------------------------------------
|
||||||
|
|
||||||
static void scan_dir(const fs::path& dir, NodeKind kind, ScanResult& out) {
|
static void scan_dir(const fs::path& dir, NodeKind kind, ScanResult& out) {
|
||||||
@@ -90,7 +156,6 @@ static void scan_dir(const fs::path& dir, NodeKind kind, ScanResult& out) {
|
|||||||
if (!entry.is_regular_file()) continue;
|
if (!entry.is_regular_file()) continue;
|
||||||
const auto& p = entry.path();
|
const auto& p = entry.path();
|
||||||
if (p.extension() != ".md") continue;
|
if (p.extension() != ".md") continue;
|
||||||
// skip template.md / INDEX.md / README.md / AGENT_GUIDE.md
|
|
||||||
auto stem = p.stem().string();
|
auto stem = p.stem().string();
|
||||||
if (stem == "template" || stem == "INDEX" || stem == "README" || stem == "AGENT_GUIDE") continue;
|
if (stem == "template" || stem == "INDEX" || stem == "README" || stem == "AGENT_GUIDE") continue;
|
||||||
|
|
||||||
@@ -99,24 +164,20 @@ static void scan_dir(const fs::path& dir, NodeKind kind, ScanResult& out) {
|
|||||||
if (!fm.has_frontmatter) { ++out.parse_errors; continue; }
|
if (!fm.has_frontmatter) { ++out.parse_errors; continue; }
|
||||||
|
|
||||||
Node n;
|
Node n;
|
||||||
n.kind = kind;
|
n.kind = kind;
|
||||||
n.id = get_str(fm, kind == NodeKind::Issue ? "id" : "id");
|
n.id = get_str(fm, "id");
|
||||||
if (n.id.empty()) n.id = get_str(fm, "name");
|
if (n.id.empty()) n.id = get_str(fm, "name");
|
||||||
n.title = get_str(fm, kind == NodeKind::Issue ? "title" : "name");
|
n.title = get_str(fm, kind == NodeKind::Issue ? "title" : "name");
|
||||||
n.status = get_str(fm, "status");
|
n.status_raw = get_str(fm, "status");
|
||||||
n.type = get_str(fm, "type");
|
n.type = get_str(fm, "type");
|
||||||
n.priority = get_str(fm, "priority");
|
n.priority = get_str(fm, "priority");
|
||||||
n.domain = get_list(fm, "domain");
|
n.domain = get_list(fm, "domain");
|
||||||
n.depends = get_list(fm, kind == NodeKind::Issue ? "depends" : "related_issues");
|
n.depends = get_list(fm, kind == NodeKind::Issue ? "depends" : "related_issues");
|
||||||
n.related = get_list(fm, "related");
|
n.related = get_list(fm, "related");
|
||||||
n.file_path = p.string();
|
n.file_path = p.string();
|
||||||
|
|
||||||
if (n.status.empty()) n.status = "(unknown)";
|
if (n.status_raw.empty()) n.status_raw = "(unknown)";
|
||||||
if (n.domain.empty()) n.domain.push_back("(unknown)");
|
if (n.domain.empty()) n.domain.push_back("(unknown)");
|
||||||
|
|
||||||
++out.count_by_status[n.status];
|
|
||||||
++out.count_by_kind[kind == NodeKind::Issue ? "issue" : "flow"];
|
|
||||||
for (const auto& d : n.domain) ++out.count_by_domain[d];
|
|
||||||
|
|
||||||
out.nodes.push_back(std::move(n));
|
out.nodes.push_back(std::move(n));
|
||||||
}
|
}
|
||||||
@@ -124,28 +185,332 @@ static void scan_dir(const fs::path& dir, NodeKind kind, ScanResult& out) {
|
|||||||
|
|
||||||
static ScanResult scan_registry(const fs::path& root) {
|
static ScanResult scan_registry(const fs::path& root) {
|
||||||
ScanResult r;
|
ScanResult r;
|
||||||
scan_dir(root / "dev" / "issues", NodeKind::Issue, r);
|
scan_dir(root / "dev" / "issues", NodeKind::Issue, r);
|
||||||
scan_dir(root / "dev" / "issues" / "completed", NodeKind::Issue, r);
|
scan_dir(root / "dev" / "issues" / "completed", NodeKind::Issue, r);
|
||||||
scan_dir(root / "dev" / "flows", NodeKind::Flow, r);
|
scan_dir(root / "dev" / "flows", NodeKind::Flow, r);
|
||||||
scan_dir(root / "dev" / "flows" / "completed", NodeKind::Flow, r);
|
scan_dir(root / "dev" / "flows" / "completed", NodeKind::Flow, r);
|
||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Lock-unlock derivation --------------------------------------------
|
||||||
|
|
||||||
|
static void derive_status_eff(std::vector<Node>& nodes) {
|
||||||
|
// Build set of "done" IDs (completado / completed).
|
||||||
|
std::unordered_set<std::string> done;
|
||||||
|
for (const auto& n : nodes) {
|
||||||
|
if (n.status_raw == "completado" || n.status_raw == "completed") done.insert(n.id);
|
||||||
|
}
|
||||||
|
for (auto& n : nodes) {
|
||||||
|
const auto& s = n.status_raw;
|
||||||
|
if (s == "completado" || s == "completed") {
|
||||||
|
n.status_eff = "completado";
|
||||||
|
} else if (s == "in-progress") {
|
||||||
|
n.status_eff = "in-progress";
|
||||||
|
} else if (s == "deferred") {
|
||||||
|
n.status_eff = "deferred";
|
||||||
|
} else if (s == "bloqueado") {
|
||||||
|
n.status_eff = "bloqueado";
|
||||||
|
} else if (s == "pendiente" || s == "pending") {
|
||||||
|
// Locked if any depends/related_issues is NOT in done.
|
||||||
|
bool locked = false;
|
||||||
|
for (const auto& d : n.depends) {
|
||||||
|
if (!d.empty() && done.find(d) == done.end()) { locked = true; break; }
|
||||||
|
}
|
||||||
|
n.status_eff = locked ? "pendiente_locked" : "pendiente_unlocked";
|
||||||
|
} else {
|
||||||
|
n.status_eff = "deferred"; // unknown bucket -> outer ring
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- UI state -----------------------------------------------------------
|
// ---- UI state -----------------------------------------------------------
|
||||||
|
|
||||||
static fs::path g_root;
|
static fs::path g_root;
|
||||||
static ScanResult g_scan;
|
static ScanResult g_scan;
|
||||||
static bool g_show_tree = true;
|
static bool g_show_tree = true;
|
||||||
static bool g_show_inspector = true;
|
static bool g_show_inspector = true;
|
||||||
static int g_selected = -1;
|
static int g_selected = -1;
|
||||||
|
static int g_hover = -1;
|
||||||
|
static float g_cam_x = 0.0f;
|
||||||
|
static float g_cam_y = 0.0f;
|
||||||
|
static float g_cam_zoom = 1.0f;
|
||||||
|
|
||||||
|
static const float kNodeRadius = 18.0f;
|
||||||
|
static const float kAnimDur = 1.0f; // seconds for ring migration lerp
|
||||||
|
|
||||||
|
// Apply ring layout to nodes, preserving anim state across reloads.
|
||||||
|
static void apply_layout(std::vector<Node>& nodes) {
|
||||||
|
std::vector<fn_ring::LayoutInput> input;
|
||||||
|
input.reserve(nodes.size());
|
||||||
|
for (const auto& n : nodes) {
|
||||||
|
fn_ring::LayoutInput li;
|
||||||
|
li.id = n.id;
|
||||||
|
li.status = n.status_eff;
|
||||||
|
li.domain = n.domain.empty() ? "(unknown)" : n.domain.front();
|
||||||
|
li.recency = 0.0f;
|
||||||
|
input.push_back(std::move(li));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn_ring::LayoutConfig cfg;
|
||||||
|
cfg.n_sectors = 18;
|
||||||
|
cfg.center_x = 0.0f;
|
||||||
|
cfg.center_y = 0.0f;
|
||||||
|
cfg.ring_radii = { 0.0f, 150.0f, 280.0f, 450.0f, 650.0f, 850.0f };
|
||||||
|
cfg.bin_padding = 18.0f;
|
||||||
|
cfg.start_angle = -1.5708f; // -PI/2: sector 0 starts at 12 o'clock
|
||||||
|
|
||||||
|
auto out = fn_ring::compute_ring_layout(input, cfg, kStatusMap, kDomainOrder);
|
||||||
|
|
||||||
|
// Map by id for O(1) assignment.
|
||||||
|
std::unordered_map<std::string, const fn_ring::LayoutOutput*> by_id;
|
||||||
|
by_id.reserve(out.size());
|
||||||
|
for (const auto& o : out) by_id.emplace(o.id, &o);
|
||||||
|
|
||||||
|
double now = now_seconds();
|
||||||
|
for (auto& n : nodes) {
|
||||||
|
auto it = by_id.find(n.id);
|
||||||
|
if (it == by_id.end()) continue;
|
||||||
|
const auto& o = *it->second;
|
||||||
|
|
||||||
|
// Detect status change since last layout: kick off anim.
|
||||||
|
if (!n.anim_prev_status.empty() && n.anim_prev_status != n.status_eff) {
|
||||||
|
n.prev_x = n.x;
|
||||||
|
n.prev_y = n.y;
|
||||||
|
n.anim_start = now;
|
||||||
|
}
|
||||||
|
if (n.anim_prev_status.empty()) {
|
||||||
|
// first apply: no anim needed
|
||||||
|
n.prev_x = o.x;
|
||||||
|
n.prev_y = o.y;
|
||||||
|
n.anim_start = now - kAnimDur; // already finished
|
||||||
|
}
|
||||||
|
n.x = o.x;
|
||||||
|
n.y = o.y;
|
||||||
|
n.ring = o.ring;
|
||||||
|
n.sector = o.sector;
|
||||||
|
n.anim_prev_status = n.status_eff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void recount(ScanResult& s) {
|
||||||
|
s.count_by_status.clear();
|
||||||
|
s.count_by_domain.clear();
|
||||||
|
s.count_by_kind.clear();
|
||||||
|
for (const auto& n : s.nodes) {
|
||||||
|
++s.count_by_status[n.status_eff];
|
||||||
|
++s.count_by_kind[n.kind == NodeKind::Issue ? "issue" : "flow"];
|
||||||
|
for (const auto& d : n.domain) ++s.count_by_domain[d];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static void reload_scan() {
|
static void reload_scan() {
|
||||||
g_scan = scan_registry(g_root);
|
auto fresh = scan_registry(g_root);
|
||||||
|
derive_status_eff(fresh.nodes);
|
||||||
|
|
||||||
|
// Preserve animation state by id across reload.
|
||||||
|
std::unordered_map<std::string, Node> prev_by_id;
|
||||||
|
for (auto& n : g_scan.nodes) prev_by_id.emplace(n.id, std::move(n));
|
||||||
|
for (auto& n : fresh.nodes) {
|
||||||
|
auto it = prev_by_id.find(n.id);
|
||||||
|
if (it != prev_by_id.end()) {
|
||||||
|
n.prev_x = it->second.x;
|
||||||
|
n.prev_y = it->second.y;
|
||||||
|
n.anim_start = it->second.anim_start;
|
||||||
|
n.anim_prev_status = it->second.anim_prev_status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
g_scan = std::move(fresh);
|
||||||
|
apply_layout(g_scan.nodes);
|
||||||
|
recount(g_scan);
|
||||||
g_selected = -1;
|
g_selected = -1;
|
||||||
fn_log::log_info("skill_tree: reloaded %d nodes, %d parse errors",
|
fn_log::log_info("skill_tree: reloaded %d nodes, %d parse errors",
|
||||||
(int)g_scan.nodes.size(), g_scan.parse_errors);
|
(int)g_scan.nodes.size(), g_scan.parse_errors);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Canvas drawing -----------------------------------------------------
|
||||||
|
|
||||||
|
static ImVec2 world_to_screen(const ImVec2& origin, float wx, float wy) {
|
||||||
|
return ImVec2(origin.x + wx * g_cam_zoom + g_cam_x,
|
||||||
|
origin.y + wy * g_cam_zoom + g_cam_y);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void draw_canvas() {
|
||||||
|
const ImVec2 avail = ImGui::GetContentRegionAvail();
|
||||||
|
if (avail.x < 40 || avail.y < 40) return;
|
||||||
|
|
||||||
|
ImGui::InvisibleButton("canvas", avail, ImGuiButtonFlags_MouseButtonLeft | ImGuiButtonFlags_MouseButtonRight);
|
||||||
|
const bool hovered = ImGui::IsItemHovered();
|
||||||
|
const bool active = ImGui::IsItemActive();
|
||||||
|
|
||||||
|
const ImVec2 p0 = ImGui::GetItemRectMin();
|
||||||
|
const ImVec2 p1 = ImGui::GetItemRectMax();
|
||||||
|
const ImVec2 center(0.5f * (p0.x + p1.x), 0.5f * (p0.y + p1.y));
|
||||||
|
ImDrawList* dl = ImGui::GetWindowDrawList();
|
||||||
|
dl->PushClipRect(p0, p1, true);
|
||||||
|
|
||||||
|
// Background.
|
||||||
|
dl->AddRectFilled(p0, p1, IM_COL32(18, 18, 22, 255));
|
||||||
|
|
||||||
|
// Pan with right or middle drag.
|
||||||
|
if (active && ImGui::IsMouseDragging(ImGuiMouseButton_Right, 0.0f)) {
|
||||||
|
ImVec2 d = ImGui::GetIO().MouseDelta;
|
||||||
|
g_cam_x += d.x;
|
||||||
|
g_cam_y += d.y;
|
||||||
|
}
|
||||||
|
if (active && ImGui::IsMouseDragging(ImGuiMouseButton_Middle, 0.0f)) {
|
||||||
|
ImVec2 d = ImGui::GetIO().MouseDelta;
|
||||||
|
g_cam_x += d.x;
|
||||||
|
g_cam_y += d.y;
|
||||||
|
}
|
||||||
|
// Zoom with wheel (centered on mouse).
|
||||||
|
if (hovered && std::abs(ImGui::GetIO().MouseWheel) > 0.0f) {
|
||||||
|
const float wheel = ImGui::GetIO().MouseWheel;
|
||||||
|
const float old_zoom = g_cam_zoom;
|
||||||
|
const float new_zoom = std::clamp(old_zoom * (1.0f + wheel * 0.12f), 0.2f, 4.0f);
|
||||||
|
// Keep mouse-world point under cursor:
|
||||||
|
ImVec2 mp = ImGui::GetMousePos();
|
||||||
|
float mx_world = (mp.x - center.x - g_cam_x) / old_zoom;
|
||||||
|
float my_world = (mp.y - center.y - g_cam_y) / old_zoom;
|
||||||
|
g_cam_zoom = new_zoom;
|
||||||
|
g_cam_x = (mp.x - center.x) - mx_world * new_zoom;
|
||||||
|
g_cam_y = (mp.y - center.y) - my_world * new_zoom;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Origin honoring pan; concentric backdrop rings.
|
||||||
|
static const float radii[] = { 150.0f, 280.0f, 450.0f, 650.0f, 850.0f };
|
||||||
|
ImVec2 origin_with_pan(center.x + g_cam_x, center.y + g_cam_y);
|
||||||
|
for (float r : radii) {
|
||||||
|
dl->AddCircle(origin_with_pan, r * g_cam_zoom, IM_COL32(255, 255, 255, 22), 96, 1.0f);
|
||||||
|
}
|
||||||
|
// Center marker.
|
||||||
|
dl->AddCircleFilled(origin_with_pan, 3.0f, IM_COL32(255, 255, 255, 80));
|
||||||
|
|
||||||
|
// Build screen-space index for picking.
|
||||||
|
const double now = now_seconds();
|
||||||
|
auto node_screen = [&](const Node& n) {
|
||||||
|
float t = std::clamp(float(now - n.anim_start) / kAnimDur, 0.0f, 1.0f);
|
||||||
|
// ease-in-out
|
||||||
|
float e = t * t * (3.0f - 2.0f * t);
|
||||||
|
float wx = n.prev_x + (n.x - n.prev_x) * e;
|
||||||
|
float wy = n.prev_y + (n.y - n.prev_y) * e;
|
||||||
|
return ImVec2(origin_with_pan.x + wx * g_cam_zoom,
|
||||||
|
origin_with_pan.y + wy * g_cam_zoom);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Edges: depends + related as thin lines between ID-matched nodes.
|
||||||
|
std::unordered_map<std::string, int> idx_by_id;
|
||||||
|
idx_by_id.reserve(g_scan.nodes.size());
|
||||||
|
for (int i = 0; i < (int)g_scan.nodes.size(); ++i) idx_by_id[g_scan.nodes[i].id] = i;
|
||||||
|
|
||||||
|
auto draw_edge = [&](int src, int dst, ImU32 col) {
|
||||||
|
if (src < 0 || dst < 0) return;
|
||||||
|
ImVec2 a = node_screen(g_scan.nodes[src]);
|
||||||
|
ImVec2 b = node_screen(g_scan.nodes[dst]);
|
||||||
|
// Curve toward center.
|
||||||
|
ImVec2 mid((a.x + b.x) * 0.5f, (a.y + b.y) * 0.5f);
|
||||||
|
ImVec2 toCenter(origin_with_pan.x - mid.x, origin_with_pan.y - mid.y);
|
||||||
|
float len = std::sqrt(toCenter.x * toCenter.x + toCenter.y * toCenter.y);
|
||||||
|
if (len > 0.001f) { toCenter.x /= len; toCenter.y /= len; }
|
||||||
|
ImVec2 c1(mid.x + toCenter.x * 40.0f * g_cam_zoom, mid.y + toCenter.y * 40.0f * g_cam_zoom);
|
||||||
|
dl->AddBezierQuadratic(a, c1, b, col, 1.2f);
|
||||||
|
};
|
||||||
|
|
||||||
|
for (int i = 0; i < (int)g_scan.nodes.size(); ++i) {
|
||||||
|
const auto& n = g_scan.nodes[i];
|
||||||
|
for (const auto& d : n.depends) {
|
||||||
|
auto it = idx_by_id.find(d);
|
||||||
|
if (it != idx_by_id.end()) draw_edge(it->second, i, IM_COL32(160, 160, 180, 90));
|
||||||
|
}
|
||||||
|
for (const auto& r : n.related) {
|
||||||
|
auto it = idx_by_id.find(r);
|
||||||
|
if (it != idx_by_id.end()) draw_edge(it->second, i, IM_COL32(120, 100, 180, 50));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Picking + node draw.
|
||||||
|
g_hover = -1;
|
||||||
|
const ImVec2 mp = ImGui::GetMousePos();
|
||||||
|
const float node_r = kNodeRadius * g_cam_zoom;
|
||||||
|
|
||||||
|
for (int i = 0; i < (int)g_scan.nodes.size(); ++i) {
|
||||||
|
const auto& n = g_scan.nodes[i];
|
||||||
|
if (n.ring < 0) continue;
|
||||||
|
ImVec2 sp = node_screen(n);
|
||||||
|
|
||||||
|
// Cull off-screen.
|
||||||
|
if (sp.x < p0.x - node_r || sp.x > p1.x + node_r) continue;
|
||||||
|
if (sp.y < p0.y - node_r || sp.y > p1.y + node_r) continue;
|
||||||
|
|
||||||
|
float dx = mp.x - sp.x, dy = mp.y - sp.y;
|
||||||
|
bool over = hovered && (dx * dx + dy * dy) < node_r * node_r;
|
||||||
|
if (over) g_hover = i;
|
||||||
|
|
||||||
|
ImU32 col = ring_color(n.ring);
|
||||||
|
// Flow nodes diamond outline as visual differentiator.
|
||||||
|
if (n.kind == NodeKind::Flow) {
|
||||||
|
ImVec2 pts[4] = {
|
||||||
|
{ sp.x, sp.y - node_r },
|
||||||
|
{ sp.x + node_r, sp.y },
|
||||||
|
{ sp.x, sp.y + node_r },
|
||||||
|
{ sp.x - node_r, sp.y },
|
||||||
|
};
|
||||||
|
dl->AddConvexPolyFilled(pts, 4, col);
|
||||||
|
dl->AddPolyline(pts, 4, IM_COL32(255, 255, 255, 200), ImDrawFlags_Closed, 1.5f);
|
||||||
|
} else {
|
||||||
|
dl->AddCircleFilled(sp, node_r, col, 24);
|
||||||
|
ImU32 outline = (g_selected == i) ? IM_COL32(255, 255, 255, 255)
|
||||||
|
: (over ? IM_COL32(255, 255, 255, 200)
|
||||||
|
: IM_COL32(255, 255, 255, 80));
|
||||||
|
dl->AddCircle(sp, node_r, outline, 24, g_selected == i ? 2.5f : 1.2f);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text: ID (and short title on hover/select).
|
||||||
|
if (g_cam_zoom > 0.55f) {
|
||||||
|
const char* lbl = n.id.c_str();
|
||||||
|
ImVec2 ts = ImGui::CalcTextSize(lbl);
|
||||||
|
ImVec2 tp(sp.x - ts.x * 0.5f, sp.y - ts.y * 0.5f);
|
||||||
|
// White text with shadow for readability.
|
||||||
|
dl->AddText(ImVec2(tp.x + 1, tp.y + 1), IM_COL32(0, 0, 0, 200), lbl);
|
||||||
|
dl->AddText(tp, IM_COL32(255, 255, 255, 240), lbl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tooltip on hover (title).
|
||||||
|
if (over) {
|
||||||
|
ImGui::BeginTooltip();
|
||||||
|
ImGui::Text("%s %s", n.kind == NodeKind::Issue ? "[ISSUE]" : "[FLOW]", n.id.c_str());
|
||||||
|
ImGui::TextWrapped("%s", n.title.c_str());
|
||||||
|
ImGui::TextDisabled("status: %s · ring: %s · domain: %s",
|
||||||
|
n.status_eff.c_str(),
|
||||||
|
ring_label(n.ring),
|
||||||
|
n.domain.empty() ? "?" : n.domain.front().c_str());
|
||||||
|
ImGui::EndTooltip();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Click: select.
|
||||||
|
if (hovered && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && g_hover >= 0) {
|
||||||
|
g_selected = g_hover;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sector labels at outermost ring.
|
||||||
|
if (g_cam_zoom > 0.4f) {
|
||||||
|
const int N = 18;
|
||||||
|
const float r = radii[std::size(radii) - 1] - 8.0f;
|
||||||
|
for (int s = 0; s < N; ++s) {
|
||||||
|
float theta = -1.5708f + (s + 0.5f) * (2.0f * 3.14159265f / N);
|
||||||
|
ImVec2 sp(origin_with_pan.x + std::cos(theta) * r * g_cam_zoom,
|
||||||
|
origin_with_pan.y + std::sin(theta) * r * g_cam_zoom);
|
||||||
|
const std::string& dom = (s < (int)kDomainOrder.size()) ? kDomainOrder[s] : std::string("(other)");
|
||||||
|
ImVec2 ts = ImGui::CalcTextSize(dom.c_str());
|
||||||
|
dl->AddText(ImVec2(sp.x - ts.x * 0.5f, sp.y - ts.y * 0.5f),
|
||||||
|
IM_COL32(255, 255, 255, 110), dom.c_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dl->PopClipRect();
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Panels -------------------------------------------------------------
|
// ---- Panels -------------------------------------------------------------
|
||||||
|
|
||||||
static void draw_tree() {
|
static void draw_tree() {
|
||||||
@@ -154,40 +519,25 @@ static void draw_tree() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ImGui::Text("skill_tree v0.1.0 — fase A (shell)");
|
// HUD strip.
|
||||||
ImGui::Text("Root: %s", g_root.string().c_str());
|
int n_done = g_scan.count_by_status["completado"];
|
||||||
|
int n_inprog = g_scan.count_by_status["in-progress"];
|
||||||
|
int n_unlock = g_scan.count_by_status["pendiente_unlocked"];
|
||||||
|
int n_lock = g_scan.count_by_status["pendiente_locked"];
|
||||||
|
int n_defer = g_scan.count_by_status["deferred"] + g_scan.count_by_status["bloqueado"];
|
||||||
|
int xp_total = 10 * n_done; // crude: tune later (epic=10 etc.)
|
||||||
|
int level = (int)std::sqrt((float)xp_total);
|
||||||
|
|
||||||
|
ImGui::Text("LV %d · XP %d · %d done · %d in-progress · %d unlocked · %d locked · %d deferred",
|
||||||
|
level, xp_total, n_done, n_inprog, n_unlock, n_lock, n_defer);
|
||||||
|
ImGui::SameLine();
|
||||||
|
if (ImGui::SmallButton(TI_REFRESH " Reload (F5)")) reload_scan();
|
||||||
|
if (ImGui::IsKeyPressed(ImGuiKey_F5, false)) reload_scan();
|
||||||
|
ImGui::SameLine();
|
||||||
|
if (ImGui::SmallButton("Reset view")) { g_cam_x = g_cam_y = 0.0f; g_cam_zoom = 1.0f; }
|
||||||
|
|
||||||
ImGui::Separator();
|
ImGui::Separator();
|
||||||
|
draw_canvas();
|
||||||
ImGui::Text("Total: %d nodos (%d issues + %d flows). Parse errors: %d",
|
|
||||||
(int)g_scan.nodes.size(),
|
|
||||||
g_scan.count_by_kind["issue"],
|
|
||||||
g_scan.count_by_kind["flow"],
|
|
||||||
g_scan.parse_errors);
|
|
||||||
|
|
||||||
if (ImGui::Button(TI_REFRESH " Reload (F5)")) reload_scan();
|
|
||||||
if (ImGui::IsKeyPressed(ImGuiKey_F5, false)) reload_scan();
|
|
||||||
|
|
||||||
ImGui::SeparatorText("Por status");
|
|
||||||
for (auto& [k, v] : g_scan.count_by_status) {
|
|
||||||
ImGui::Text(" %-16s %d", k.c_str(), v);
|
|
||||||
}
|
|
||||||
|
|
||||||
ImGui::SeparatorText("Por dominio");
|
|
||||||
for (auto& [k, v] : g_scan.count_by_domain) {
|
|
||||||
ImGui::Text(" %-22s %d", k.c_str(), v);
|
|
||||||
}
|
|
||||||
|
|
||||||
ImGui::SeparatorText("Nodos (lista provisional — render anillos en 0109b)");
|
|
||||||
ImGui::BeginChild("nodes_list", ImVec2(0, 0), ImGuiChildFlags_Borders);
|
|
||||||
for (int i = 0; i < (int)g_scan.nodes.size(); ++i) {
|
|
||||||
const auto& n = g_scan.nodes[i];
|
|
||||||
char label[512];
|
|
||||||
std::snprintf(label, sizeof(label), "%s %s · %s · %s##%d",
|
|
||||||
n.kind == NodeKind::Issue ? "[ISSUE]" : "[FLOW] ",
|
|
||||||
n.id.c_str(), n.status.c_str(), n.title.c_str(), i);
|
|
||||||
if (ImGui::Selectable(label, g_selected == i)) g_selected = i;
|
|
||||||
}
|
|
||||||
ImGui::EndChild();
|
|
||||||
ImGui::End();
|
ImGui::End();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,10 +555,11 @@ static void draw_inspector() {
|
|||||||
ImGui::Text("%s %s", n.kind == NodeKind::Issue ? "[ISSUE]" : "[FLOW]", n.id.c_str());
|
ImGui::Text("%s %s", n.kind == NodeKind::Issue ? "[ISSUE]" : "[FLOW]", n.id.c_str());
|
||||||
ImGui::TextWrapped("%s", n.title.c_str());
|
ImGui::TextWrapped("%s", n.title.c_str());
|
||||||
ImGui::Separator();
|
ImGui::Separator();
|
||||||
ImGui::Text("status: %s", n.status.c_str());
|
ImGui::Text("status: %s (eff: %s)", n.status_raw.c_str(), n.status_eff.c_str());
|
||||||
if (!n.type.empty()) ImGui::Text("type: %s", n.type.c_str());
|
ImGui::Text("ring: %s", ring_label(n.ring));
|
||||||
if (!n.priority.empty()) ImGui::Text("priority: %s", n.priority.c_str());
|
if (!n.type.empty()) ImGui::Text("type: %s", n.type.c_str());
|
||||||
ImGui::Text("file: %s", n.file_path.c_str());
|
if (!n.priority.empty()) ImGui::Text("priority: %s", n.priority.c_str());
|
||||||
|
ImGui::Text("file: %s", n.file_path.c_str());
|
||||||
|
|
||||||
ImGui::SeparatorText("domain");
|
ImGui::SeparatorText("domain");
|
||||||
for (const auto& d : n.domain) ImGui::BulletText("%s", d.c_str());
|
for (const auto& d : n.domain) ImGui::BulletText("%s", d.c_str());
|
||||||
@@ -223,7 +574,7 @@ static void draw_inspector() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ImGui::Separator();
|
ImGui::Separator();
|
||||||
ImGui::TextDisabled("Botones [Generate ideas] y [Run autonomous-task] llegan en 0109e/f.");
|
ImGui::TextDisabled("Botones [Generate ideas] / [Run autonomous-task] llegan en 0109e/f.");
|
||||||
ImGui::End();
|
ImGui::End();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,18 +592,32 @@ static int run_self_test() {
|
|||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
g_scan = scan_registry(g_root);
|
g_scan = scan_registry(g_root);
|
||||||
std::printf("skill_tree v0.1.0\n");
|
derive_status_eff(g_scan.nodes);
|
||||||
|
apply_layout(g_scan.nodes);
|
||||||
|
recount(g_scan);
|
||||||
|
|
||||||
|
std::printf("skill_tree v0.2.0\n");
|
||||||
std::printf("root: %s\n", g_root.string().c_str());
|
std::printf("root: %s\n", g_root.string().c_str());
|
||||||
std::printf("nodes: %zu (%d issues + %d flows)\n",
|
std::printf("nodes: %zu (%d issues + %d flows)\n",
|
||||||
g_scan.nodes.size(),
|
g_scan.nodes.size(),
|
||||||
g_scan.count_by_kind["issue"],
|
g_scan.count_by_kind["issue"],
|
||||||
g_scan.count_by_kind["flow"]);
|
g_scan.count_by_kind["flow"]);
|
||||||
std::printf("parse_errors: %d\n", g_scan.parse_errors);
|
std::printf("parse_errors: %d\n", g_scan.parse_errors);
|
||||||
std::printf("by_status:\n");
|
|
||||||
for (auto& [k, v] : g_scan.count_by_status) std::printf(" %-16s %d\n", k.c_str(), v);
|
// Ring breakdown.
|
||||||
std::printf("by_domain:\n");
|
int per_ring[5] = {0};
|
||||||
for (auto& [k, v] : g_scan.count_by_domain) std::printf(" %-22s %d\n", k.c_str(), v);
|
int unmapped = 0;
|
||||||
return g_scan.parse_errors == 0 ? 0 : 2;
|
for (const auto& n : g_scan.nodes) {
|
||||||
|
if (n.ring < 0 || n.ring >= 5) ++unmapped;
|
||||||
|
else ++per_ring[n.ring];
|
||||||
|
}
|
||||||
|
std::printf("by_ring: done=%d in-progress=%d unlocked=%d locked=%d deferred=%d unmapped=%d\n",
|
||||||
|
per_ring[0], per_ring[1], per_ring[2], per_ring[3], per_ring[4], unmapped);
|
||||||
|
|
||||||
|
std::printf("by_status_eff:\n");
|
||||||
|
for (auto& [k, v] : g_scan.count_by_status) std::printf(" %-22s %d\n", k.c_str(), v);
|
||||||
|
|
||||||
|
return (g_scan.parse_errors == 0 && unmapped == 0) ? 0 : 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Entry --------------------------------------------------------------
|
// ---- Entry --------------------------------------------------------------
|
||||||
@@ -263,7 +628,7 @@ int main(int argc, char** argv) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
g_root = discover_registry_root();
|
g_root = discover_registry_root();
|
||||||
g_scan = scan_registry(g_root);
|
reload_scan();
|
||||||
|
|
||||||
static fn_ui::PanelToggle panels[] = {
|
static fn_ui::PanelToggle panels[] = {
|
||||||
{ "Tree", nullptr, &g_show_tree },
|
{ "Tree", nullptr, &g_show_tree },
|
||||||
@@ -272,7 +637,7 @@ int main(int argc, char** argv) {
|
|||||||
|
|
||||||
fn::AppConfig cfg;
|
fn::AppConfig cfg;
|
||||||
cfg.title = "skill_tree";
|
cfg.title = "skill_tree";
|
||||||
cfg.about = { "skill_tree", "0.1.0",
|
cfg.about = { "skill_tree", "0.2.0",
|
||||||
"Mapa interactivo de issues+flows en anillos concentricos por estado." };
|
"Mapa interactivo de issues+flows en anillos concentricos por estado." };
|
||||||
cfg.log = { "skill_tree.log", 1 };
|
cfg.log = { "skill_tree.log", 1 };
|
||||||
cfg.panels = panels;
|
cfg.panels = panels;
|
||||||
|
|||||||
Reference in New Issue
Block a user