Skip to main content

ggplot_rs/stat/
marimekko.rs

1//! `StatMarimekko`: variable-width column layouts — variwide charts and
2//! Marimekko (mosaic) charts.
3//!
4//! Columns are the distinct `x` values in data order; a column's width is
5//! its share of the summed `weight` (1 per row when unmapped). Columns sit
6//! side by side over `x ∈ [0, 1]` with an optional gap between them.
7//!
8//! * Variwide: each column is one bar of height `y` (the first row's).
9//! * Mosaic: each column is split by `fill` into stacked segments whose
10//!   heights are their share of the column (`y ∈ [0, 1]`).
11//!
12//! Output: one row per bar / segment with `xmin`/`xmax`/`ymin`/`ymax`,
13//! `x` (the column centre), `y` (the bar top / segment share), `column`
14//! (the category), `value` (summed weight), `share` (of the column in a
15//! mosaic, of the total in a variwide) and the carried `fill`.
16
17use crate::aes::Aesthetic;
18use crate::data::{DataFrame, Value};
19use crate::scale::ScaleSet;
20
21use super::Stat;
22
23/// Distinct `x` keys in data order with their summed weights.
24pub(crate) fn column_totals(x: &[Value], weight: Option<&[Value]>) -> Vec<(String, f64)> {
25    let mut cols: indexmap::IndexMap<String, f64> = indexmap::IndexMap::new();
26    for (i, v) in x.iter().enumerate() {
27        if v.is_na() {
28            continue;
29        }
30        let w = weight.map(|c| c[i].as_f64().unwrap_or(0.0)).unwrap_or(1.0);
31        *cols.entry(v.key_str().into_owned()).or_insert(0.0) += w.max(0.0);
32    }
33    cols.into_iter().collect()
34}
35
36/// `[xmin, xmax]` per column over `[0, 1]`, widths ∝ `widths`, separated by
37/// `gap` (a fraction of the full width per gap).
38pub(crate) fn column_spans(widths: &[f64], gap: f64) -> Vec<(f64, f64)> {
39    let k = widths.len();
40    let total: f64 = widths.iter().sum();
41    if k == 0 || total <= 0.0 {
42        return vec![(0.0, 0.0); k];
43    }
44    let gap = gap.clamp(0.0, 0.5 / k.max(1) as f64);
45    let avail = 1.0 - gap * (k - 1) as f64;
46    let mut x = 0.0;
47    widths
48        .iter()
49        .map(|w| {
50            let span = (x, x + avail * w / total);
51            x = span.1 + gap;
52            span
53        })
54        .collect()
55}
56
57/// One output bar / segment: (row of origin, column index, ymin, ymax,
58/// value, share).
59type Cell = (usize, usize, f64, f64, f64, f64);
60
61/// Variwide / Marimekko stat behind [`GeomMarimekko`](crate::geom::marimekko::GeomMarimekko).
62#[derive(Clone, Debug, Default)]
63pub struct StatMarimekko {
64    /// `true` for a mosaic (segments by `fill`), `false` for a variwide.
65    pub mosaic: bool,
66    /// Gap between columns as a fraction of the full width.
67    pub gap: f64,
68}
69
70impl Stat for StatMarimekko {
71    fn compute_group(&self, data: &DataFrame, _scales: &ScaleSet) -> DataFrame {
72        let Some(x) = data.column("x") else {
73            return DataFrame::new();
74        };
75        let weight = data.column("weight");
76        let totals = column_totals(x, weight);
77        let widths: Vec<f64> = totals.iter().map(|c| c.1).collect();
78        let spans = column_spans(&widths, self.gap);
79        let grand: f64 = widths.iter().sum();
80        let w = |i: usize| {
81            weight
82                .map(|c| c[i].as_f64().unwrap_or(0.0))
83                .unwrap_or(1.0)
84                .max(0.0)
85        };
86
87        let mut rows: Vec<Cell> = Vec::new();
88        for (k, (key, total)) in totals.iter().enumerate() {
89            let members: Vec<usize> = (0..data.nrows())
90                .filter(|&i| !x[i].is_na() && x[i].key_str() == key.as_str())
91                .collect();
92            if self.mosaic {
93                let fill = data.column("fill");
94                let mut segs: indexmap::IndexMap<String, (usize, f64)> = indexmap::IndexMap::new();
95                for &i in &members {
96                    let f = fill
97                        .map(|c| c[i].key_str().into_owned())
98                        .unwrap_or_default();
99                    segs.entry(f).or_insert((i, 0.0)).1 += w(i);
100                }
101                let mut y = 0.0;
102                for (first, v) in segs.into_values() {
103                    let share = if *total > 0.0 { v / total } else { 0.0 };
104                    rows.push((first, k, y, y + share, v, share));
105                    y += share;
106                }
107            } else if let Some(&first) = members.first() {
108                let h = data
109                    .column("y")
110                    .and_then(|c| c[first].as_f64())
111                    .unwrap_or(f64::NAN);
112                let share = if grand > 0.0 { total / grand } else { 0.0 };
113                rows.push((first, k, 0.0_f64.min(h), 0.0_f64.max(h), *total, share));
114            }
115        }
116
117        let f =
118            |g: &dyn Fn(&Cell) -> f64| rows.iter().map(|r| Value::Float(g(r))).collect::<Vec<_>>();
119        let mut out = DataFrame::new();
120        out.add_column("xmin".into(), f(&|r| spans[r.1].0));
121        out.add_column("xmax".into(), f(&|r| spans[r.1].1));
122        out.add_column("ymin".into(), f(&|r| r.2));
123        out.add_column("ymax".into(), f(&|r| r.3));
124        out.add_column("x".into(), f(&|r| (spans[r.1].0 + spans[r.1].1) / 2.0));
125        out.add_column(
126            "y".into(),
127            // Variwide bars span [min(0, h), max(0, h)], so ymin + ymax = h.
128            f(&|r| if self.mosaic { r.5 } else { r.2 + r.3 }),
129        );
130        out.add_column(
131            "column".into(),
132            rows.iter()
133                .map(|r| Value::Str(totals[r.1].0.clone()))
134                .collect(),
135        );
136        out.add_column("value".into(), f(&|r| r.4));
137        out.add_column("share".into(), f(&|r| r.5));
138        for name in data.column_names() {
139            if out.has_column(name) || name == "weight" {
140                continue;
141            }
142            let col = data.column(name).unwrap_or_default();
143            out.add_column(
144                name.to_string(),
145                rows.iter().map(|r| col[r.0].clone()).collect(),
146            );
147        }
148        out
149    }
150
151    fn required_aes(&self) -> Vec<Aesthetic> {
152        vec![Aesthetic::X]
153    }
154
155    /// Column widths are shares of the panel's total.
156    fn panelwise(&self) -> bool {
157        true
158    }
159
160    fn name(&self) -> &str {
161        "marimekko"
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    fn s(v: &[&str]) -> Vec<Value> {
170        v.iter().map(|x| Value::Str(x.to_string())).collect()
171    }
172    fn fl(v: &[f64]) -> Vec<Value> {
173        v.iter().map(|&x| Value::Float(x)).collect()
174    }
175    fn col(d: &DataFrame, c: &str) -> Vec<f64> {
176        d.column(c)
177            .unwrap()
178            .iter()
179            .map(|v| v.as_f64().unwrap())
180            .collect()
181    }
182
183    #[test]
184    fn spans_are_proportional_and_gapped() {
185        assert_eq!(
186            column_spans(&[1.0, 3.0], 0.0),
187            vec![(0.0, 0.25), (0.25, 1.0)]
188        );
189        let g = column_spans(&[1.0, 1.0], 0.1);
190        assert!((g[0].1 - 0.45).abs() < 1e-12 && (g[1].0 - 0.55).abs() < 1e-12);
191    }
192
193    #[test]
194    fn variwide_and_mosaic() {
195        let mut d = DataFrame::new();
196        d.add_column("x".into(), s(&["A", "B"]));
197        d.add_column("y".into(), fl(&[10.0, 4.0]));
198        d.add_column("weight".into(), fl(&[1.0, 3.0]));
199        let out = StatMarimekko::default().compute_group(&d, &ScaleSet::new());
200        assert_eq!(col(&out, "xmax"), vec![0.25, 1.0]);
201        assert_eq!(col(&out, "ymax"), vec![10.0, 4.0]);
202
203        let mut m = DataFrame::new();
204        m.add_column("x".into(), s(&["A", "A", "B", "B", "B"]));
205        m.add_column("fill".into(), s(&["p", "q", "p", "q", "p"]));
206        m.add_column("weight".into(), fl(&[1.0, 3.0, 2.0, 4.0, 2.0]));
207        let out = StatMarimekko {
208            mosaic: true,
209            gap: 0.0,
210        }
211        .compute_group(&m, &ScaleSet::new());
212        // A: 4 of 12 → [0, 1/3]; segments p 0.25, q 0.75. B: p 0.5, q 0.5.
213        assert_eq!(out.nrows(), 4);
214        let (x1, y0, y1) = (col(&out, "xmax"), col(&out, "ymin"), col(&out, "ymax"));
215        assert!((x1[0] - 1.0 / 3.0).abs() < 1e-12);
216        assert_eq!((y0[1], y1[1]), (0.25, 1.0));
217        assert_eq!((y0[3], y1[3]), (0.5, 1.0));
218    }
219}