Skip to main content

ggplot_rs/stat/
chord.rs

1//! `StatChord`: angular layout for a chord diagram / dependency wheel.
2//!
3//! Input: an edge list (`from`, `to`, optional `weight`, 1 when unmapped;
4//! repeated pairs summed, self-loops dropped). Each node gets an arc on the
5//! circle proportional to its total flow (in + out, Highcharts' dependency
6//! wheel), separated by `pad_angle`; inside its arc a node lays out its
7//! outgoing links first, then its incoming ones, each ordered around the
8//! circle. Angles run clockwise from 12 o'clock, in radians.
9//!
10//! Output: one row per node (`part = "node"`, arc `a0..a1`) then one per link
11//! (`part = "link"`, source band `a0..a1`, target band `b0..b1`), with `node`
12//! (name; the source for a link), `from`, `to`, `value`, and constant
13//! `xmin`/`xmax`/`ymin`/`ymax` framing the unit circle plus label room so
14//! the position scales train on a square-ish extent.
15
16use std::collections::HashMap;
17use std::f64::consts::TAU;
18
19use crate::aes::Aesthetic;
20use crate::data::{DataFrame, Value};
21use crate::scale::ScaleSet;
22
23use super::Stat;
24
25/// Chord layout stat behind [`GeomChord`](crate::geom::chord::GeomChord).
26#[derive(Clone, Debug)]
27pub struct StatChord {
28    /// Gap between neighbouring node arcs, in radians.
29    pub pad_angle: f64,
30    /// Half-width of the trained x extent (the circle has radius 1; the rest
31    /// is room for labels).
32    pub x_extent: f64,
33    /// Half-height of the trained y extent.
34    pub y_extent: f64,
35}
36
37impl Default for StatChord {
38    fn default() -> Self {
39        StatChord {
40            pad_angle: 0.04,
41            x_extent: 1.45,
42            y_extent: 1.25,
43        }
44    }
45}
46
47impl Stat for StatChord {
48    fn compute_group(&self, data: &DataFrame, _scales: &ScaleSet) -> DataFrame {
49        let (Some(from), Some(to)) = (data.column("from"), data.column("to")) else {
50            return DataFrame::new();
51        };
52        let weight = data.column("weight");
53        let mut index: HashMap<String, usize> = HashMap::new();
54        let mut names: Vec<String> = Vec::new();
55        let mut id = |v: &Value| -> Option<usize> {
56            if v.is_na() {
57                return None;
58            }
59            let key = v.key_str().into_owned();
60            Some(*index.entry(key.clone()).or_insert_with(|| {
61                names.push(key);
62                names.len() - 1
63            }))
64        };
65        let mut pairs: indexmap::IndexMap<(usize, usize), f64> = indexmap::IndexMap::new();
66        for i in 0..data.nrows() {
67            let (Some(a), Some(b)) = (id(&from[i]), id(&to[i])) else {
68                continue;
69            };
70            let w = match weight {
71                Some(c) => match c[i].as_f64() {
72                    Some(w) if w.is_finite() && w > 0.0 => w,
73                    _ => continue,
74                },
75                None => 1.0,
76            };
77            if a != b {
78                *pairs.entry((a, b)).or_insert(0.0) += w;
79            }
80        }
81        let n = names.len();
82        let links: Vec<(usize, usize, f64)> =
83            pairs.into_iter().map(|((a, b), w)| (a, b, w)).collect();
84        if n == 0 || links.is_empty() {
85            return DataFrame::new();
86        }
87        let mut total = vec![0.0; n];
88        for &(a, b, w) in &links {
89            total[a] += w;
90            total[b] += w;
91        }
92        let pad = self.pad_angle.clamp(0.0, TAU / n as f64 * 0.5);
93        let k = (TAU - pad * n as f64) / total.iter().sum::<f64>();
94        let mut start = vec![0.0; n];
95        let mut a = 0.0;
96        for i in 0..n {
97            start[i] = a;
98            a += total[i] * k + pad;
99        }
100        // Order a node's bands around the circle: outgoing (by target
101        // position, clockwise from the node), then incoming (by source).
102        let rel = |from: usize, other: usize| (other + n - from) % n;
103        let mut src = vec![(0.0, 0.0); links.len()];
104        let mut dst = vec![(0.0, 0.0); links.len()];
105        for (node, &node_start) in start.iter().enumerate() {
106            let mut cursor = node_start;
107            let mut out: Vec<usize> = (0..links.len()).filter(|&l| links[l].0 == node).collect();
108            out.sort_by_key(|&l| std::cmp::Reverse(rel(node, links[l].1)));
109            for l in out {
110                src[l] = (cursor, cursor + links[l].2 * k);
111                cursor = src[l].1;
112            }
113            let mut inc: Vec<usize> = (0..links.len()).filter(|&l| links[l].1 == node).collect();
114            inc.sort_by_key(|&l| std::cmp::Reverse(rel(node, links[l].0)));
115            for l in inc {
116                dst[l] = (cursor, cursor + links[l].2 * k);
117                cursor = dst[l].1;
118            }
119        }
120
121        let rows = n + links.len();
122        let mut part = Vec::with_capacity(rows);
123        let (mut a0, mut a1, mut b0, mut b1) = (vec![], vec![], vec![], vec![]);
124        let (mut node, mut f, mut t, mut value) = (vec![], vec![], vec![], vec![]);
125        let s = |x: &str| Value::Str(x.to_string());
126        for i in 0..n {
127            let (lo, hi) = (start[i], start[i] + total[i] * k);
128            part.push(s("node"));
129            a0.push(lo);
130            a1.push(hi);
131            b0.push(lo);
132            b1.push(hi);
133            node.push(s(&names[i]));
134            f.push(s(&names[i]));
135            t.push(s(&names[i]));
136            value.push(total[i]);
137        }
138        for (l, &(x, y, w)) in links.iter().enumerate() {
139            part.push(s("link"));
140            a0.push(src[l].0);
141            a1.push(src[l].1);
142            b0.push(dst[l].0);
143            b1.push(dst[l].1);
144            node.push(s(&names[x]));
145            f.push(s(&names[x]));
146            t.push(s(&names[y]));
147            value.push(w);
148        }
149        let floats = |v: Vec<f64>| v.into_iter().map(Value::Float).collect::<Vec<_>>();
150        let mut outdf = DataFrame::new();
151        outdf.add_column("part".into(), part);
152        outdf.add_column("a0".into(), floats(a0));
153        outdf.add_column("a1".into(), floats(a1));
154        outdf.add_column("b0".into(), floats(b0));
155        outdf.add_column("b1".into(), floats(b1));
156        outdf.add_column("node".into(), node);
157        outdf.add_column("from".into(), f);
158        outdf.add_column("to".into(), t);
159        outdf.add_column("value".into(), floats(value));
160        for (c, v) in [
161            ("xmin", -self.x_extent),
162            ("xmax", self.x_extent),
163            ("ymin", -self.y_extent),
164            ("ymax", self.y_extent),
165        ] {
166            outdf.add_column(c.into(), vec![Value::Float(v); rows]);
167        }
168        outdf
169    }
170
171    fn required_aes(&self) -> Vec<Aesthetic> {
172        vec![Aesthetic::From, Aesthetic::To]
173    }
174
175    /// The layout needs every link of the panel at once.
176    fn panelwise(&self) -> bool {
177        true
178    }
179
180    fn name(&self) -> &str {
181        "chord"
182    }
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188
189    fn edges(e: &[(&str, &str, f64)]) -> DataFrame {
190        let mut d = DataFrame::new();
191        d.add_column(
192            "from".into(),
193            e.iter().map(|r| Value::Str(r.0.to_string())).collect(),
194        );
195        d.add_column(
196            "to".into(),
197            e.iter().map(|r| Value::Str(r.1.to_string())).collect(),
198        );
199        d.add_column(
200            "weight".into(),
201            e.iter().map(|r| Value::Float(r.2)).collect(),
202        );
203        d
204    }
205
206    fn f(d: &DataFrame, c: &str) -> Vec<f64> {
207        d.column(c)
208            .unwrap()
209            .iter()
210            .map(|v| v.as_f64().unwrap())
211            .collect()
212    }
213
214    #[test]
215    fn arcs_fill_the_circle_and_bands_nest_in_their_nodes() {
216        let d = edges(&[
217            ("A", "B", 10.0),
218            ("B", "A", 5.0),
219            ("A", "C", 3.0),
220            ("C", "B", 2.0),
221            ("A", "A", 9.0),
222        ]);
223        let stat = StatChord::default();
224        let out = stat.compute_group(&d, &ScaleSet::new());
225        assert_eq!(out.nrows(), 3 + 4, "self-loop dropped");
226        let (a0, a1, b0, b1) = (f(&out, "a0"), f(&out, "a1"), f(&out, "b0"), f(&out, "b1"));
227        let arcs: f64 = (0..3).map(|i| a1[i] - a0[i]).sum();
228        assert!((arcs + 3.0 * stat.pad_angle - TAU).abs() < 1e-9);
229        // A: out 13 + in 5 = 18 of 40 total flow (each link counted twice).
230        let k = arcs / 40.0;
231        assert!((a1[0] - a0[0] - 18.0 * k).abs() < 1e-9);
232        let name = |c: &str, i: usize| out.column(c).unwrap()[i].key_str().into_owned();
233        let node_of = |n: &str| (0..3).find(|&i| name("node", i) == n).unwrap();
234        for l in 3..7 {
235            let (s, t) = (node_of(&name("from", l)), node_of(&name("to", l)));
236            assert!(a0[l] >= a0[s] - 1e-9 && a1[l] <= a1[s] + 1e-9);
237            assert!(b0[l] >= a0[t] - 1e-9 && b1[l] <= a1[t] + 1e-9);
238        }
239    }
240}