diff --git a/cpp/functions/viz/sankey.cpp b/cpp/functions/viz/sankey.cpp new file mode 100644 index 00000000..c42676fe --- /dev/null +++ b/cpp/functions/viz/sankey.cpp @@ -0,0 +1,233 @@ +#include "viz/sankey.h" + +#include +#include +#include + +namespace { + +// BFS topologico: asigna a cada nodo el max(level(src)+1). +// Nodos sin in-edges arrancan en nivel 0. +std::vector compute_levels(int n_nodes, const std::vector& links) { + std::vector indeg(n_nodes, 0); + std::vector> out(n_nodes); + for (const auto& l : links) { + if (l.src < 0 || l.src >= n_nodes || l.dst < 0 || l.dst >= n_nodes) continue; + out[l.src].push_back(l.dst); + indeg[l.dst]++; + } + std::vector level(n_nodes, 0); + std::vector visited(n_nodes, false); + std::queue q; + for (int i = 0; i < n_nodes; i++) { + if (indeg[i] == 0) { + q.push(i); + visited[i] = true; + } + } + while (!q.empty()) { + int u = q.front(); q.pop(); + for (int v : out[u]) { + if (level[v] < level[u] + 1) level[v] = level[u] + 1; + indeg[v]--; + if (indeg[v] == 0 && !visited[v]) { + visited[v] = true; + q.push(v); + } + } + } + return level; +} + +ImU32 node_color(int idx) { + // Paleta indigo/teal/amber rotativa. + static const ImU32 palette[] = { + IM_COL32(120, 144, 252, 230), + IM_COL32( 92, 200, 200, 230), + IM_COL32(250, 176, 92, 230), + IM_COL32(180, 120, 200, 230), + IM_COL32( 92, 200, 130, 230), + IM_COL32(250, 120, 130, 230), + IM_COL32(180, 200, 92, 230), + IM_COL32(120, 200, 230, 230), + }; + constexpr int N = sizeof(palette) / sizeof(palette[0]); + return palette[((idx % N) + N) % N]; +} + +} // namespace + +void sankey(const char* id, + const std::vector& nodes, + const std::vector& links, + ImVec2 size) { + ImGui::PushID(id); + + ImVec2 avail = ImGui::GetContentRegionAvail(); + float W = (size.x > 0.0f) ? size.x : avail.x; + float H = (size.y > 0.0f) ? size.y : 300.0f; + + ImVec2 origin = ImGui::GetCursorScreenPos(); + ImGui::Dummy(ImVec2(W, H)); + + int N = (int)nodes.size(); + if (N == 0) { ImGui::PopID(); return; } + + auto levels = compute_levels(N, links); + int L = 0; + for (int v : levels) L = std::max(L, v + 1); + if (L < 1) L = 1; + + // Magnitud por nodo = max(in_total, out_total). Para sources es out_total, + // para sinks es in_total, en intermedios usamos el max para que los + // rectangulos encajen visualmente. + std::vector in_tot (N, 0.0f); + std::vector out_tot(N, 0.0f); + for (const auto& l : links) { + if (l.src < 0 || l.src >= N || l.dst < 0 || l.dst >= N) continue; + in_tot [l.dst] += l.value; + out_tot[l.src] += l.value; + } + std::vector magnitude(N, 0.0f); + for (int i = 0; i < N; i++) magnitude[i] = std::max(in_tot[i], out_tot[i]); + + // Por columna: total magnitud + lista de nodos. + std::vector> by_col(L); + for (int i = 0; i < N; i++) by_col[levels[i]].push_back(i); + + // Geometria + const float pad_x = 16.0f; + const float pad_y = 16.0f; + const float node_w = 14.0f; + const float gap_y = 6.0f; + float avail_w = std::max(1.0f, W - 2.0f * pad_x); + float col_pitch = (L > 1) ? (avail_w - node_w) / (float)(L - 1) : 0.0f; + float avail_h = std::max(1.0f, H - 2.0f * pad_y); + + // Por columna calcula scale: pixel/value tal que (sum mag + gaps) cabe en avail_h. + std::vector col_scale(L, 1.0f); + std::vector col_yoff (L, 0.0f); + for (int c = 0; c < L; c++) { + float sum_m = 0.0f; + for (int i : by_col[c]) sum_m += magnitude[i]; + int cnt = (int)by_col[c].size(); + float gaps = (cnt > 1) ? (cnt - 1) * gap_y : 0.0f; + float usable = std::max(1.0f, avail_h - gaps); + col_scale[c] = (sum_m > 0.0f) ? (usable / sum_m) : 0.0f; + // Centrar verticalmente + float total_h = sum_m * col_scale[c] + gaps; + col_yoff[c] = pad_y + (avail_h - total_h) * 0.5f; + } + + // Posiciones de nodos: para cada nodo, y_top y altura. + struct NodeBox { + float x_left, x_right; + float y_top, y_bot; + }; + std::vector boxes(N); + // y_cursor por columna + std::vector y_cursor(L, 0.0f); + for (int c = 0; c < L; c++) y_cursor[c] = col_yoff[c]; + for (int c = 0; c < L; c++) { + for (int i : by_col[c]) { + float h = magnitude[i] * col_scale[c]; + if (h < 1.0f) h = 1.0f; + float xl = pad_x + c * col_pitch; + boxes[i].x_left = xl; + boxes[i].x_right = xl + node_w; + boxes[i].y_top = y_cursor[c]; + boxes[i].y_bot = y_cursor[c] + h; + y_cursor[c] = boxes[i].y_bot + gap_y; + } + } + + // Por nodo: cursores de salida (en src) y entrada (en dst), incrementan al consumir links. + std::vector src_cursor(N, 0.0f); + std::vector dst_cursor(N, 0.0f); + for (int i = 0; i < N; i++) { + src_cursor[i] = boxes[i].y_top; + dst_cursor[i] = boxes[i].y_top; + } + + ImDrawList* dl = ImGui::GetWindowDrawList(); + + // Render links primero (por debajo de los nodos). + // Sort por src para estetica (topdown). + std::vector link_order(links.size()); + for (size_t i = 0; i < links.size(); i++) link_order[i] = (int)i; + std::sort(link_order.begin(), link_order.end(), + [&](int a, int b) { + const auto& la = links[a]; + const auto& lb = links[b]; + if (la.src != lb.src) return la.src < lb.src; + return la.dst < lb.dst; + }); + + for (int li : link_order) { + const auto& l = links[li]; + if (l.src < 0 || l.src >= N || l.dst < 0 || l.dst >= N) continue; + if (l.value <= 0.0f) continue; + + float h_src = l.value * col_scale[levels[l.src]]; + float h_dst = l.value * col_scale[levels[l.dst]]; + float ya0 = src_cursor[l.src]; + float ya1 = ya0 + h_src; + float yb0 = dst_cursor[l.dst]; + float yb1 = yb0 + h_dst; + src_cursor[l.src] += h_src; + dst_cursor[l.dst] += h_dst; + + float xa = boxes[l.src].x_right; + float xb = boxes[l.dst].x_left; + + // Dos beziers cubicos formando una banda (top + bottom + cierre). + ImVec2 p0(origin.x + xa, origin.y + (ya0 + ya1) * 0.5f); + ImVec2 p1(origin.x + xb, origin.y + (yb0 + yb1) * 0.5f); + float ctrl_dx = (xb - xa) * 0.5f; + + // Aproximar la banda con poligono de 24 segmentos top + 24 bottom. + const int STEPS = 24; + std::vector poly; + poly.reserve(STEPS * 2 + 2); + for (int s = 0; s <= STEPS; s++) { + float t = (float)s / (float)STEPS; + float u = 1.0f - t; + float bx = u*u*u * (xa) + 3*u*u*t * (xa + ctrl_dx) + 3*u*t*t * (xb - ctrl_dx) + t*t*t * (xb); + float by_top = u*u*u * (ya0) + 3*u*u*t * (ya0) + 3*u*t*t * (yb0) + t*t*t * (yb0); + poly.push_back(ImVec2(origin.x + bx, origin.y + by_top)); + (void)p0; (void)p1; + } + for (int s = STEPS; s >= 0; s--) { + float t = (float)s / (float)STEPS; + float u = 1.0f - t; + float bx = u*u*u * (xa) + 3*u*u*t * (xa + ctrl_dx) + 3*u*t*t * (xb - ctrl_dx) + t*t*t * (xb); + float by_bot = u*u*u * (ya1) + 3*u*u*t * (ya1) + 3*u*t*t * (yb1) + t*t*t * (yb1); + poly.push_back(ImVec2(origin.x + bx, origin.y + by_bot)); + } + ImU32 src_c = node_color(l.src); + // alpha bajo + ImU32 band = (src_c & 0x00FFFFFF) | (90u << 24); + dl->AddConvexPolyFilled(poly.data(), (int)poly.size(), band); + } + + // Render nodos + labels. + for (int i = 0; i < N; i++) { + ImVec2 a(origin.x + boxes[i].x_left, origin.y + boxes[i].y_top); + ImVec2 b(origin.x + boxes[i].x_right, origin.y + boxes[i].y_bot); + dl->AddRectFilled(a, b, node_color(i)); + // Label: a la izquierda si es ultima columna, derecha si no. + const char* lbl = nodes[i].label.c_str(); + ImVec2 ts = ImGui::CalcTextSize(lbl); + float ly = (a.y + b.y) * 0.5f - ts.y * 0.5f; + if (levels[i] == L - 1) { + // a la izquierda del rect + dl->AddText(ImVec2(a.x - ts.x - 4.0f, ly), + IM_COL32(220, 222, 235, 230), lbl); + } else { + dl->AddText(ImVec2(b.x + 4.0f, ly), + IM_COL32(220, 222, 235, 230), lbl); + } + } + + ImGui::PopID(); +} diff --git a/cpp/functions/viz/sankey.h b/cpp/functions/viz/sankey.h new file mode 100644 index 00000000..5ce7e84b --- /dev/null +++ b/cpp/functions/viz/sankey.h @@ -0,0 +1,25 @@ +#pragma once + +// Sankey diagram para flujos source -> target con magnitudes. +// +// Asume DAG (sin ciclos). Si hay ciclos, los nodos sin nivel quedan en la +// columna 0 — visualmente raro pero no rompe. + +#include "imgui.h" +#include +#include + +struct SankeyNode { + std::string label; +}; + +struct SankeyLink { + int src; + int dst; + float value; +}; + +void sankey(const char* id, + const std::vector& nodes, + const std::vector& links, + ImVec2 size = ImVec2(-1.0f, 400.0f)); diff --git a/cpp/functions/viz/sankey.md b/cpp/functions/viz/sankey.md new file mode 100644 index 00000000..dcecdd77 --- /dev/null +++ b/cpp/functions/viz/sankey.md @@ -0,0 +1,58 @@ +--- +name: sankey +kind: component +lang: cpp +domain: viz +version: "1.0.0" +purity: pure +signature: "void sankey(const char* id, const std::vector& nodes, const std::vector& links, ImVec2 size)" +description: "Sankey diagram para flujos source -> target con magnitudes. BFS topologico para columnas, bandas curvas (bezier cubico) para los links." +tags: [imgui, drawlist, chart, visualization, sankey, flow, dag] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: [imgui] +tested: false +tests: [] +test_file_path: "" +file_path: "cpp/functions/viz/sankey.cpp" +framework: imgui +params: + - name: id + desc: "Identificador unico para PushID" + - name: nodes + desc: "Vector de SankeyNode (label)" + - name: links + desc: "Vector de SankeyLink {src, dst, value}. src/dst son indices en nodes" + - name: size + desc: "Tamano del diagrama. x <= 0 usa el ancho disponible" +output: "Renderiza nodos como rectangulos verticales por columna y links como bandas con bezier cubico, con alpha bajo y color del nodo origen" +--- + +# sankey + +Sankey diagram. Asigna nodos a columnas via BFS topologico (level = max(level(src))+1) y los apila verticalmente en cada columna proporcionalmente a max(in_total, out_total). Los links se renderizan como bandas curvas con bezier cubico, color del nodo origen + alpha bajo. + +## Limitaciones + +- **Asume DAG** (sin ciclos). Si hay ciclos, los nodos del ciclo se quedan en su nivel parcial calculado por BFS — el render no rompe pero puede solapar visualmente. +- Sin orden de nodos optimizado para minimizar cruces (heuristica simple por orden de insercion). +- Sin interaccion (hover, click). + +## Ejemplo + +```cpp +std::vector nodes = { + {"clientes_premium"}, {"clientes_basicos"}, + {"laptops"}, {"phones"}, {"tablets"}, + {"hw"}, {"sw"}, +}; +std::vector links = { + {0, 2, 80}, {0, 3, 30}, + {1, 3, 60}, {1, 4, 40}, + {2, 5, 80}, {3, 5, 90}, {4, 5, 40}, +}; +sankey("##flow", nodes, links, ImVec2(-1, 400)); +```