Files
skill_tree/main.cpp
T
egutierrez 17c99cd9d2 feat(skill_tree): 3-bucket colors + grid anti-collision por bin
Cambios visuales pedidos por el usuario:

1. Colores simplificados a 3 buckets independientes del ring:
   - done    (completado/completed)         -> green-500  #22c55e
   - planned (in-progress)                  -> amber-500  #f59e0b
   - todo    (resto: pendiente/locked/...)  -> violet-500 #a855f7
   Ring sigue separando geografia, color cuenta el estado.

2. Hash-distribute unknown domains: nodos sin domain valido (61 sobre 167)
   ya no se amontonan en sector 17. Se distribuyen via FNV-1a(id) % 18
   entre los 18 sectores canonicos.

3. Anti-collision grid 2D por bin (ring,sector). compute_ring_layout
   distribuia solo radialmente; ahora main.cpp resuelve bins poblados
   con un grid rows x cols (con brick offset entre filas) que ocupa
   tanto eje radial como angular del sector. Determinista (sort by id).

self-test: 167 nodes, 0 parse_errors, 0 unmapped. Linux + Windows OK.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:23:01 +02:00

791 lines
30 KiB
C++

#include <imgui.h>
#include "app_base.h"
#include "core/panel_menu.h"
#include "core/icons_tabler.h"
#include "core/logger.h"
#include "core/parse_md_frontmatter.h"
#include "core/compute_ring_layout.h"
#include <algorithm>
#include <chrono>
#include <cmath>
#include <cstdlib>
#include <cstring>
#include <filesystem>
#include <fstream>
#include <map>
#include <sstream>
#include <string>
#include <unordered_map>
#include <unordered_set>
#include <vector>
namespace fs = std::filesystem;
// ---- Node model ---------------------------------------------------------
enum class NodeKind { Issue, Flow };
struct Node {
NodeKind kind;
std::string id;
std::string title;
std::string status_raw; // status as read from frontmatter
std::string status_eff; // effective status after lock derivation
std::string type;
std::string priority;
std::vector<std::string> domain;
std::vector<std::string> depends; // for issues
std::vector<std::string> related;
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 {
std::vector<Node> nodes;
std::map<std::string, int> count_by_status;
std::map<std::string, int> count_by_domain;
std::map<std::string, int> count_by_kind;
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},
};
// 3 buckets de color: done / planned / todo. Independiente del ring (que
// sigue agrupando geograficamente). El usuario pidio simplificar.
enum class Bucket { Done, Planned, Todo };
static Bucket status_bucket(const std::string& status_eff) {
if (status_eff == "completado" || status_eff == "completed") return Bucket::Done;
if (status_eff == "in-progress") return Bucket::Planned;
return Bucket::Todo;
}
static ImU32 bucket_color(Bucket b) {
switch (b) {
case Bucket::Done: return IM_COL32( 34, 197, 94, 240); // green-500
case Bucket::Planned: return IM_COL32(245, 158, 11, 240); // amber-500
case Bucket::Todo: return IM_COL32(168, 85, 247, 240); // violet-500
}
return IM_COL32(150, 150, 150, 200);
}
static const char* bucket_label(Bucket b) {
switch (b) {
case Bucket::Done: return "done";
case Bucket::Planned: return "planned";
case Bucket::Todo: return "todo";
}
return "?";
}
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 -------------------------------------------
static fs::path discover_registry_root() {
if (const char* env = std::getenv("FN_REGISTRY_ROOT")) {
fs::path p(env);
if (fs::exists(p / "registry.db")) return p;
}
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;
}
return {};
}
// ---- Helpers ------------------------------------------------------------
static std::string read_file(const fs::path& p) {
std::ifstream f(p);
if (!f) return {};
std::stringstream ss; ss << f.rdbuf();
return ss.str();
}
static std::string get_str(const fn_md::Frontmatter& fm, const char* key) {
auto it = fm.fields.find(key);
if (it == fm.fields.end()) return {};
if (auto* s = std::get_if<std::string>(&it->second)) return *s;
return {};
}
static std::vector<std::string> get_list(const fn_md::Frontmatter& fm, const char* key) {
auto it = fm.fields.find(key);
if (it == fm.fields.end()) return {};
if (auto* v = std::get_if<std::vector<std::string>>(&it->second)) return *v;
if (auto* s = std::get_if<std::string>(&it->second)) {
if (!s->empty()) return { *s };
}
return {};
}
static double now_seconds() {
using namespace std::chrono;
return duration_cast<duration<double>>(steady_clock::now().time_since_epoch()).count();
}
// ---- Scanner ------------------------------------------------------------
static void scan_dir(const fs::path& dir, NodeKind kind, ScanResult& out) {
if (!fs::exists(dir) || !fs::is_directory(dir)) return;
for (const auto& entry : fs::directory_iterator(dir)) {
if (!entry.is_regular_file()) continue;
const auto& p = entry.path();
if (p.extension() != ".md") continue;
auto stem = p.stem().string();
if (stem == "template" || stem == "INDEX" || stem == "README" || stem == "AGENT_GUIDE") continue;
auto content = read_file(p);
auto fm = fn_md::parse_md_frontmatter(content);
if (!fm.has_frontmatter) { ++out.parse_errors; continue; }
Node n;
n.kind = kind;
n.id = get_str(fm, "id");
if (n.id.empty()) n.id = get_str(fm, "name");
n.title = get_str(fm, kind == NodeKind::Issue ? "title" : "name");
n.status_raw = get_str(fm, "status");
n.type = get_str(fm, "type");
n.priority = get_str(fm, "priority");
n.domain = get_list(fm, "domain");
n.depends = get_list(fm, kind == NodeKind::Issue ? "depends" : "related_issues");
n.related = get_list(fm, "related");
n.file_path = p.string();
if (n.status_raw.empty()) n.status_raw = "(unknown)";
if (n.domain.empty()) n.domain.push_back("(unknown)");
out.nodes.push_back(std::move(n));
}
}
static ScanResult scan_registry(const fs::path& root) {
ScanResult r;
scan_dir(root / "dev" / "issues", 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" / "completed", NodeKind::Flow, 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 -----------------------------------------------------------
static fs::path g_root;
static ScanResult g_scan;
static bool g_show_tree = true;
static bool g_show_inspector = true;
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 uint32_t fnv1a(const std::string& s) {
uint32_t h = 2166136261u;
for (unsigned char c : s) { h ^= c; h *= 16777619u; }
return h;
}
static const float kNodeRadius = 10.0f;
static const float kFlowRadiusMul = 1.55f;
static const float kAnimDur = 1.0f; // seconds for ring migration lerp
static const float kWorldExtent = 1200.0f * 2.0f; // diameter to fit
static const std::vector<float> kRingRadii = { 0.0f, 200.0f, 380.0f, 600.0f, 900.0f, 1200.0f };
static bool g_fit_pending = true; // auto-fit on first frame
// Apply ring layout to nodes, preserving anim state across reloads.
static void apply_layout(std::vector<Node>& nodes) {
// Set of valid domains for fast lookup.
std::unordered_set<std::string> domain_set(kDomainOrder.begin(), kDomainOrder.end());
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;
// Domain: usar el primero del frontmatter si esta en la allowlist; si no,
// distribuir deterministicamente por hash entre los 18 sectores canonicos
// (evita que todos los "(unknown)" se amontonen en el sector fallback).
std::string d = n.domain.empty() ? "" : n.domain.front();
if (d.empty() || domain_set.find(d) == domain_set.end()) {
uint32_t h = fnv1a(n.id);
d = kDomainOrder[h % kDomainOrder.size()];
}
li.domain = d;
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 = kRingRadii;
cfg.bin_padding = 28.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 (refs to fn output) for ring/sector lookup later.
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);
// ---- Anti-collision: redistribuir cada bin (ring,sector) como grid 2D ----
// compute_ring_layout solo distribuye RADIAL dentro del bin. Cuando hay
// muchos nodos por bin, se pisan. Aqui les damos tambien spread ANGULAR
// dentro del slice del sector.
std::unordered_map<std::string, std::pair<float,float>> final_pos;
final_pos.reserve(out.size());
for (const auto& o : out) final_pos[o.id] = { o.x, o.y };
{
std::map<std::pair<int,int>, std::vector<const fn_ring::LayoutOutput*>> bins;
for (const auto& o : out) {
if (o.ring < 0) continue;
bins[{o.ring, o.sector}].push_back(&o);
}
const float min_sep = kNodeRadius * 2.0f * 1.2f; // world units
const int n_sectors = 18;
const float sector_angle = 2.0f * 3.14159265f / float(n_sectors);
const float pad_r = 35.0f;
const float kStart = -1.5708f;
for (auto& [key, list] : bins) {
int ring = key.first;
int sector = key.second;
if (list.size() < 2) continue;
std::sort(list.begin(), list.end(),
[](const fn_ring::LayoutOutput* a, const fn_ring::LayoutOutput* b) {
return a->id < b->id;
});
float r_inner = kRingRadii[ring];
if (r_inner == 0.0f) r_inner = 30.0f;
float r_outer = kRingRadii[ring + 1];
float r_lo = r_inner + pad_r;
float r_hi = r_outer - pad_r;
if (r_lo > r_hi) { r_lo = r_hi = 0.5f * (r_inner + r_outer); }
float r_mid = 0.5f * (r_lo + r_hi);
float theta_center = kStart + (float(sector) + 0.5f) * sector_angle;
float half_arc = sector_angle * 0.45f;
float theta_lo = theta_center - half_arc;
float theta_hi = theta_center + half_arc;
int N = int(list.size());
int rad_cap = std::max(1, int((r_hi - r_lo) / min_sep));
int ang_cap = std::max(1, int(((theta_hi - theta_lo) * r_mid) / min_sep));
// Elige cols/rows para que cols*rows >= N, prefiriendo angular si
// el sector es ancho a ese radio.
int cols = std::clamp(int(std::ceil(std::sqrt(float(N) * float(ang_cap) / float(std::max(1, rad_cap))))), 1, ang_cap);
int rows = (N + cols - 1) / cols;
if (rows > rad_cap) {
rows = rad_cap;
cols = (N + rows - 1) / rows;
}
if (rows < 1) rows = 1;
if (cols < 1) cols = 1;
for (int k = 0; k < N; ++k) {
int row = k / cols;
int col = k % cols;
float r = (rows == 1) ? r_mid
: r_lo + (float(row) + 0.5f) * (r_hi - r_lo) / float(rows);
float col_offset = (row & 1) ? 0.5f : 0.0f; // brick offset entre filas
float t = (cols == 1) ? theta_center
: theta_lo + (float(col) + 0.5f + col_offset)
* (theta_hi - theta_lo) / float(cols + ((row & 1) ? 1 : 0));
final_pos[list[k]->id] = { std::cos(t) * r, std::sin(t) * r };
}
}
}
double now = now_seconds();
for (auto& n : nodes) {
auto it_r = by_id.find(n.id);
if (it_r == by_id.end()) continue;
const auto& o = *it_r->second;
auto it_p = final_pos.find(n.id);
float fx = (it_p != final_pos.end()) ? it_p->second.first : o.x;
float fy = (it_p != final_pos.end()) ? it_p->second.second : o.y;
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()) {
n.prev_x = fx;
n.prev_y = fy;
n.anim_start = now - kAnimDur;
}
n.x = fx;
n.y = fy;
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() {
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;
fn_log::log_info("skill_tree: reloaded %d nodes, %d 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.
ImVec2 origin_with_pan(center.x + g_cam_x, center.y + g_cam_y);
// Auto-fit on first frame (after avail is known): scale so outer ring fits with margin.
if (g_fit_pending) {
float min_dim = std::min(avail.x, avail.y);
g_cam_zoom = std::clamp((min_dim * 0.92f) / kWorldExtent, 0.05f, 4.0f);
g_cam_x = 0.0f;
g_cam_y = 0.0f;
g_fit_pending = false;
}
// Ring band fills with subtle tint by status.
static const ImU32 ring_band_tint[5] = {
IM_COL32( 30, 60, 30, 40), // done
IM_COL32( 60, 50, 20, 40), // in-progress
IM_COL32( 60, 20, 60, 40), // unlocked
IM_COL32( 32, 32, 40, 40), // locked
IM_COL32( 20, 20, 24, 40), // deferred
};
for (int i = 0; i < (int)kRingRadii.size() - 1; ++i) {
float ri = kRingRadii[i] * g_cam_zoom;
float ro = kRingRadii[i + 1] * g_cam_zoom;
// Filled annulus via two circles (approximate); ImDrawList lacks ring fill.
// Trick: filled circle outer alpha + cut center with inner. Use AddCircleFilled twice
// — outer in tint, inner in bg to subtract. Cheaper: thick line outline + tinted bg.
dl->AddCircleFilled(origin_with_pan, ro, ring_band_tint[i], 96);
if (ri > 0.5f) dl->AddCircleFilled(origin_with_pan, ri, IM_COL32(18, 18, 22, 255), 96);
}
// Ring outlines.
for (int i = 1; i < (int)kRingRadii.size(); ++i) {
dl->AddCircle(origin_with_pan, kRingRadii[i] * g_cam_zoom, IM_COL32(255, 255, 255, 30), 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. Two passes: issues first (background), flows on top.
g_hover = -1;
const ImVec2 mp = ImGui::GetMousePos();
const float node_r_issue = kNodeRadius * g_cam_zoom;
const float node_r_flow = kNodeRadius * kFlowRadiusMul * g_cam_zoom;
auto draw_one = [&](int i, bool flow_pass) {
const auto& n = g_scan.nodes[i];
if (n.ring < 0) return;
bool is_flow = (n.kind == NodeKind::Flow);
if (is_flow != flow_pass) return;
ImVec2 sp = node_screen(n);
const float r = is_flow ? node_r_flow : node_r_issue;
if (sp.x < p0.x - r || sp.x > p1.x + r) return;
if (sp.y < p0.y - r || sp.y > p1.y + r) return;
float dx = mp.x - sp.x, dy = mp.y - sp.y;
bool over = hovered && (dx * dx + dy * dy) < r * r;
if (over) g_hover = i;
ImU32 col = bucket_color(status_bucket(n.status_eff));
if (is_flow) {
// Diamond + thick cyan outline so flows pop out of the issue sea.
ImVec2 pts[4] = {
{ sp.x, sp.y - r },
{ sp.x + r, sp.y },
{ sp.x, sp.y + r },
{ sp.x - r, sp.y },
};
dl->AddConvexPolyFilled(pts, 4, col);
ImU32 outline = (g_selected == i) ? IM_COL32(255, 255, 255, 255)
: IM_COL32(34, 211, 238, 255); // cyan-400 always
dl->AddPolyline(pts, 4, outline, ImDrawFlags_Closed,
(g_selected == i) ? 3.0f : 2.0f);
} else {
dl->AddCircleFilled(sp, r, col, 20);
ImU32 outline = (g_selected == i) ? IM_COL32(255, 255, 255, 255)
: (over ? IM_COL32(255, 255, 255, 220)
: IM_COL32(255, 255, 255, 90));
dl->AddCircle(sp, r, outline, 20, g_selected == i ? 2.5f : 1.0f);
}
// Text only when zoomed in or this is a flow (always show flow labels).
if ((g_cam_zoom > 0.65f) || is_flow) {
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);
dl->AddText(ImVec2(tp.x + 1, tp.y + 1), IM_COL32(0, 0, 0, 220), lbl);
dl->AddText(tp, IM_COL32(255, 255, 255, 245), 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();
}
};
// Pass 1: issues. Pass 2: flows on top.
for (int i = 0; i < (int)g_scan.nodes.size(); ++i) draw_one(i, false);
for (int i = 0; i < (int)g_scan.nodes.size(); ++i) draw_one(i, true);
// 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 = kRingRadii.back() - 12.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 -------------------------------------------------------------
static void draw_tree() {
if (!ImGui::Begin(TI_GRAPH " Tree", &g_show_tree)) {
ImGui::End();
return;
}
// HUD strip.
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("Fit view")) { g_fit_pending = true; }
ImGui::Separator();
draw_canvas();
ImGui::End();
}
static void draw_inspector() {
if (!ImGui::Begin(TI_INFO_CIRCLE " Inspector", &g_show_inspector)) {
ImGui::End();
return;
}
if (g_selected < 0 || g_selected >= (int)g_scan.nodes.size()) {
ImGui::TextDisabled("Click en un nodo del Tree para inspeccionar.");
ImGui::End();
return;
}
const auto& n = g_scan.nodes[g_selected];
ImGui::Text("%s %s", n.kind == NodeKind::Issue ? "[ISSUE]" : "[FLOW]", n.id.c_str());
ImGui::TextWrapped("%s", n.title.c_str());
ImGui::Separator();
ImGui::Text("status: %s (eff: %s)", n.status_raw.c_str(), n.status_eff.c_str());
ImGui::Text("ring: %s", ring_label(n.ring));
if (!n.type.empty()) ImGui::Text("type: %s", n.type.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");
for (const auto& d : n.domain) ImGui::BulletText("%s", d.c_str());
if (!n.depends.empty()) {
ImGui::SeparatorText(n.kind == NodeKind::Issue ? "depends" : "related_issues");
for (const auto& d : n.depends) ImGui::BulletText("%s", d.c_str());
}
if (!n.related.empty()) {
ImGui::SeparatorText("related");
for (const auto& r : n.related) ImGui::BulletText("%s", r.c_str());
}
ImGui::Separator();
ImGui::TextDisabled("Botones [Generate ideas] / [Run autonomous-task] llegan en 0109e/f.");
ImGui::End();
}
static void render() {
if (g_show_tree) draw_tree();
if (g_show_inspector) draw_inspector();
}
// ---- Self-test ----------------------------------------------------------
static int run_self_test() {
g_root = discover_registry_root();
if (g_root.empty()) {
std::fprintf(stderr, "skill_tree --self-test: FN_REGISTRY_ROOT not found\n");
return 1;
}
g_scan = scan_registry(g_root);
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("nodes: %zu (%d issues + %d flows)\n",
g_scan.nodes.size(),
g_scan.count_by_kind["issue"],
g_scan.count_by_kind["flow"]);
std::printf("parse_errors: %d\n", g_scan.parse_errors);
// Ring breakdown.
int per_ring[5] = {0};
int unmapped = 0;
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 --------------------------------------------------------------
int main(int argc, char** argv) {
for (int i = 1; i < argc; ++i) {
if (std::strcmp(argv[i], "--self-test") == 0) return run_self_test();
}
g_root = discover_registry_root();
reload_scan();
static fn_ui::PanelToggle panels[] = {
{ "Tree", nullptr, &g_show_tree },
{ "Inspector", nullptr, &g_show_inspector },
};
fn::AppConfig cfg;
cfg.title = "skill_tree";
cfg.about = { "skill_tree", "0.2.0",
"Mapa interactivo de issues+flows en anillos concentricos por estado." };
cfg.log = { "skill_tree.log", 1 };
cfg.panels = panels;
cfg.panel_count = sizeof(panels) / sizeof(panels[0]);
return fn::run_app(cfg, render);
}