Skip to main content

ggplot_rs/stat/
treemap.rs

1//! `StatTreemap`: squarified treemap layout (Bruls, Huizing & van Wijk).
2//!
3//! Each input row with a positive `weight` becomes one tile whose area is
4//! proportional to that weight. With a `subgroup` mapping, groups are laid
5//! out first (area = their summed weight) and each group's rows are tiled
6//! inside its rectangle (inset by `group_padding`). The layout fills
7//! `[0, aspect] × [0, 1]`.
8//!
9//! Output: one row per tile (`part = "tile"`, every input column carried
10//! through, `xmin`/`xmax`/`ymin`/`ymax`, `area` = weight) followed — when
11//! subgroups are mapped — by one row per group (`part = "group"`, its
12//! rectangle, `subgroup`, `area` = group total, `fill` = its first
13//! member's; other columns `Na`).
14
15use crate::aes::Aesthetic;
16use crate::data::{DataFrame, Value};
17use crate::scale::ScaleSet;
18
19use super::Stat;
20
21/// Rectangle `(x, y, w, h)` with `(x, y)` its lower-left corner.
22pub(crate) type Rect = (f64, f64, f64, f64);
23
24/// Squarified treemap stat behind [`GeomTreemap`](crate::geom::treemap::GeomTreemap).
25#[derive(Clone, Debug)]
26pub struct StatTreemap {
27    /// Layout width / height (match the panel's so tiles come out square).
28    pub aspect: f64,
29    /// Inset of each group's tiles inside the group rectangle, in layout units.
30    pub group_padding: f64,
31}
32
33impl Default for StatTreemap {
34    fn default() -> Self {
35        StatTreemap {
36            aspect: 1.6,
37            group_padding: 0.006,
38        }
39    }
40}
41
42/// Worst aspect ratio of a row of areas laid along a side of length `side`.
43fn worst(row: &[f64], side: f64) -> f64 {
44    let sum: f64 = row.iter().sum();
45    let (mx, mn) = row
46        .iter()
47        .fold((f64::MIN, f64::MAX), |(a, b), &v| (a.max(v), b.min(v)));
48    let (s2, sum2) = (side * side, sum * sum);
49    f64::max(s2 * mx / sum2, sum2 / (s2 * mn))
50}
51
52/// Squarified layout of `values` (any order; positive) inside `rect`.
53/// Returns one rectangle per value, in input order. Rows are laid along the
54/// shorter side, starting at the top-left.
55pub(crate) fn squarify(values: &[f64], rect: Rect) -> Vec<Rect> {
56    let mut out = vec![(0.0, 0.0, 0.0, 0.0); values.len()];
57    let total: f64 = values.iter().sum();
58    let (_, _, w0, h0) = rect;
59    if values.is_empty() || total <= 0.0 || w0 <= 0.0 || h0 <= 0.0 {
60        return out;
61    }
62    let scale = w0 * h0 / total;
63    let mut order: Vec<usize> = (0..values.len()).collect();
64    order.sort_by(|&a, &b| values[b].total_cmp(&values[a]));
65    let areas: Vec<f64> = order.iter().map(|&i| values[i] * scale).collect();
66
67    let (mut x, y, mut w, mut h) = rect;
68    let mut start = 0;
69    while start < areas.len() {
70        let side = w.min(h);
71        let mut end = start + 1;
72        while end < areas.len()
73            && worst(&areas[start..=end], side) <= worst(&areas[start..end], side)
74        {
75            end += 1;
76        }
77        let row = &areas[start..end];
78        let sum: f64 = row.iter().sum();
79        if w >= h {
80            // Vertical strip on the left, filled top to bottom.
81            let sw = if h > 0.0 { sum / h } else { 0.0 };
82            let mut top = y + h;
83            for (k, a) in row.iter().enumerate() {
84                let th = if sw > 0.0 { a / sw } else { 0.0 };
85                out[order[start + k]] = (x, top - th, sw, th);
86                top -= th;
87            }
88            x += sw;
89            w -= sw;
90        } else {
91            // Horizontal strip along the top, filled left to right.
92            let sh = if w > 0.0 { sum / w } else { 0.0 };
93            let mut left = x;
94            for (k, a) in row.iter().enumerate() {
95                let tw = if sh > 0.0 { a / sh } else { 0.0 };
96                out[order[start + k]] = (left, y + h - sh, tw, sh);
97                left += tw;
98            }
99            h -= sh;
100        }
101        start = end;
102    }
103    out
104}
105
106impl Stat for StatTreemap {
107    fn compute_group(&self, data: &DataFrame, _scales: &ScaleSet) -> DataFrame {
108        let Some(weight) = data.column("weight") else {
109            return DataFrame::new();
110        };
111        let rows: Vec<usize> = (0..data.nrows())
112            .filter(|&i| weight[i].as_f64().is_some_and(|w| w.is_finite() && w > 0.0))
113            .collect();
114        if rows.is_empty() {
115            return DataFrame::new();
116        }
117        let w = |i: usize| weight[i].as_f64().unwrap_or(0.0);
118        let full: Rect = (0.0, 0.0, self.aspect.max(1e-6), 1.0);
119        let mut tile = vec![(0.0, 0.0, 0.0, 0.0); data.nrows()];
120        let mut groups: Vec<(Value, f64, Rect, usize)> = Vec::new();
121
122        if let Some(sub) = data.column("subgroup") {
123            let mut members: indexmap::IndexMap<String, (Value, Vec<usize>)> =
124                indexmap::IndexMap::new();
125            for &i in &rows {
126                members
127                    .entry(sub[i].key_str().into_owned())
128                    .or_insert_with(|| (sub[i].clone(), Vec::new()))
129                    .1
130                    .push(i);
131            }
132            let totals: Vec<f64> = members
133                .values()
134                .map(|(_, m)| m.iter().map(|&i| w(i)).sum())
135                .collect();
136            let rects = squarify(&totals, full);
137            let p = self.group_padding;
138            for (((val, m), total), r) in members.into_values().zip(&totals).zip(rects) {
139                let inner = (
140                    r.0 + p,
141                    r.1 + p,
142                    (r.2 - 2.0 * p).max(0.0),
143                    (r.3 - 2.0 * p).max(0.0),
144                );
145                let vals: Vec<f64> = m.iter().map(|&i| w(i)).collect();
146                for (&i, t) in m.iter().zip(squarify(&vals, inner)) {
147                    tile[i] = t;
148                }
149                groups.push((val, *total, r, m[0]));
150            }
151        } else {
152            let vals: Vec<f64> = rows.iter().map(|&i| w(i)).collect();
153            for (&i, t) in rows.iter().zip(squarify(&vals, full)) {
154                tile[i] = t;
155            }
156        }
157
158        let n = rows.len() + groups.len();
159        let mut out = DataFrame::new();
160        let mut part = vec![Value::Str("tile".into()); rows.len()];
161        part.extend(std::iter::repeat_n(
162            Value::Str("group".into()),
163            groups.len(),
164        ));
165        out.add_column("part".into(), part);
166        let edge = |f: fn(&Rect) -> f64| -> Vec<Value> {
167            rows.iter()
168                .map(|&i| Value::Float(f(&tile[i])))
169                .chain(groups.iter().map(|g| Value::Float(f(&g.2))))
170                .collect()
171        };
172        out.add_column("xmin".into(), edge(|r| r.0));
173        out.add_column("xmax".into(), edge(|r| r.0 + r.2));
174        out.add_column("ymin".into(), edge(|r| r.1));
175        out.add_column("ymax".into(), edge(|r| r.1 + r.3));
176        out.add_column(
177            "area".into(),
178            rows.iter()
179                .map(|&i| Value::Float(w(i)))
180                .chain(groups.iter().map(|g| Value::Float(g.1)))
181                .collect(),
182        );
183        for name in data.column_names() {
184            if out.has_column(name) {
185                continue;
186            }
187            let col = data.column(name).unwrap_or_default();
188            let mut vals: Vec<Value> = rows.iter().map(|&i| col[i].clone()).collect();
189            if name == "subgroup" {
190                vals.extend(groups.iter().map(|g| g.0.clone()));
191            } else if name == "fill" {
192                // A group row isn't filled, but its fill must not train the
193                // scale with an NA level: take its first member's.
194                vals.extend(groups.iter().map(|g| col[g.3].clone()));
195            } else {
196                vals.extend(std::iter::repeat_n(Value::Na, groups.len()));
197            }
198            debug_assert_eq!(vals.len(), n);
199            out.add_column(name.to_string(), vals);
200        }
201        out
202    }
203
204    fn required_aes(&self) -> Vec<Aesthetic> {
205        vec![Aesthetic::Weight]
206    }
207
208    /// Tiles share one rectangle: every row of the panel is laid out at once.
209    fn panelwise(&self) -> bool {
210        true
211    }
212
213    fn name(&self) -> &str {
214        "treemap"
215    }
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221
222    #[test]
223    fn squarify_tiles_exactly_and_keeps_areas() {
224        let vals = [6.0, 6.0, 4.0, 3.0, 2.0, 2.0, 1.0];
225        let rect = (0.0, 0.0, 6.0, 4.0);
226        let r = squarify(&vals, rect);
227        let total: f64 = r.iter().map(|t| t.2 * t.3).sum();
228        assert!((total - 24.0).abs() < 1e-9);
229        for (v, t) in vals.iter().zip(&r) {
230            assert!((t.2 * t.3 - v).abs() < 1e-9, "{t:?}");
231            assert!(t.0 >= -1e-9 && t.0 + t.2 <= 6.0 + 1e-9);
232            assert!(t.1 >= -1e-9 && t.1 + t.3 <= 4.0 + 1e-9);
233        }
234        // Bruls et al.'s example: worst aspect ratio stays modest.
235        let worst = r
236            .iter()
237            .map(|t| (t.2 / t.3).max(t.3 / t.2))
238            .fold(0.0, f64::max);
239        assert!(worst < 3.0, "{worst}");
240        // No two tiles overlap.
241        for i in 0..r.len() {
242            for j in i + 1..r.len() {
243                let (a, b) = (r[i], r[j]);
244                let ox = (a.0 + a.2).min(b.0 + b.2) - a.0.max(b.0);
245                let oy = (a.1 + a.3).min(b.1 + b.3) - a.1.max(b.1);
246                assert!(ox <= 1e-9 || oy <= 1e-9, "{a:?} overlaps {b:?}");
247            }
248        }
249    }
250
251    #[test]
252    fn subgroups_nest_tiles_inside_group_rects() {
253        let mut d = DataFrame::new();
254        let s = |v: &[&str]| v.iter().map(|x| Value::Str(x.to_string())).collect();
255        d.add_column("label".into(), s(&["a", "b", "c", "d", "e"]));
256        d.add_column("subgroup".into(), s(&["G1", "G1", "G2", "G2", "G2"]));
257        d.add_column(
258            "weight".into(),
259            [5.0, 3.0, 4.0, 2.0, 2.0]
260                .iter()
261                .map(|&v| Value::Float(v))
262                .collect(),
263        );
264        let out = StatTreemap::default().compute_group(&d, &ScaleSet::new());
265        assert_eq!(out.nrows(), 5 + 2);
266        let f = |c: &str| -> Vec<f64> {
267            out.column(c)
268                .unwrap()
269                .iter()
270                .map(|v| v.as_f64().unwrap())
271                .collect()
272        };
273        let (x0, x1, y0, y1) = (f("xmin"), f("xmax"), f("ymin"), f("ymax"));
274        let sub = |i: usize| out.column("subgroup").unwrap()[i].key_str().into_owned();
275        for t in 0..5 {
276            let g = if sub(t) == "G1" { 5 } else { 6 };
277            assert!(x0[t] >= x0[g] && x1[t] <= x1[g] && y0[t] >= y0[g] && y1[t] <= y1[g]);
278        }
279        // G1 holds 8 of 16 units: half of the 1.6 × 1 layout.
280        let ga = (x1[5] - x0[5]) * (y1[5] - y0[5]);
281        assert!((ga - 0.8).abs() < 1e-9, "{ga}");
282        assert_eq!(out.column("label").unwrap()[5], Value::Na);
283    }
284}