From 17c99cd9d25a86b8eb4031303575645f8032a1c8 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sun, 17 May 2026 20:23:01 +0200 Subject: [PATCH] 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) --- main.cpp | 149 +++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 127 insertions(+), 22 deletions(-) diff --git a/main.cpp b/main.cpp index 1b20b00..329baef 100644 --- a/main.cpp +++ b/main.cpp @@ -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& nodes) { + // Set of valid domains for fast lookup. + std::unordered_set domain_set(kDomainOrder.begin(), kDomainOrder.end()); + std::vector 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& 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 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> final_pos; + final_pos.reserve(out.size()); + for (const auto& o : out) final_pos[o.id] = { o.x, o.y }; + + { + std::map, std::vector> 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] = {