Skip to main content

ggplot_rs/stat/
venn.rs

1//! `StatVenn`: area-proportional Venn / Euler layouts.
2//!
3//! Input rows give set sizes the venn.js way (inclusive): `sets = "A"` is
4//! the size of A, `sets = "A&B"` the size of A ∩ B, with the size in
5//! `weight`. Each set becomes a circle of area ∝ its size; pairwise centre
6//! distances are solved so circle overlaps match the pairwise
7//! intersections (exact for two sets), then centres are placed exactly
8//! (two or three sets) or by a deterministic stress-minimising descent
9//! (four or more). Higher-order intersections are not fitted.
10//!
11//! Output: one row per set (`part = "circle"`, `x`/`y`/`r`, `set`) and one
12//! per labelled region (`part = "label"`, `x`/`y` at the region's deepest
13//! interior point found by grid search, `text` = the set name for a set's
14//! own region or the size for an intersection row), plus `xmin`/`xmax`/
15//! `ymin`/`ymax` framing the diagram as a square.
16
17use std::f64::consts::PI;
18
19use crate::aes::Aesthetic;
20use crate::data::{DataFrame, Value};
21use crate::scale::ScaleSet;
22
23use super::Stat;
24
25/// Area of the lens where circles of radius `r1`, `r2` at distance `d` overlap.
26pub(crate) fn lens_area(r1: f64, r2: f64, d: f64) -> f64 {
27    if d >= r1 + r2 {
28        return 0.0;
29    }
30    if d <= (r1 - r2).abs() {
31        return PI * r1.min(r2).powi(2);
32    }
33    let a = r1
34        * r1
35        * ((d * d + r1 * r1 - r2 * r2) / (2.0 * d * r1))
36            .clamp(-1.0, 1.0)
37            .acos();
38    let b = r2
39        * r2
40        * ((d * d + r2 * r2 - r1 * r1) / (2.0 * d * r2))
41            .clamp(-1.0, 1.0)
42            .acos();
43    let c = 0.5
44        * ((-d + r1 + r2) * (d + r1 - r2) * (d - r1 + r2) * (d + r1 + r2))
45            .max(0.0)
46            .sqrt();
47    a + b - c
48}
49
50/// Centre distance giving an overlap of `overlap` (bisection; the lens area
51/// falls monotonically with distance).
52pub(crate) fn distance_for_overlap(r1: f64, r2: f64, overlap: f64) -> f64 {
53    let full = PI * r1.min(r2).powi(2);
54    if overlap <= 0.0 {
55        return r1 + r2 + 0.05 * (r1 + r2);
56    }
57    if overlap >= full {
58        return (r1 - r2).abs();
59    }
60    let (mut lo, mut hi) = ((r1 - r2).abs(), r1 + r2);
61    for _ in 0..100 {
62        let mid = (lo + hi) / 2.0;
63        if lens_area(r1, r2, mid) > overlap {
64            lo = mid;
65        } else {
66            hi = mid;
67        }
68    }
69    (lo + hi) / 2.0
70}
71
72/// Venn layout stat behind [`GeomVenn`](crate::geom::venn::GeomVenn).
73#[derive(Clone, Debug, Default)]
74pub struct StatVenn;
75
76impl Stat for StatVenn {
77    fn compute_group(&self, data: &DataFrame, _scales: &ScaleSet) -> DataFrame {
78        let Some(sets_col) = data.column("sets") else {
79            return DataFrame::new();
80        };
81        let weight = data.column("weight");
82        let parse = |v: &Value| -> Vec<String> {
83            let mut s: Vec<String> = v
84                .key_str()
85                .split('&')
86                .map(|p| p.trim().to_string())
87                .filter(|p| !p.is_empty())
88                .collect();
89            s.sort();
90            s.dedup();
91            s
92        };
93        // Sets in first-appearance order; sizes per combination.
94        let mut names: Vec<String> = Vec::new();
95        let mut combos: Vec<(Vec<String>, f64)> = Vec::new();
96        for i in 0..data.nrows() {
97            let combo = parse(&sets_col[i]);
98            let size = match weight {
99                Some(c) => c[i].as_f64().unwrap_or(f64::NAN),
100                None => 1.0,
101            };
102            if combo.is_empty() || !size.is_finite() || size < 0.0 {
103                continue;
104            }
105            for s in &combo {
106                if !names.contains(s) {
107                    names.push(s.clone());
108                }
109            }
110            combos.push((combo, size));
111        }
112        let n = names.len();
113        if n == 0 {
114            return DataFrame::new();
115        }
116        let idx = |s: &str| names.iter().position(|m| m == s).unwrap();
117        let mut size = vec![0.0; n];
118        let mut pair = vec![vec![0.0; n]; n];
119        for (combo, s) in &combos {
120            match combo.as_slice() {
121                [a] => size[idx(a)] = *s,
122                [a, b] => {
123                    let (i, j) = (idx(a), idx(b));
124                    pair[i][j] = *s;
125                    pair[j][i] = *s;
126                }
127                _ => {}
128            }
129        }
130        // A set given only through intersections gets at least their size.
131        for i in 0..n {
132            let implied = (0..n).map(|j| pair[i][j]).fold(0.0, f64::max);
133            size[i] = size[i].max(implied).max(1e-9);
134        }
135        let r: Vec<f64> = size.iter().map(|s| (s / PI).sqrt()).collect();
136        let mut d = vec![vec![0.0; n]; n];
137        for i in 0..n {
138            for j in i + 1..n {
139                let dij = distance_for_overlap(r[i], r[j], pair[i][j].min(size[i]).min(size[j]));
140                d[i][j] = dij;
141                d[j][i] = dij;
142            }
143        }
144        let mut pos = vec![(0.0, 0.0); n];
145        if n >= 2 {
146            pos[1] = (d[0][1], 0.0);
147        }
148        if n >= 3 {
149            // Third centre by the law of cosines (clamped when the three
150            // distances can't form a triangle).
151            let (a, b, c) = (d[0][1], d[0][2], d[1][2]);
152            let x = if a > 0.0 {
153                (a * a + b * b - c * c) / (2.0 * a)
154            } else {
155                0.0
156            };
157            pos[2] = (x, -(b * b - x * x).max(0.0).sqrt());
158        }
159        if n >= 4 {
160            // Start the rest on a circle, then descend on the stress
161            // Σ (|pi − pj| − dij)².
162            let spread = d.iter().flatten().fold(0.0f64, |m, &v| m.max(v));
163            for (k, p) in pos.iter_mut().enumerate().skip(3) {
164                let a = 2.0 * PI * k as f64 / n as f64;
165                *p = (spread * a.cos() / 2.0, spread * a.sin() / 2.0);
166            }
167            for it in 0..2000 {
168                let step = 0.05 * (1.0 - it as f64 / 2000.0) + 0.002;
169                let mut grad = vec![(0.0, 0.0); n];
170                for i in 0..n {
171                    for j in 0..n {
172                        if i == j {
173                            continue;
174                        }
175                        let (dx, dy) = (pos[i].0 - pos[j].0, pos[i].1 - pos[j].1);
176                        let dist = dx.hypot(dy).max(1e-9);
177                        let e = (dist - d[i][j]) / dist;
178                        grad[i].0 += e * dx;
179                        grad[i].1 += e * dy;
180                    }
181                }
182                for i in 0..n {
183                    pos[i].0 -= step * grad[i].0;
184                    pos[i].1 -= step * grad[i].1;
185                }
186            }
187        }
188        // Centre the diagram.
189        let (mut x0, mut x1, mut y0, mut y1) = (f64::MAX, f64::MIN, f64::MAX, f64::MIN);
190        for k in 0..n {
191            x0 = x0.min(pos[k].0 - r[k]);
192            x1 = x1.max(pos[k].0 + r[k]);
193            y0 = y0.min(pos[k].1 - r[k]);
194            y1 = y1.max(pos[k].1 + r[k]);
195        }
196        let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0);
197        for p in pos.iter_mut() {
198            p.0 -= cx;
199            p.1 -= cy;
200        }
201        let half = ((x1 - x0).max(y1 - y0) / 2.0) * 1.08;
202
203        // Region labels: deepest grid point inside exactly the combination's
204        // sets (and outside the others).
205        let inside = |p: (f64, f64), k: usize| (p.0 - pos[k].0).hypot(p.1 - pos[k].1) < r[k];
206        let margin =
207            |p: (f64, f64), k: usize| (r[k] - (p.0 - pos[k].0).hypot(p.1 - pos[k].1)).abs();
208        let grid = 80;
209        let mut labels: Vec<(f64, f64, String, usize)> = Vec::new();
210        for (combo, s) in &combos {
211            let members: Vec<usize> = combo.iter().map(|c| idx(c)).collect();
212            let mut best: Option<((f64, f64), f64)> = None;
213            for gx in 0..=grid {
214                for gy in 0..=grid {
215                    let p = (
216                        -half + 2.0 * half * gx as f64 / grid as f64,
217                        -half + 2.0 * half * gy as f64 / grid as f64,
218                    );
219                    if !(0..n).all(|k| inside(p, k) == members.contains(&k)) {
220                        continue;
221                    }
222                    let m = (0..n).map(|k| margin(p, k)).fold(f64::INFINITY, f64::min);
223                    if best.is_none_or(|b| m > b.1) {
224                        best = Some((p, m));
225                    }
226                }
227            }
228            if let Some((p, _)) = best {
229                let text = if members.len() == 1 {
230                    names[members[0]].clone()
231                } else {
232                    crate::format::format_value(&Value::Float(*s))
233                };
234                labels.push((p.0, p.1, text, members[0]));
235            }
236        }
237
238        let rows = n + labels.len();
239        let s = |v: &str| Value::Str(v.to_string());
240        let mut part = vec![s("circle"); n];
241        part.extend(std::iter::repeat_n(s("label"), labels.len()));
242        let xs: Vec<Value> = pos
243            .iter()
244            .map(|p| Value::Float(p.0))
245            .chain(labels.iter().map(|l| Value::Float(l.0)))
246            .collect();
247        let ys: Vec<Value> = pos
248            .iter()
249            .map(|p| Value::Float(p.1))
250            .chain(labels.iter().map(|l| Value::Float(l.1)))
251            .collect();
252        let rs: Vec<Value> = r
253            .iter()
254            .map(|&v| Value::Float(v))
255            .chain(std::iter::repeat_n(Value::Float(0.0), labels.len()))
256            .collect();
257        // A label row carries its region's first set, so a fill mapped on
258        // `set` never trains an NA level.
259        let set: Vec<Value> = names
260            .iter()
261            .map(|nm| s(nm))
262            .chain(labels.iter().map(|l| s(&names[l.3])))
263            .collect();
264        let text: Vec<Value> = names
265            .iter()
266            .map(|_| Value::Na)
267            .chain(labels.iter().map(|l| s(&l.2)))
268            .collect();
269        let sizes: Vec<Value> = size
270            .iter()
271            .map(|&v| Value::Float(v))
272            .chain(std::iter::repeat_n(Value::Na, labels.len()))
273            .collect();
274        let mut out = DataFrame::new();
275        out.add_column("part".into(), part);
276        out.add_column("x".into(), xs);
277        out.add_column("y".into(), ys);
278        out.add_column("r".into(), rs);
279        out.add_column("set".into(), set);
280        out.add_column("text".into(), text);
281        out.add_column("size".into(), sizes);
282        for (c, v) in [
283            ("xmin", -half),
284            ("xmax", half),
285            ("ymin", -half),
286            ("ymax", half),
287        ] {
288            out.add_column(c.into(), vec![Value::Float(v); rows]);
289        }
290        out
291    }
292
293    fn required_aes(&self) -> Vec<Aesthetic> {
294        vec![Aesthetic::Sets]
295    }
296
297    /// All sets of the panel form one diagram.
298    fn panelwise(&self) -> bool {
299        true
300    }
301
302    fn name(&self) -> &str {
303        "venn"
304    }
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310
311    fn data(rows: &[(&str, f64)]) -> DataFrame {
312        let mut d = DataFrame::new();
313        d.add_column(
314            "sets".into(),
315            rows.iter().map(|r| Value::Str(r.0.into())).collect(),
316        );
317        d.add_column(
318            "weight".into(),
319            rows.iter().map(|r| Value::Float(r.1)).collect(),
320        );
321        d
322    }
323
324    fn f(d: &DataFrame, c: &str) -> Vec<f64> {
325        d.column(c)
326            .unwrap()
327            .iter()
328            .map(|v| v.as_f64().unwrap_or(f64::NAN))
329            .collect()
330    }
331
332    #[test]
333    fn two_sets_overlap_exactly() {
334        let out = StatVenn.compute_group(
335            &data(&[("A", 100.0), ("B", 60.0), ("A&B", 25.0)]),
336            &ScaleSet::new(),
337        );
338        let (x, y, r) = (f(&out, "x"), f(&out, "y"), f(&out, "r"));
339        assert!((PI * r[0] * r[0] - 100.0).abs() < 1e-9);
340        let d = (x[0] - x[1]).hypot(y[0] - y[1]);
341        assert!((lens_area(r[0], r[1], d) - 25.0).abs() < 1e-6);
342        // Labels: A, B and the intersection size.
343        let texts: Vec<String> = out.column("text").unwrap()[2..]
344            .iter()
345            .map(|v| v.key_str().into_owned())
346            .collect();
347        assert_eq!(texts, ["A", "B", "25"]);
348    }
349
350    #[test]
351    fn three_and_four_sets_fit_pairwise_overlaps() {
352        let rows = [
353            ("A", 50.0),
354            ("B", 50.0),
355            ("C", 50.0),
356            ("A&B", 10.0),
357            ("A&C", 10.0),
358            ("B&C", 10.0),
359        ];
360        let out = StatVenn.compute_group(&data(&rows), &ScaleSet::new());
361        let (x, y, r) = (f(&out, "x"), f(&out, "y"), f(&out, "r"));
362        for (i, j) in [(0, 1), (0, 2), (1, 2)] {
363            let d = (x[i] - x[j]).hypot(y[i] - y[j]);
364            assert!((lens_area(r[i], r[j], d) - 10.0).abs() < 1e-6, "{i}{j}");
365        }
366        let four = [
367            ("A", 40.0),
368            ("B", 40.0),
369            ("C", 40.0),
370            ("D", 40.0),
371            ("A&B", 8.0),
372            ("B&C", 8.0),
373            ("C&D", 8.0),
374            ("A&D", 8.0),
375        ];
376        let out = StatVenn.compute_group(&data(&four), &ScaleSet::new());
377        let (x, y, r) = (f(&out, "x"), f(&out, "y"), f(&out, "r"));
378        for (i, j) in [(0, 1), (1, 2), (2, 3), (0, 3)] {
379            let d = (x[i] - x[j]).hypot(y[i] - y[j]);
380            let got = lens_area(r[i], r[j], d);
381            assert!((got - 8.0).abs() < 3.0, "{i}{j}: {got}");
382        }
383    }
384}