Skip to main content

ggplot_rs/stat/
sankey.rs

1//! `StatSankey`: node/link layout for a Sankey diagram from an edge list.
2//!
3//! Input: one row per link with `from`, `to` and an optional `weight` (1 when
4//! unmapped); repeated (from, to) pairs are summed. Nodes are placed in
5//! columns by their longest path from a source (Highcharts' layout), sized by
6//! `max(inflow, outflow)`, stacked with padding and centred per column; a few
7//! barycentre sweeps reorder each column to reduce crossings.
8//!
9//! Output: one row per node (`part = "node"`) then one per link
10//! (`part = "link"`), both as boxes in layout units: x is the column index,
11//! y the flow value. A node is `xmin..xmax × ymin..ymax`; a link leaves its
12//! source at `x = xmin, ymin..ymax` and enters its target at
13//! `x = xmax, yend_min..yend_max`. Also `node` (node name; the source for a
14//! link), `from`, `to`, `value` and `column`.
15
16use std::collections::HashMap;
17
18use crate::aes::Aesthetic;
19use crate::data::{DataFrame, Value};
20use crate::scale::ScaleSet;
21
22use super::Stat;
23
24/// Sankey layout stat behind [`GeomSankey`](crate::geom::sankey::GeomSankey).
25#[derive(Clone, Debug)]
26pub struct StatSankey {
27    /// Node width in column units (columns are 1 apart).
28    pub node_width: f64,
29    /// Vertical gap between nodes as a fraction of the tallest column's flow.
30    pub node_padding: f64,
31    /// Move nodes without outgoing links to the last column (d3's
32    /// `sankeyJustify`); `false` keeps them at their depth.
33    pub justify: bool,
34    /// Barycentre sweeps used to reorder nodes (0 keeps first-appearance order).
35    pub iterations: usize,
36}
37
38impl Default for StatSankey {
39    fn default() -> Self {
40        StatSankey {
41            node_width: 0.12,
42            node_padding: 0.08,
43            justify: false,
44            iterations: 6,
45        }
46    }
47}
48
49struct Layout {
50    names: Vec<String>,
51    column: Vec<usize>,
52    value: Vec<f64>,
53    top: Vec<f64>,
54    /// Aggregated links: (from, to, value).
55    links: Vec<(usize, usize, f64)>,
56}
57
58impl StatSankey {
59    fn layout(&self, data: &DataFrame) -> Option<Layout> {
60        let (from, to) = (data.column("from")?, data.column("to")?);
61        let weight = data.column("weight");
62        let mut index: HashMap<String, usize> = HashMap::new();
63        let mut names = Vec::new();
64        let mut id = |v: &Value| -> Option<usize> {
65            if v.is_na() {
66                return None;
67            }
68            let key = v.key_str().into_owned();
69            Some(*index.entry(key.clone()).or_insert_with(|| {
70                names.push(key);
71                names.len() - 1
72            }))
73        };
74        let mut pairs: indexmap::IndexMap<(usize, usize), f64> = indexmap::IndexMap::new();
75        for i in 0..data.nrows() {
76            let (Some(a), Some(b)) = (id(&from[i]), id(&to[i])) else {
77                continue;
78            };
79            let w = match weight {
80                Some(c) => match c[i].as_f64() {
81                    Some(w) if w.is_finite() && w > 0.0 => w,
82                    _ => continue,
83                },
84                None => 1.0,
85            };
86            if a != b {
87                *pairs.entry((a, b)).or_insert(0.0) += w;
88            }
89        }
90        let n = names.len();
91        if n == 0 {
92            return None;
93        }
94        let links: Vec<(usize, usize, f64)> =
95            pairs.into_iter().map(|((a, b), w)| (a, b, w)).collect();
96
97        // Column = longest path from a source (capped, so a cycle terminates).
98        let mut column = vec![0usize; n];
99        for _ in 0..n {
100            let mut changed = false;
101            for &(a, b, _) in &links {
102                if column[b] < column[a] + 1 && column[a] + 1 < n {
103                    column[b] = column[a] + 1;
104                    changed = true;
105                }
106            }
107            if !changed {
108                break;
109            }
110        }
111        let (mut inflow, mut outflow) = (vec![0.0; n], vec![0.0; n]);
112        for &(a, b, w) in &links {
113            outflow[a] += w;
114            inflow[b] += w;
115        }
116        let last = column.iter().copied().max().unwrap_or(0);
117        if self.justify {
118            for k in 0..n {
119                if outflow[k] == 0.0 {
120                    column[k] = last;
121                }
122            }
123        }
124        let value: Vec<f64> = (0..n).map(|k| f64::max(inflow[k], outflow[k])).collect();
125
126        let mut cols: Vec<Vec<usize>> = vec![Vec::new(); last + 1];
127        for k in 0..n {
128            cols[column[k]].push(k);
129        }
130        let col_total = |c: &Vec<usize>| c.iter().map(|&k| value[k]).sum::<f64>();
131        let max_total = cols.iter().map(col_total).fold(0.0, f64::max);
132        let pad = self.node_padding * max_total;
133        let height = cols
134            .iter()
135            .map(|c| col_total(c) + pad * c.len().saturating_sub(1) as f64)
136            .fold(0.0, f64::max);
137
138        let mut top = vec![0.0; n];
139        let stack = |c: &Vec<usize>, top: &mut Vec<f64>| {
140            let h = col_total(c) + pad * c.len().saturating_sub(1) as f64;
141            let mut y = height - (height - h) / 2.0;
142            for &k in c {
143                top[k] = y;
144                y -= value[k] + pad;
145            }
146        };
147        for c in &cols {
148            stack(c, &mut top);
149        }
150        for _ in 0..self.iterations {
151            for forward in [true, false] {
152                let order: Vec<usize> = if forward {
153                    (1..cols.len()).collect()
154                } else {
155                    (0..cols.len().saturating_sub(1)).rev().collect()
156                };
157                for ci in order {
158                    let center = |k: usize| top[k] - value[k] / 2.0;
159                    let mut keyed: Vec<(f64, usize)> = cols[ci]
160                        .iter()
161                        .map(|&k| {
162                            let (mut sw, mut sy) = (0.0, 0.0);
163                            for &(a, b, w) in &links {
164                                let other = if forward && b == k {
165                                    a
166                                } else if !forward && a == k {
167                                    b
168                                } else {
169                                    continue;
170                                };
171                                sw += w;
172                                sy += w * center(other);
173                            }
174                            (if sw > 0.0 { sy / sw } else { center(k) }, k)
175                        })
176                        .collect();
177                    // Higher barycentre first (y grows upward; stacking runs top-down).
178                    keyed.sort_by(|a, b| b.0.total_cmp(&a.0));
179                    cols[ci] = keyed.into_iter().map(|(_, k)| k).collect();
180                    stack(&cols[ci], &mut top);
181                }
182            }
183        }
184        Some(Layout {
185            names,
186            column,
187            value,
188            top,
189            links,
190        })
191    }
192}
193
194impl Stat for StatSankey {
195    fn compute_group(&self, data: &DataFrame, _scales: &ScaleSet) -> DataFrame {
196        let Some(l) = self.layout(data) else {
197            return DataFrame::new();
198        };
199        let n = l.names.len();
200        let half = self.node_width / 2.0;
201        let center = |k: usize| l.top[k] - l.value[k] / 2.0;
202
203        let mut rows: Vec<[f64; 6]> = Vec::new();
204        let mut text: Vec<[String; 4]> = Vec::new();
205        let mut value = Vec::new();
206        let mut column = Vec::new();
207        for k in 0..n {
208            let x = l.column[k] as f64;
209            let (lo, hi) = (l.top[k] - l.value[k], l.top[k]);
210            rows.push([x - half, x + half, lo, hi, lo, hi]);
211            let name = l.names[k].clone();
212            text.push(["node".into(), name.clone(), name.clone(), name]);
213            value.push(l.value[k]);
214            column.push(l.column[k] as f64);
215        }
216        // Outgoing links stack down from the source's top in target order,
217        // incoming links down from the target's top in source order.
218        let mut out_off = vec![0.0; n];
219        let mut in_off = vec![0.0; n];
220        let mut by_source: Vec<usize> = (0..l.links.len()).collect();
221        by_source.sort_by(|&i, &j| {
222            let (a1, b1, _) = l.links[i];
223            let (a2, b2, _) = l.links[j];
224            a1.cmp(&a2).then(center(b2).total_cmp(&center(b1)))
225        });
226        let mut src_band = vec![(0.0, 0.0); l.links.len()];
227        for i in by_source {
228            let (a, _, w) = l.links[i];
229            let hi = l.top[a] - out_off[a];
230            src_band[i] = (hi - w, hi);
231            out_off[a] += w;
232        }
233        let mut by_target: Vec<usize> = (0..l.links.len()).collect();
234        by_target.sort_by(|&i, &j| {
235            let (a1, b1, _) = l.links[i];
236            let (a2, b2, _) = l.links[j];
237            b1.cmp(&b2).then(center(a2).total_cmp(&center(a1)))
238        });
239        let mut dst_band = vec![(0.0, 0.0); l.links.len()];
240        for i in by_target {
241            let (_, b, w) = l.links[i];
242            let hi = l.top[b] - in_off[b];
243            dst_band[i] = (hi - w, hi);
244            in_off[b] += w;
245        }
246        for (i, &(a, b, w)) in l.links.iter().enumerate() {
247            let (x0, x1) = (l.column[a] as f64 + half, l.column[b] as f64 - half);
248            rows.push([
249                x0,
250                x1,
251                src_band[i].0,
252                src_band[i].1,
253                dst_band[i].0,
254                dst_band[i].1,
255            ]);
256            let (fa, tb) = (l.names[a].clone(), l.names[b].clone());
257            text.push(["link".into(), fa.clone(), fa, tb]);
258            value.push(w);
259            column.push(l.column[a] as f64);
260        }
261
262        let num = |k: usize| rows.iter().map(|r| Value::Float(r[k])).collect::<Vec<_>>();
263        let txt = |k: usize| {
264            text.iter()
265                .map(|r| Value::Str(r[k].clone()))
266                .collect::<Vec<_>>()
267        };
268        let mut out = DataFrame::new();
269        out.add_column("part".into(), txt(0));
270        out.add_column("xmin".into(), num(0));
271        out.add_column("xmax".into(), num(1));
272        out.add_column("ymin".into(), num(2));
273        out.add_column("ymax".into(), num(3));
274        out.add_column("yend_min".into(), num(4));
275        out.add_column("yend_max".into(), num(5));
276        out.add_column("node".into(), txt(1));
277        out.add_column("from".into(), txt(2));
278        out.add_column("to".into(), txt(3));
279        out.add_column(
280            "value".into(),
281            value.into_iter().map(Value::Float).collect(),
282        );
283        out.add_column(
284            "column".into(),
285            column.into_iter().map(Value::Float).collect(),
286        );
287        out
288    }
289
290    fn required_aes(&self) -> Vec<Aesthetic> {
291        vec![Aesthetic::From, Aesthetic::To]
292    }
293
294    /// The layout needs every link of the panel at once.
295    fn panelwise(&self) -> bool {
296        true
297    }
298
299    fn name(&self) -> &str {
300        "sankey"
301    }
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307
308    fn edges(e: &[(&str, &str, f64)]) -> DataFrame {
309        let mut d = DataFrame::new();
310        d.add_column(
311            "from".into(),
312            e.iter().map(|r| Value::Str(r.0.to_string())).collect(),
313        );
314        d.add_column(
315            "to".into(),
316            e.iter().map(|r| Value::Str(r.1.to_string())).collect(),
317        );
318        d.add_column(
319            "weight".into(),
320            e.iter().map(|r| Value::Float(r.2)).collect(),
321        );
322        d
323    }
324
325    #[test]
326    fn columns_values_and_conservation() {
327        let d = edges(&[
328            ("Coal", "Power", 40.0),
329            ("Gas", "Power", 30.0),
330            ("Gas", "Heat", 20.0),
331            ("Power", "Homes", 50.0),
332            ("Power", "Industry", 20.0),
333            ("Gas", "Power", 5.0),
334        ]);
335        let l = StatSankey::default().layout(&d).unwrap();
336        let idx = |n: &str| l.names.iter().position(|x| x == n).unwrap();
337        assert_eq!(l.column[idx("Coal")], 0);
338        assert_eq!(l.column[idx("Power")], 1);
339        assert_eq!(l.column[idx("Homes")], 2);
340        assert_eq!(l.column[idx("Heat")], 1);
341        assert_eq!(l.value[idx("Power")], 75.0, "max(in 75, out 70)");
342        assert_eq!(l.value[idx("Gas")], 55.0, "repeated pair summed");
343        assert_eq!(l.links.len(), 5);
344
345        let out = StatSankey::default().compute_group(&d, &ScaleSet::new());
346        assert_eq!(out.nrows(), 6 + 5);
347        // Every link band lies inside its source and target nodes.
348        let f = |c: &str| -> Vec<f64> {
349            out.column(c)
350                .unwrap()
351                .iter()
352                .map(|v| v.as_f64().unwrap())
353                .collect()
354        };
355        let (ymin, ymax, emin, emax) = (f("ymin"), f("ymax"), f("yend_min"), f("yend_max"));
356        let s = |c: &str, i: usize| out.column(c).unwrap()[i].key_str().into_owned();
357        let node_row = |n: &str| (0..6).find(|&i| s("node", i) == n).unwrap();
358        for i in 6..11 {
359            let (a, b) = (node_row(&s("from", i)), node_row(&s("to", i)));
360            assert!(ymin[i] >= ymin[a] - 1e-9 && ymax[i] <= ymax[a] + 1e-9);
361            assert!(emin[i] >= ymin[b] - 1e-9 && emax[i] <= ymax[b] + 1e-9);
362        }
363    }
364
365    #[test]
366    fn justify_moves_sinks_to_the_last_column() {
367        let d = edges(&[("A", "B", 1.0), ("B", "C", 1.0), ("A", "D", 1.0)]);
368        let l = StatSankey {
369            justify: true,
370            ..Default::default()
371        }
372        .layout(&d)
373        .unwrap();
374        let idx = |n: &str| l.names.iter().position(|x| x == n).unwrap();
375        assert_eq!(l.column[idx("D")], 2);
376    }
377
378    #[test]
379    fn cycles_terminate() {
380        let d = edges(&[("A", "B", 1.0), ("B", "A", 1.0)]);
381        assert!(StatSankey::default().layout(&d).is_some());
382    }
383}