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>
This commit is contained in:
2026-05-17 20:23:01 +02:00
parent 47b1b0a465
commit 17c99cd9d2
+127 -22
View File
@@ -81,16 +81,32 @@ static const fn_ring::StatusRingMap kStatusMap = {
{"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);
// 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) {
@@ -235,6 +251,12 @@ 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
@@ -244,13 +266,24 @@ 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;
li.domain = n.domain.empty() ? "(unknown)" : n.domain.front();
// 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));
}
@@ -265,31 +298,103 @@ static void apply_layout(std::vector<Node>& nodes) {
auto out = fn_ring::compute_ring_layout(input, cfg, kStatusMap, kDomainOrder);
// Map by id for O(1) assignment.
// 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 = by_id.find(n.id);
if (it == by_id.end()) continue;
const auto& o = *it->second;
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;
// 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.prev_x = fx;
n.prev_y = fy;
n.anim_start = now - kAnimDur;
}
n.x = o.x;
n.y = o.y;
n.x = fx;
n.y = fy;
n.ring = o.ring;
n.sector = o.sector;
n.anim_prev_status = n.status_eff;
@@ -478,7 +583,7 @@ static void draw_canvas() {
bool over = hovered && (dx * dx + dy * dy) < r * r;
if (over) g_hover = i;
ImU32 col = ring_color(n.ring);
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] = {