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:
@@ -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] = {
|
||||
|
||||
Reference in New Issue
Block a user