fix(skill_tree): anti-overlap + flows mas visibles

- kNodeRadius 18 -> 10 (mas cards caben sin pisarse)
- ring_radii expandidos { 0, 200, 380, 600, 900, 1200 } (mas banda por anillo)
- bin_padding 18 -> 28 (margen entre anillos)
- Initial zoom = auto-fit (Min(canvas) * 0.92 / 2400) en lugar de zoom=1
- Boton 'Fit view' reemplaza 'Reset view'
- Flows ahora 1.55x mayores que issues + outline cyan permanente para
  destacar entre la marea de 160 circulos
- Render en 2 pasadas: issues primero (background), flows on top
- Labels de ID siempre visibles en flows (no condicionado al zoom)
- Ring bands con tint sutil por status para legibilidad

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-17 20:16:29 +02:00
parent 72552bcb5a
commit 78910bc295
+73 -35
View File
@@ -235,8 +235,12 @@ 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
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) {
@@ -255,8 +259,8 @@ static void apply_layout(std::vector<Node>& nodes) {
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.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);
@@ -378,10 +382,35 @@ static void draw_canvas() {
}
// 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);
// 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));
@@ -428,51 +457,56 @@ static void draw_canvas() {
}
}
// Picking + node draw.
// Picking + node draw. Two passes: issues first (background), flows on top.
g_hover = -1;
const ImVec2 mp = ImGui::GetMousePos();
const float node_r = kNodeRadius * g_cam_zoom;
const float node_r_issue = kNodeRadius * g_cam_zoom;
const float node_r_flow = kNodeRadius * kFlowRadiusMul * g_cam_zoom;
for (int i = 0; i < (int)g_scan.nodes.size(); ++i) {
auto draw_one = [&](int i, bool flow_pass) {
const auto& n = g_scan.nodes[i];
if (n.ring < 0) continue;
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;
// 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;
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) < node_r * node_r;
bool over = hovered && (dx * dx + dy * dy) < r * r;
if (over) g_hover = i;
ImU32 col = ring_color(n.ring);
// Flow nodes diamond outline as visual differentiator.
if (n.kind == NodeKind::Flow) {
if (is_flow) {
// Diamond + thick cyan outline so flows pop out of the issue sea.
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 },
{ 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);
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);
: 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: ID (and short title on hover/select).
if (g_cam_zoom > 0.55f) {
// 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);
// 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);
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).
@@ -486,7 +520,11 @@ static void draw_canvas() {
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) {
@@ -496,7 +534,7 @@ static void draw_canvas() {
// 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;
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,
@@ -534,7 +572,7 @@ static void draw_tree() {
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; }
if (ImGui::SmallButton("Fit view")) { g_fit_pending = true; }
ImGui::Separator();
draw_canvas();