feat(pineal): cierra stub flow — diagrama Sankey
Fase F: quinto stub de pineal cerrado. - layout — pipeline Sankey: columnas por longest-path en el DAG (back-edges detectadas por DFS y descartadas para romper ciclos), valor de nodo = max(entrante, saliente), apilado vertical por columna escalado a la altura, una pasada de barycenter para reducir cruces, anclas de cada banda en los bordes de sus nodos. - ribbon — teselado de bandas como triangle-strip con curva S (x lineal, y por smoothstep → tangentes horizontales). paint_ribbon + paint_sankey (ribbons al fondo, nodos encima). Painters agnósticos (trait Canvas). 6 tests verdes (columnas, ciclos sin loop infinito, proporcionalidad, conteo de draw calls). Pineal: 5/6 stubs cerrados. Resta mesh (viz de grafos: force-directed + Sugiyama + tree layout — módulo, no stub). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,300 @@
|
||||
//! Layout de un diagrama Sankey.
|
||||
//!
|
||||
//! Pipeline: columnas por longest-path en el DAG (back-edges descartadas)
|
||||
//! → valor de nodo = max(entrada, salida) → apilado vertical por columna
|
||||
//! con una pasada de barycenter para reducir cruces → anclas de cada
|
||||
//! banda (link) en los bordes de sus nodos.
|
||||
|
||||
use pineal_render::{Point, Rect};
|
||||
|
||||
/// Un nodo del Sankey.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SankeyNode {
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
impl SankeyNode {
|
||||
pub fn new(label: impl Into<String>) -> Self {
|
||||
Self { label: label.into() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Un flujo dirigido `source → target` con un caudal `value`.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct SankeyLink {
|
||||
pub source: usize,
|
||||
pub target: usize,
|
||||
pub value: f64,
|
||||
}
|
||||
|
||||
/// Caja de un nodo ya ubicada en el lienzo.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NodeBox {
|
||||
pub rect: Rect,
|
||||
pub column: usize,
|
||||
}
|
||||
|
||||
/// Banda de un link: cuatro anclas (arriba/abajo en origen y destino).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct LinkBand {
|
||||
pub link: usize,
|
||||
pub src_top: Point,
|
||||
pub src_bot: Point,
|
||||
pub dst_top: Point,
|
||||
pub dst_bot: Point,
|
||||
}
|
||||
|
||||
/// Layout completo: cajas de nodos + bandas de links.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct SankeyLayout {
|
||||
pub nodes: Vec<NodeBox>,
|
||||
pub links: Vec<LinkBand>,
|
||||
}
|
||||
|
||||
/// Calcula el layout de un Sankey dentro de `area`.
|
||||
pub fn compute_layout(
|
||||
nodes: &[SankeyNode],
|
||||
links: &[SankeyLink],
|
||||
area: Rect,
|
||||
node_width: f32,
|
||||
node_gap: f32,
|
||||
) -> SankeyLayout {
|
||||
let n = nodes.len();
|
||||
if n == 0 || area.w <= 0.0 || area.h <= 0.0 {
|
||||
return SankeyLayout::default();
|
||||
}
|
||||
let valid: Vec<&SankeyLink> = links
|
||||
.iter()
|
||||
.filter(|l| l.source < n && l.target < n && l.source != l.target && l.value > 0.0)
|
||||
.collect();
|
||||
|
||||
let columns = assign_columns(n, &valid);
|
||||
let n_cols = columns.iter().copied().max().unwrap_or(0) + 1;
|
||||
|
||||
// Valor de cada nodo = max(suma entrante, suma saliente).
|
||||
let mut in_sum = vec![0.0f64; n];
|
||||
let mut out_sum = vec![0.0f64; n];
|
||||
for l in &valid {
|
||||
in_sum[l.target] += l.value;
|
||||
out_sum[l.source] += l.value;
|
||||
}
|
||||
let node_value: Vec<f64> = (0..n).map(|i| in_sum[i].max(out_sum[i]).max(0.0)).collect();
|
||||
|
||||
// Nodos por columna.
|
||||
let mut by_col: Vec<Vec<usize>> = vec![Vec::new(); n_cols];
|
||||
for (i, &c) in columns.iter().enumerate() {
|
||||
by_col[c].push(i);
|
||||
}
|
||||
|
||||
// Escala vertical: la columna más cargada llena `area.h` (con gaps).
|
||||
let max_col_value = by_col
|
||||
.iter()
|
||||
.map(|col| col.iter().map(|&i| node_value[i]).sum::<f64>())
|
||||
.fold(0.0f64, f64::max)
|
||||
.max(1e-9);
|
||||
let max_col_count = by_col.iter().map(|c| c.len()).max().unwrap_or(1).max(1);
|
||||
let usable_h = (area.h - node_gap * (max_col_count.saturating_sub(1)) as f32).max(1.0);
|
||||
let v_scale = usable_h as f64 / max_col_value;
|
||||
|
||||
// Una pasada de barycenter para ordenar cada columna.
|
||||
barycenter_pass(&mut by_col, &valid, &columns);
|
||||
|
||||
// Geometría de cada nodo.
|
||||
let col_step = if n_cols > 1 {
|
||||
(area.w - node_width) / (n_cols - 1) as f32
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let mut boxes = vec![
|
||||
NodeBox { rect: Rect::new(0.0, 0.0, 0.0, 0.0), column: 0 };
|
||||
n
|
||||
];
|
||||
for (c, col) in by_col.iter().enumerate() {
|
||||
let mut y = area.y;
|
||||
for &i in col {
|
||||
let h = (node_value[i] * v_scale) as f32;
|
||||
let x = area.x + c as f32 * col_step;
|
||||
boxes[i] = NodeBox {
|
||||
rect: Rect::new(x, y, node_width, h.max(1.0)),
|
||||
column: c,
|
||||
};
|
||||
y += h + node_gap;
|
||||
}
|
||||
}
|
||||
|
||||
// Bandas de links: apiladas en el borde derecho del origen y el
|
||||
// borde izquierdo del destino, en el orden de aparición.
|
||||
let mut src_cursor = vec![0.0f32; n];
|
||||
let mut dst_cursor = vec![0.0f32; n];
|
||||
let mut bands = Vec::with_capacity(valid.len());
|
||||
for (vi, l) in valid.iter().enumerate() {
|
||||
let sb = &boxes[l.source].rect;
|
||||
let tb = &boxes[l.target].rect;
|
||||
let thick_s = (l.value * v_scale) as f32;
|
||||
let s_y0 = sb.y + src_cursor[l.source];
|
||||
let t_y0 = tb.y + dst_cursor[l.target];
|
||||
src_cursor[l.source] += thick_s;
|
||||
dst_cursor[l.target] += thick_s;
|
||||
// El índice real del link en el slice original.
|
||||
let link_idx = links
|
||||
.iter()
|
||||
.position(|x| {
|
||||
x.source == l.source && x.target == l.target && x.value == l.value
|
||||
})
|
||||
.unwrap_or(vi);
|
||||
bands.push(LinkBand {
|
||||
link: link_idx,
|
||||
src_top: Point::new(sb.right(), s_y0),
|
||||
src_bot: Point::new(sb.right(), s_y0 + thick_s),
|
||||
dst_top: Point::new(tb.x, t_y0),
|
||||
dst_bot: Point::new(tb.x, t_y0 + thick_s),
|
||||
});
|
||||
}
|
||||
|
||||
SankeyLayout { nodes: boxes, links: bands }
|
||||
}
|
||||
|
||||
/// Columna de cada nodo = longest-path desde una fuente. Las back-edges
|
||||
/// (detectadas por DFS) se descartan para romper ciclos.
|
||||
fn assign_columns(n: usize, links: &[&SankeyLink]) -> Vec<usize> {
|
||||
let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n];
|
||||
for l in links {
|
||||
adj[l.source].push(l.target);
|
||||
}
|
||||
// DFS marcando back-edges (destino en la pila actual).
|
||||
let mut state = vec![0u8; n]; // 0=blanco 1=en-pila 2=hecho
|
||||
let mut back: Vec<(usize, usize)> = Vec::new();
|
||||
for s in 0..n {
|
||||
if state[s] != 0 {
|
||||
continue;
|
||||
}
|
||||
let mut stack = vec![(s, 0usize)];
|
||||
state[s] = 1;
|
||||
while let Some(&mut (u, ref mut i)) = stack.last_mut() {
|
||||
if *i < adj[u].len() {
|
||||
let v = adj[u][*i];
|
||||
*i += 1;
|
||||
match state[v] {
|
||||
0 => {
|
||||
state[v] = 1;
|
||||
stack.push((v, 0));
|
||||
}
|
||||
1 => back.push((u, v)),
|
||||
_ => {}
|
||||
}
|
||||
} else {
|
||||
state[u] = 2;
|
||||
stack.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
// Longest-path en el DAG (sin back-edges) vía relajación topológica.
|
||||
let mut indeg = vec![0usize; n];
|
||||
let mut dag: Vec<Vec<usize>> = vec![Vec::new(); n];
|
||||
for l in links {
|
||||
if !back.contains(&(l.source, l.target)) {
|
||||
dag[l.source].push(l.target);
|
||||
indeg[l.target] += 1;
|
||||
}
|
||||
}
|
||||
let mut col = vec![0usize; n];
|
||||
let mut queue: Vec<usize> = (0..n).filter(|&i| indeg[i] == 0).collect();
|
||||
let mut head = 0;
|
||||
while head < queue.len() {
|
||||
let u = queue[head];
|
||||
head += 1;
|
||||
for &v in &dag[u] {
|
||||
col[v] = col[v].max(col[u] + 1);
|
||||
indeg[v] -= 1;
|
||||
if indeg[v] == 0 {
|
||||
queue.push(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
col
|
||||
}
|
||||
|
||||
/// Reordena los nodos de cada columna por el promedio de las posiciones
|
||||
/// de sus vecinos (barycenter heuristic), una pasada izquierda→derecha.
|
||||
fn barycenter_pass(by_col: &mut [Vec<usize>], links: &[&SankeyLink], columns: &[usize]) {
|
||||
let n = columns.len();
|
||||
let mut order_in_col = vec![0usize; n];
|
||||
for col in by_col.iter() {
|
||||
for (pos, &i) in col.iter().enumerate() {
|
||||
order_in_col[i] = pos;
|
||||
}
|
||||
}
|
||||
for c in 1..by_col.len() {
|
||||
let bary: Vec<(usize, f64)> = by_col[c]
|
||||
.iter()
|
||||
.map(|&node| {
|
||||
let mut sum = 0.0;
|
||||
let mut cnt = 0.0;
|
||||
for l in links {
|
||||
if l.target == node && columns[l.source] == c - 1 {
|
||||
sum += order_in_col[l.source] as f64;
|
||||
cnt += 1.0;
|
||||
}
|
||||
}
|
||||
(node, if cnt > 0.0 { sum / cnt } else { f64::MAX })
|
||||
})
|
||||
.collect();
|
||||
let mut sorted = bary;
|
||||
sorted.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
|
||||
by_col[c] = sorted.into_iter().map(|(node, _)| node).collect();
|
||||
for (pos, &i) in by_col[c].iter().enumerate() {
|
||||
order_in_col[i] = pos;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn nodes(n: usize) -> Vec<SankeyNode> {
|
||||
(0..n).map(|i| SankeyNode::new(format!("n{i}"))).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_input() {
|
||||
let l = compute_layout(&[], &[], Rect::new(0.0, 0.0, 100.0, 100.0), 20.0, 4.0);
|
||||
assert!(l.nodes.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chain_assigns_increasing_columns() {
|
||||
// 0 → 1 → 2
|
||||
let links = [
|
||||
SankeyLink { source: 0, target: 1, value: 5.0 },
|
||||
SankeyLink { source: 1, target: 2, value: 5.0 },
|
||||
];
|
||||
let l = compute_layout(&nodes(3), &links, Rect::new(0.0, 0.0, 300.0, 100.0), 20.0, 4.0);
|
||||
assert_eq!(l.nodes[0].column, 0);
|
||||
assert_eq!(l.nodes[1].column, 1);
|
||||
assert_eq!(l.nodes[2].column, 2);
|
||||
assert_eq!(l.links.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn back_edge_does_not_loop_forever() {
|
||||
// ciclo 0 → 1 → 0 ; debe terminar y no panickear.
|
||||
let links = [
|
||||
SankeyLink { source: 0, target: 1, value: 3.0 },
|
||||
SankeyLink { source: 1, target: 0, value: 1.0 },
|
||||
];
|
||||
let l = compute_layout(&nodes(2), &links, Rect::new(0.0, 0.0, 200.0, 100.0), 20.0, 4.0);
|
||||
assert_eq!(l.nodes.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_height_proportional_to_flow() {
|
||||
// 0 manda 10 a 1 y 1 a 2 ; nodo 0 más "grueso" que nodo 2.
|
||||
let links = [
|
||||
SankeyLink { source: 0, target: 1, value: 10.0 },
|
||||
SankeyLink { source: 0, target: 2, value: 2.0 },
|
||||
];
|
||||
let l = compute_layout(&nodes(3), &links, Rect::new(0.0, 0.0, 200.0, 200.0), 20.0, 4.0);
|
||||
assert!(l.nodes[0].rect.h > l.nodes[2].rect.h);
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,18 @@
|
||||
//! `pineal-flow` — diagramas Sankey.
|
||||
//!
|
||||
//! Pipeline (sección 3.7 del ARCHITECTURE.md):
|
||||
//! 1. Columnas via longest-path en el DAG (back-edges drop).
|
||||
//! 2. Flow por nodo = max(in_value, out_value).
|
||||
//! 3. Barycenter ordering con inversion-count crossings.
|
||||
//! 4. Stripes por edge dentro de cada lado del nodo.
|
||||
//! 5. Ribbons como triangle-strip de béziers, un draw call por
|
||||
//! ribbon, color por vértice.
|
||||
//! Pipeline:
|
||||
//! 1. Columnas por longest-path en el DAG (back-edges descartadas).
|
||||
//! 2. Valor de nodo = max(caudal entrante, caudal saliente).
|
||||
//! 3. Apilado vertical por columna + una pasada de barycenter.
|
||||
//! 4. Bandas (ribbons) como triangle-strips con curva S (`smoothstep`).
|
||||
//!
|
||||
//! - [`layout`] — cómputo del layout (agnóstico).
|
||||
//! - [`ribbon`] — teselado + painters contra `Canvas`.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
#![allow(dead_code)]
|
||||
|
||||
pub mod layout {}
|
||||
pub mod ribbon {}
|
||||
pub mod element {}
|
||||
pub mod layout;
|
||||
pub mod ribbon;
|
||||
|
||||
pub use layout::{compute_layout, LinkBand, NodeBox, SankeyLayout, SankeyLink, SankeyNode};
|
||||
pub use ribbon::{paint_ribbon, paint_sankey, ribbon_strip};
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
//! Tesela y dibuja las bandas (ribbons) de un Sankey.
|
||||
//!
|
||||
//! Cada banda es una franja con curva S: `x` avanza lineal entre nodos y
|
||||
//! `y` interpola con `smoothstep`, lo que da tangentes horizontales en
|
||||
//! ambos extremos (el look clásico de Sankey). Se emite como un triangle
|
||||
//! strip `[top0,bot0,top1,bot1,…]`, un draw call por ribbon.
|
||||
|
||||
use crate::layout::{LinkBand, SankeyLayout};
|
||||
use pineal_render::{Canvas, Color};
|
||||
|
||||
/// Segmentos por ribbon — controla la suavidad de la curva.
|
||||
const RIBBON_SEGMENTS: usize = 24;
|
||||
|
||||
fn smoothstep(t: f32) -> f32 {
|
||||
t * t * (3.0 - 2.0 * t)
|
||||
}
|
||||
|
||||
/// Tesela una banda en coords interleaved `[x,y,…]` de un triangle strip.
|
||||
pub fn ribbon_strip(band: &LinkBand) -> Vec<f32> {
|
||||
let mut coords = Vec::with_capacity((RIBBON_SEGMENTS + 1) * 4);
|
||||
for i in 0..=RIBBON_SEGMENTS {
|
||||
let t = i as f32 / RIBBON_SEGMENTS as f32;
|
||||
let e = smoothstep(t);
|
||||
let x_top = band.src_top.x + (band.dst_top.x - band.src_top.x) * t;
|
||||
let y_top = band.src_top.y + (band.dst_top.y - band.src_top.y) * e;
|
||||
let x_bot = band.src_bot.x + (band.dst_bot.x - band.src_bot.x) * t;
|
||||
let y_bot = band.src_bot.y + (band.dst_bot.y - band.src_bot.y) * e;
|
||||
coords.push(x_top);
|
||||
coords.push(y_top);
|
||||
coords.push(x_bot);
|
||||
coords.push(y_bot);
|
||||
}
|
||||
coords
|
||||
}
|
||||
|
||||
/// Dibuja una sola banda con el color dado.
|
||||
pub fn paint_ribbon(band: &LinkBand, color: Color, canvas: &mut dyn Canvas) {
|
||||
let coords = ribbon_strip(band);
|
||||
let colors = vec![color; coords.len() / 2];
|
||||
canvas.fill_triangle_strip(&coords, &colors);
|
||||
}
|
||||
|
||||
/// Dibuja un Sankey completo: ribbons primero (al fondo), nodos encima.
|
||||
pub fn paint_sankey(
|
||||
layout: &SankeyLayout,
|
||||
node_color: Color,
|
||||
link_color: Color,
|
||||
canvas: &mut dyn Canvas,
|
||||
) {
|
||||
for band in &layout.links {
|
||||
paint_ribbon(band, link_color, canvas);
|
||||
}
|
||||
for nb in &layout.nodes {
|
||||
if nb.rect.w > 0.0 && nb.rect.h > 0.0 {
|
||||
canvas.fill_rect(nb.rect, node_color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::layout::{compute_layout, SankeyLink, SankeyNode};
|
||||
use pineal_render::{PlanRecorder, Rect, RenderCmd};
|
||||
|
||||
#[test]
|
||||
fn ribbon_strip_has_expected_vertex_count() {
|
||||
let band = LinkBand {
|
||||
link: 0,
|
||||
src_top: pineal_render::Point::new(0.0, 0.0),
|
||||
src_bot: pineal_render::Point::new(0.0, 10.0),
|
||||
dst_top: pineal_render::Point::new(100.0, 50.0),
|
||||
dst_bot: pineal_render::Point::new(100.0, 60.0),
|
||||
};
|
||||
let coords = ribbon_strip(&band);
|
||||
assert_eq!(coords.len(), (RIBBON_SEGMENTS + 1) * 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paint_sankey_emits_nodes_and_ribbons() {
|
||||
let nodes = vec![
|
||||
SankeyNode::new("a"),
|
||||
SankeyNode::new("b"),
|
||||
SankeyNode::new("c"),
|
||||
];
|
||||
let links = [
|
||||
SankeyLink { source: 0, target: 1, value: 5.0 },
|
||||
SankeyLink { source: 1, target: 2, value: 3.0 },
|
||||
];
|
||||
let layout = compute_layout(
|
||||
&nodes,
|
||||
&links,
|
||||
Rect::new(0.0, 0.0, 300.0, 150.0),
|
||||
18.0,
|
||||
6.0,
|
||||
);
|
||||
let mut rec = PlanRecorder::new();
|
||||
paint_sankey(&layout, Color::from_hex(0x335577), Color::from_hex(0x88aacc), &mut rec);
|
||||
let cmds = rec.into_plan().cmds;
|
||||
let rects = cmds.iter().filter(|c| matches!(c, RenderCmd::FillRect { .. })).count();
|
||||
let strips = cmds.iter().filter(|c| matches!(c, RenderCmd::FillTriangleStrip { .. })).count();
|
||||
assert_eq!(rects, 3, "un fill_rect por nodo");
|
||||
assert_eq!(strips, 2, "un triangle strip por link");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user