Skip to main content

ravenrs_graphics/
sankey.rs

1//! The layout of a sankey diagram: where each node goes and the ribbon of each flow,
2//! from a graph that must not go in circles. Pure logic, in the coordinates asked for.
3
4use std::fmt;
5
6use ravenrs_core::{Point, Rect};
7
8use crate::vector::PathSegment;
9
10/// Which column a node goes to, among those its flows allow.
11#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
12#[non_exhaustive]
13#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
14pub enum SankeyAlign {
15    /// As far left as its flows allow.
16    Left,
17    /// As far right as its flows allow.
18    Right,
19    /// Like [`Self::Left`], except that a node with nothing coming in sits right
20    /// before the first thing it feeds.
21    Center,
22    /// Like [`Self::Left`], except that a node with nothing going out goes to the last
23    /// column.
24    #[default]
25    Justify,
26}
27
28/// A flow from one node to another, given by their places in the list of nodes.
29#[derive(Clone, Copy, Debug, PartialEq)]
30pub struct SankeyLink {
31    /// Where it comes from.
32    pub source: usize,
33    /// Where it goes.
34    pub target: usize,
35    /// How much. One that is not above zero has no ribbon, and does not count.
36    pub value: f64,
37}
38
39/// How a graph is laid out.
40#[derive(Clone, Copy, Debug, PartialEq)]
41#[non_exhaustive]
42pub struct SankeyStyle {
43    /// How wide a node is.
44    pub node_width: f32,
45    /// The room between two nodes of a column.
46    pub node_gap: f32,
47    /// Which column a node goes to.
48    pub align: SankeyAlign,
49    /// How many passes put nodes closer to what they connect to.
50    pub passes: usize,
51}
52
53impl Default for SankeyStyle {
54    fn default() -> Self {
55        Self { node_width: 12.0, node_gap: 14.0, align: SankeyAlign::default(), passes: 16 }
56    }
57}
58
59/// Why a graph has no layout.
60#[derive(Clone, Debug, PartialEq, Eq)]
61#[non_exhaustive]
62pub enum SankeyError {
63    /// The flows go in a circle, through these nodes, the first one again at the end.
64    Cycle(Vec<usize>),
65    /// A flow names a node past the end of the list.
66    NoSuchNode(usize),
67}
68
69impl fmt::Display for SankeyError {
70    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71        match self {
72            Self::Cycle(nodes) => {
73                let names: Vec<String> = nodes.iter().map(ToString::to_string).collect();
74                write!(f, "the flows go in a circle: {}", names.join(" -> "))
75            }
76            Self::NoSuchNode(node) => write!(f, "a flow names node {node}, which does not exist"),
77        }
78    }
79}
80
81impl std::error::Error for SankeyError {}
82
83/// A node as it landed.
84#[derive(Clone, Debug, PartialEq)]
85#[non_exhaustive]
86pub struct SankeyNode {
87    /// Where it is.
88    pub rect: Rect,
89    /// The larger of what comes in and what goes out.
90    pub value: f64,
91    /// Which column.
92    pub column: usize,
93    /// The flows coming in, as places in [`SankeyLayout::flows`].
94    pub incoming: Vec<usize>,
95    /// The flows going out.
96    pub outgoing: Vec<usize>,
97}
98
99/// A flow as it landed: a ribbon from the side of a node to the side of another.
100#[derive(Clone, Copy, Debug, PartialEq)]
101#[non_exhaustive]
102pub struct SankeyFlow {
103    /// Where it comes from.
104    pub source: usize,
105    /// Where it goes.
106    pub target: usize,
107    /// How much.
108    pub value: f64,
109    /// Where it starts: on this side, at this height, its middle.
110    pub from: Point,
111    /// Where it ends.
112    pub to: Point,
113    /// How thick it is.
114    pub width: f32,
115}
116
117impl SankeyFlow {
118    /// The two control points' place along the way, halfway across.
119    fn middle(&self) -> f32 {
120        (self.from.x + self.to.x) / 2.0
121    }
122
123    /// The outline of the ribbon.
124    pub fn path(&self) -> Vec<PathSegment> {
125        let (half, middle) = (self.width / 2.0, self.middle());
126        let at = Point::new;
127        let (x0, x1, y0, y1) = (self.from.x, self.to.x, self.from.y, self.to.y);
128        vec![
129            PathSegment::Move(at(x0, y0 - half)),
130            PathSegment::Cubic(at(middle, y0 - half), at(middle, y1 - half), at(x1, y1 - half)),
131            PathSegment::Line(at(x1, y1 + half)),
132            PathSegment::Cubic(at(middle, y1 + half), at(middle, y0 + half), at(x0, y0 + half)),
133            PathSegment::Close,
134        ]
135    }
136
137    /// Whether the ribbon covers this point.
138    pub fn contains(&self, point: Point) -> bool {
139        if point.x < self.from.x || point.x > self.to.x {
140            return false;
141        }
142        let (x0, x1, middle) = (self.from.x, self.to.x, self.middle());
143        let x_at = |t: f32| {
144            let u = 1.0 - t;
145            u * u * u * x0 + 3.0 * u * t * middle * (u + t) + t * t * t * x1
146        };
147        // Across, a curve only ever goes forward, so halving finds the place on it.
148        let (mut low, mut high) = (0.0_f32, 1.0_f32);
149        for _ in 0..16 {
150            let t = (low + high) / 2.0;
151            if x_at(t) < point.x {
152                low = t;
153            } else {
154                high = t;
155            }
156        }
157        let t = (low + high) / 2.0;
158        let share = t * t * (3.0 - 2.0 * t);
159        let y = self.from.y + (self.to.y - self.from.y) * share;
160        (point.y - y).abs() <= self.width / 2.0
161    }
162}
163
164/// What is under a point of a diagram.
165#[derive(Clone, Copy, Debug, PartialEq, Eq)]
166pub enum SankeyHit {
167    /// This node.
168    Node(usize),
169    /// This flow.
170    Flow(usize),
171}
172
173/// A diagram laid out.
174#[derive(Clone, Debug, Default, PartialEq)]
175#[non_exhaustive]
176pub struct SankeyLayout {
177    /// Every node, in the order given.
178    pub nodes: Vec<SankeyNode>,
179    /// Every flow with a value, in the order given.
180    pub flows: Vec<SankeyFlow>,
181    /// How many columns there are.
182    pub columns: usize,
183}
184
185impl SankeyLayout {
186    /// What is under `point`: a node, over the flows that reach it.
187    pub fn hit(&self, point: Point) -> Option<SankeyHit> {
188        if let Some(node) = self.nodes.iter().position(|node| node.rect.contains(point)) {
189            return Some(SankeyHit::Node(node));
190        }
191        // The last one drawn is the one on top.
192        self.flows.iter().rposition(|flow| flow.contains(point)).map(SankeyHit::Flow)
193    }
194}
195
196/// Each node's order so that flows only go forward, or the circle that prevents it.
197fn in_order(count: usize, links: &[SankeyLink]) -> Result<Vec<usize>, SankeyError> {
198    let mut waiting = vec![0_usize; count];
199    for link in links {
200        waiting[link.target] += 1;
201    }
202    let mut ready: Vec<usize> = (0..count).filter(|node| waiting[*node] == 0).collect();
203    let mut order = Vec::with_capacity(count);
204    while let Some(node) = ready.pop() {
205        order.push(node);
206        for link in links.iter().filter(|link| link.source == node) {
207            waiting[link.target] -= 1;
208            if waiting[link.target] == 0 {
209                ready.push(link.target);
210            }
211        }
212    }
213    if order.len() == count {
214        return Ok(order);
215    }
216    // What is left all has something coming in from what is left: going backwards
217    // from any of them comes to a node seen before.
218    let Some(start) = (0..count).find(|node| waiting[*node] > 0) else { return Ok(order) };
219    let mut path = vec![start];
220    loop {
221        let last = path[path.len() - 1];
222        let previous = links.iter().find(|l| l.target == last && waiting[l.source] > 0).map(|l| l.source);
223        let Some(previous) = previous else { return Err(SankeyError::Cycle(path)) };
224        if let Some(seen) = path.iter().position(|node| *node == previous) {
225            let mut circle: Vec<usize> = path[seen..].to_vec();
226            circle.push(previous);
227            circle.reverse();
228            return Err(SankeyError::Cycle(circle));
229        }
230        path.push(previous);
231    }
232}
233
234fn usable(links: &[SankeyLink]) -> Vec<SankeyLink> {
235    links.iter().copied().filter(|link| link.value.is_finite() && link.value > 0.0).collect()
236}
237
238/// Whether a graph can be laid out, without laying it out.
239pub fn check(count: usize, links: &[SankeyLink]) -> Result<(), SankeyError> {
240    let links = usable(links);
241    if let Some(link) = links.iter().find(|link| link.source >= count || link.target >= count) {
242        return Err(SankeyError::NoSuchNode(link.source.max(link.target)));
243    }
244    in_order(count, &links).map(|_| ())
245}
246
247/// The column of each node, and how many there are.
248fn columns_of(
249    count: usize,
250    links: &[SankeyLink],
251    order: &[usize],
252    align: SankeyAlign,
253) -> (Vec<usize>, usize) {
254    let mut depth = vec![0_usize; count];
255    for node in order {
256        for link in links.iter().filter(|link| link.source == *node) {
257            depth[link.target] = depth[link.target].max(depth[*node] + 1);
258        }
259    }
260    let mut height = vec![0_usize; count];
261    for node in order.iter().rev() {
262        for link in links.iter().filter(|link| link.target == *node) {
263            height[link.source] = height[link.source].max(height[*node] + 1);
264        }
265    }
266    let columns = depth.iter().copied().max().map_or(0, |deepest| deepest + 1);
267    let has_in = |node: usize| links.iter().any(|link| link.target == node);
268    let has_out = |node: usize| links.iter().any(|link| link.source == node);
269    let column = (0..count)
270        .map(|node| match align {
271            SankeyAlign::Left => depth[node],
272            SankeyAlign::Right => columns - 1 - height[node],
273            SankeyAlign::Center if !has_in(node) && has_out(node) => {
274                let first = links.iter().filter(|l| l.source == node).map(|l| depth[l.target]).min();
275                first.map_or(0, |at| at.saturating_sub(1))
276            }
277            SankeyAlign::Center => depth[node],
278            SankeyAlign::Justify if !has_out(node) && has_in(node) => columns - 1,
279            _ => depth[node],
280        })
281        .collect();
282    (column, columns)
283}
284
285/// Keeps a column's nodes from covering each other and inside the area.
286fn resolve(ys: &mut [f32], heights: &[f32], column: &mut [usize], area: Rect, gap: f32) {
287    column.sort_by(|a, b| ys[*a].total_cmp(&ys[*b]));
288    let mut next = area.y();
289    for node in column.iter() {
290        ys[*node] = ys[*node].max(next);
291        next = ys[*node] + heights[*node] + gap;
292    }
293    let mut limit = area.max_y();
294    for node in column.iter().rev() {
295        ys[*node] = ys[*node].min(limit - heights[*node]);
296        limit = ys[*node] - gap;
297    }
298}
299
300/// Lays a graph of `count` nodes out inside `area`. Flows with no value are left out.
301pub fn layout(
302    count: usize,
303    links: &[SankeyLink],
304    area: Rect,
305    style: &SankeyStyle,
306) -> Result<SankeyLayout, SankeyError> {
307    check(count, links)?;
308    let links = usable(links);
309    let order = in_order(count, &links)?;
310    let (column_of, columns) = columns_of(count, &links, &order, style.align);
311    if columns == 0 {
312        return Ok(SankeyLayout::default());
313    }
314    let mut value = vec![0.0_f64; count];
315    for (node, slot) in value.iter_mut().enumerate() {
316        let coming: f64 = links.iter().filter(|l| l.target == node).map(|l| l.value).sum();
317        let going: f64 = links.iter().filter(|l| l.source == node).map(|l| l.value).sum();
318        *slot = coming.max(going);
319    }
320    let mut by_column: Vec<Vec<usize>> = vec![Vec::new(); columns];
321    for node in 0..count {
322        by_column[column_of[node]].push(node);
323    }
324    // One scale for the whole diagram, the one the fullest column allows.
325    let scale = by_column
326        .iter()
327        .filter(|column| !column.is_empty())
328        .map(|column| {
329            let total: f64 = column.iter().map(|node| value[*node]).sum();
330            let room = area.height() - style.node_gap * (column.len() - 1) as f32;
331            if total > 0.0 { room.max(1.0) / total as f32 } else { f32::INFINITY }
332        })
333        .fold(f32::INFINITY, f32::min);
334    let scale = if scale.is_finite() { scale } else { 1.0 };
335    let heights: Vec<f32> = value.iter().map(|v| (*v as f32 * scale).max(1.0)).collect();
336
337    // Each column starts in the middle, and each pass brings each node closer to what
338    // it connects to, more boldly at first.
339    let mut ys = vec![area.y(); count];
340    for column in &by_column {
341        let total: f32 = column.iter().map(|node| heights[*node]).sum::<f32>()
342            + style.node_gap * column.len().saturating_sub(1) as f32;
343        let mut y = area.y() + (area.height() - total).max(0.0) / 2.0;
344        for node in column {
345            ys[*node] = y;
346            y += heights[*node] + style.node_gap;
347        }
348    }
349    for pass in 0..style.passes {
350        let strength = 0.99_f32.powi(pass as i32) * 0.8;
351        for column in (1..columns).chain((0..columns.saturating_sub(1)).rev()) {
352            let forward = pass % 2 == 0;
353            let nodes: Vec<usize> = by_column[column].clone();
354            for node in nodes {
355                let toward = |other: usize| ys[other] + heights[other] / 2.0;
356                let (mut sum, mut weight) = (0.0_f32, 0.0_f32);
357                for link in &links {
358                    let other = if forward && link.target == node {
359                        Some(link.source)
360                    } else if !forward && link.source == node {
361                        Some(link.target)
362                    } else {
363                        None
364                    };
365                    if let Some(other) = other {
366                        sum += toward(other) * link.value as f32;
367                        weight += link.value as f32;
368                    }
369                }
370                if weight > 0.0 {
371                    let wanted = sum / weight - heights[node] / 2.0;
372                    ys[node] += (wanted - ys[node]) * strength;
373                }
374            }
375            resolve(&mut ys, &heights, &mut by_column[column], area, style.node_gap);
376        }
377    }
378
379    // Many columns in little room: nodes get thinner, so flows still go forward.
380    let node_width = style.node_width.min(area.width() / (columns * 2) as f32);
381    let span = (area.width() - node_width).max(0.0);
382    let x_of = |column: usize| {
383        if columns > 1 { area.x() + span * column as f32 / (columns - 1) as f32 } else { area.x() }
384    };
385    let mut nodes: Vec<SankeyNode> = (0..count)
386        .map(|node| SankeyNode {
387            rect: Rect::new(x_of(column_of[node]), ys[node], node_width, heights[node]),
388            value: value[node],
389            column: column_of[node],
390            incoming: Vec::new(),
391            outgoing: Vec::new(),
392        })
393        .collect();
394    for (place, link) in links.iter().enumerate() {
395        nodes[link.source].outgoing.push(place);
396        nodes[link.target].incoming.push(place);
397    }
398    let middle = |node: usize| ys[node] + heights[node] / 2.0;
399    let mut starts = vec![0.0_f32; links.len()];
400    let mut ends = vec![0.0_f32; links.len()];
401    for node in nodes.iter_mut() {
402        // Ribbons leave and arrive in the order of what is at their other ends, so
403        // that they cross as little as they can.
404        node.outgoing.sort_by(|a, b| middle(links[*a].target).total_cmp(&middle(links[*b].target)));
405        node.incoming.sort_by(|a, b| middle(links[*a].source).total_cmp(&middle(links[*b].source)));
406        let mut y = node.rect.y();
407        for link in &node.outgoing {
408            starts[*link] = y + links[*link].value as f32 * scale / 2.0;
409            y += links[*link].value as f32 * scale;
410        }
411        let mut y = node.rect.y();
412        for link in &node.incoming {
413            ends[*link] = y + links[*link].value as f32 * scale / 2.0;
414            y += links[*link].value as f32 * scale;
415        }
416    }
417    let flows = links
418        .iter()
419        .enumerate()
420        .map(|(place, link)| SankeyFlow {
421            source: link.source,
422            target: link.target,
423            value: link.value,
424            from: Point::new(nodes[link.source].rect.max_x(), starts[place]),
425            to: Point::new(nodes[link.target].rect.x(), ends[place]),
426            width: link.value as f32 * scale,
427        })
428        .collect();
429    Ok(SankeyLayout { nodes, flows, columns })
430}
431
432#[cfg(test)]
433mod tests {
434    use super::*;
435
436    const AREA: Rect = Rect::new(0.0, 0.0, 400.0, 300.0);
437
438    fn links(list: &[(usize, usize, f64)]) -> Vec<SankeyLink> {
439        list.iter()
440            .map(|(source, target, value)| SankeyLink { source: *source, target: *target, value: *value })
441            .collect()
442    }
443
444    fn laid(count: usize, list: &[(usize, usize, f64)]) -> SankeyLayout {
445        layout(count, &links(list), AREA, &SankeyStyle::default()).expect("a layout")
446    }
447
448    #[test]
449    fn a_chain_goes_from_left_to_right_with_a_column_each() {
450        let graph = laid(3, &[(0, 1, 10.0), (1, 2, 10.0)]);
451        assert_eq!(graph.columns, 3);
452        assert_eq!(graph.nodes[0].rect.x(), 0.0);
453        assert!((graph.nodes[2].rect.max_x() - AREA.max_x()).abs() < 0.01);
454        assert!(graph.nodes[0].rect.max_x() < graph.nodes[1].rect.x());
455        let flow = graph.flows[0];
456        assert_eq!(flow.from.x, graph.nodes[0].rect.max_x());
457        assert_eq!(flow.to.x, graph.nodes[1].rect.x());
458    }
459
460    #[test]
461    fn a_circle_says_which_nodes_it_goes_through() {
462        let error = layout(
463            4,
464            &links(&[(0, 1, 1.0), (1, 2, 1.0), (2, 3, 1.0), (3, 1, 1.0)]),
465            AREA,
466            &SankeyStyle::default(),
467        );
468        let Err(SankeyError::Cycle(nodes)) = error else { panic!("{error:?}") };
469        assert_eq!(nodes.first(), nodes.last(), "it closes on itself");
470        assert_eq!(nodes.len(), 4, "{nodes:?}");
471        assert!(!nodes.contains(&0), "the node leading to it is not part of it");
472        for pair in nodes.windows(2) {
473            let found = [(1, 2), (2, 3), (3, 1)].contains(&(pair[0], pair[1]));
474            assert!(found, "{pair:?} in the direction of the flows");
475        }
476    }
477
478    #[test]
479    fn a_node_that_flows_into_itself_is_a_circle() {
480        assert!(matches!(check(2, &links(&[(1, 1, 1.0)])), Err(SankeyError::Cycle(_))));
481    }
482
483    #[test]
484    fn a_flow_to_nowhere_is_an_error_and_says_so() {
485        let error = check(2, &links(&[(0, 5, 1.0)])).expect_err("no such node");
486        assert_eq!(error, SankeyError::NoSuchNode(5));
487        assert!(error.to_string().contains('5'));
488    }
489
490    #[test]
491    fn a_flow_with_no_value_does_not_count() {
492        let graph = laid(3, &[(0, 1, 5.0), (1, 2, 0.0), (1, 2, f64::NAN), (2, 0, -1.0)]);
493        assert_eq!(graph.flows.len(), 1, "and the negative one closes no circle");
494    }
495
496    #[test]
497    fn what_goes_through_a_node_fills_its_side() {
498        let graph = laid(4, &[(0, 2, 30.0), (1, 2, 10.0), (2, 3, 40.0)]);
499        let middle = &graph.nodes[2];
500        let width: f32 = middle.incoming.iter().map(|f| graph.flows[*f].width).sum();
501        assert!((width - middle.rect.height()).abs() < 0.01);
502        let width: f32 = middle.outgoing.iter().map(|f| graph.flows[*f].width).sum();
503        assert!((width - middle.rect.height()).abs() < 0.01);
504        assert!(graph.nodes[0].rect.height() > graph.nodes[1].rect.height() * 2.0);
505    }
506
507    #[test]
508    fn nodes_of_a_column_stay_inside_and_apart() {
509        let list: Vec<(usize, usize, f64)> = (1..12).map(|i| (0, i, f64::from(i as u32))).collect();
510        let graph = laid(12, &list);
511        let mut column: Vec<Rect> = graph.nodes[1..].iter().map(|n| n.rect).collect();
512        column.sort_by(|a, b| a.y().total_cmp(&b.y()));
513        for pair in column.windows(2) {
514            assert!(pair[0].max_y() <= pair[1].y() + 0.01, "{pair:?}");
515        }
516        assert!(column[0].y() >= AREA.y() - 0.01);
517        assert!(column[column.len() - 1].max_y() <= AREA.max_y() + 0.01);
518    }
519
520    #[test]
521    fn the_alignment_decides_where_a_short_branch_goes() {
522        let list = links(&[(0, 1, 5.0), (1, 2, 5.0), (3, 2, 5.0), (0, 4, 5.0)]);
523        let of = |align| {
524            let style = SankeyStyle { align, ..SankeyStyle::default() };
525            layout(5, &list, AREA, &style).expect("a layout").nodes
526        };
527        assert_eq!(of(SankeyAlign::Left)[4].column, 1, "as soon as it can");
528        assert_eq!(of(SankeyAlign::Justify)[4].column, 2, "nothing goes out of it: last");
529        assert_eq!(of(SankeyAlign::Right)[3].column, 1, "right before what it feeds");
530        assert_eq!(of(SankeyAlign::Left)[3].column, 0);
531        assert_eq!(of(SankeyAlign::Center)[3].column, 1);
532    }
533
534    #[test]
535    fn the_pointer_finds_a_node_over_a_flow() {
536        let graph = laid(2, &[(0, 1, 10.0)]);
537        let node = graph.nodes[0].rect.center();
538        assert_eq!(graph.hit(node), Some(SankeyHit::Node(0)));
539        let flow = graph.flows[0];
540        let halfway = Point::new((flow.from.x + flow.to.x) / 2.0, (flow.from.y + flow.to.y) / 2.0);
541        assert_eq!(graph.hit(halfway), Some(SankeyHit::Flow(0)));
542        assert_eq!(graph.hit(Point::new(200.0, -10.0)), None);
543    }
544
545    #[test]
546    fn a_ribbon_is_where_its_outline_says_at_any_point_across() {
547        let graph = laid(3, &[(0, 2, 10.0), (1, 2, 30.0)]);
548        let flow = graph.flows[0];
549        let quarter = flow.from.x + (flow.to.x - flow.from.x) * 0.25;
550        let inside = (0..300).map(|y| Point::new(quarter, y as f32)).filter(|p| flow.contains(*p)).count();
551        assert!((inside as f32 - flow.width).abs() <= 2.0, "{inside} {}", flow.width);
552    }
553
554    #[test]
555    fn nothing_lays_out_as_nothing() {
556        assert_eq!(laid(0, &[]).nodes.len(), 0);
557        let alone = laid(2, &[]);
558        assert_eq!(alone.flows.len(), 0);
559        assert_eq!(alone.nodes.len(), 2);
560    }
561
562    #[test]
563    fn many_columns_in_little_room_still_flow_forward() {
564        let list: Vec<(usize, usize, f64)> = (0..200).map(|i| (i, i + 1, 3.0)).collect();
565        let graph = laid(201, &list);
566        assert_eq!(graph.columns, 201);
567        assert!(graph.flows.iter().all(|f| f.from.x < f.to.x));
568    }
569}