Skip to main content

ggplot_rs/geom/
treemap.rs

1//! Treemap: `geom_treemap`. Rectangles tile the panel with area ∝ `weight`;
2//! with a `subgroup` mapping, tiles nest inside group rectangles drawn with
3//! a heavier border and a group label. Layout by [`StatTreemap`].
4
5use crate::aes::Aesthetic;
6use crate::coord::Coord;
7use crate::data::{DataFrame, Value};
8use crate::position::identity::PositionIdentity;
9use crate::position::Position;
10use crate::render::backend::{DrawBackend, FontFace, RectStyle, TextAnchor, TextStyle};
11use crate::render::RenderError;
12use crate::scale::ScaleSet;
13use crate::stat::treemap::StatTreemap;
14use crate::stat::Stat;
15use crate::theme::Theme;
16
17use super::{Geom, GeomParams};
18
19/// Treemap. Map `weight` (tile area), optionally `subgroup` (hierarchy),
20/// `fill` and `label`.
21pub struct GeomTreemap {
22    /// Layout width / height; the builder sets the panel aspect to match so
23    /// tiles come out square.
24    pub aspect: f64,
25    /// Inset of a group's tiles inside its rectangle, in layout units
26    /// (the layout is 1 unit tall).
27    pub group_padding: f64,
28    /// Fill when `fill` is unmapped.
29    pub fill: (u8, u8, u8),
30    /// Tile border colour and width.
31    pub border: (u8, u8, u8),
32    pub border_width: f64,
33    /// Group border width.
34    pub group_border_width: f64,
35    pub alpha: f64,
36    /// Tile label size (shrunk to fit, hidden below `min_label_size`).
37    pub label_size: f64,
38    pub min_label_size: f64,
39    /// Group label size (0 hides group labels).
40    pub group_label_size: f64,
41}
42
43impl Default for GeomTreemap {
44    fn default() -> Self {
45        GeomTreemap {
46            aspect: 1.6,
47            group_padding: 0.006,
48            fill: (97, 156, 255),
49            border: (255, 255, 255),
50            border_width: 1.0,
51            group_border_width: 3.0,
52            alpha: 1.0,
53            label_size: 11.0,
54            min_label_size: 6.0,
55            group_label_size: 15.0,
56        }
57    }
58}
59
60/// Black or white, whichever reads better on `bg` (WCAG relative luminance).
61pub(crate) fn contrast_text(bg: (u8, u8, u8)) -> (u8, u8, u8) {
62    let lin = |c: u8| {
63        let c = c as f64 / 255.0;
64        if c <= 0.03928 {
65            c / 12.92
66        } else {
67            ((c + 0.055) / 1.055).powf(2.4)
68        }
69    };
70    let l = 0.2126 * lin(bg.0) + 0.7152 * lin(bg.1) + 0.0722 * lin(bg.2);
71    if l > 0.4 {
72        (30, 30, 30)
73    } else {
74        (255, 255, 255)
75    }
76}
77
78/// Largest font size ≤ `max` at which `text` fits a `w × h` pixel box
79/// (approximate glyph width 0.68 em, erring wide), or `None` below `min`.
80pub(crate) fn fit_size(text: &str, w: f64, h: f64, max: f64, min: f64) -> Option<f64> {
81    let chars = text.chars().count().max(1) as f64;
82    let size = max.min((w - 6.0) / (chars * 0.68)).min(h - 4.0);
83    (size >= min).then_some(size)
84}
85
86impl Geom for GeomTreemap {
87    fn draw(
88        &self,
89        data: &DataFrame,
90        coord: &dyn Coord,
91        scales: &ScaleSet,
92        theme: &Theme,
93        backend: &mut dyn DrawBackend,
94    ) -> Result<(), RenderError> {
95        let col = |n: &str| {
96            data.column(n)
97                .ok_or_else(|| RenderError::MissingAesthetic(n.into()))
98        };
99        let (part, x0c, x1c, y0c, y1c, area_c) = (
100            col("part")?,
101            col("xmin")?,
102            col("xmax")?,
103            col("ymin")?,
104            col("ymax")?,
105            col("area")?,
106        );
107        let fill_col = data.column("fill");
108        let label_col = data.column("label");
109        let sub_col = data.column("subgroup");
110        let area = backend.plot_area();
111        let px = |i: usize| {
112            let m = |aes: Aesthetic, v: &Value| scales.get(&aes).map(|s| s.map(v)).unwrap_or(0.0);
113            let (a, b) = (
114                coord.transform((m(Aesthetic::X, &x0c[i]), m(Aesthetic::Y, &y1c[i])), &area),
115                coord.transform((m(Aesthetic::X, &x1c[i]), m(Aesthetic::Y, &y0c[i])), &area),
116            );
117            ((a.0.min(b.0), a.1.min(b.1)), (a.0.max(b.0), a.1.max(b.1)))
118        };
119        let family = super::resolve_font_family("", theme);
120        let mut tile_labels = Vec::new();
121        for i in (0..data.nrows()).filter(|&i| part[i].key_str() == "tile") {
122            let ((l, t), (r, b)) = px(i);
123            let fill = fill_col
124                .and_then(|c| scales.map_color(&Aesthetic::Fill, &c[i]))
125                .unwrap_or(self.fill);
126            let label = label_col
127                .map(|c| super::tip_value(&c[i]))
128                .filter(|s| !s.is_empty());
129            let name = label
130                .clone()
131                .or_else(|| sub_col.map(|c| super::tip_value(&c[i])))
132                .unwrap_or_default();
133            let tip = if name.is_empty() {
134                super::tip_value(&area_c[i])
135            } else {
136                format!("{name}: {}", super::tip_value(&area_c[i]))
137            };
138            super::set_mark(
139                backend,
140                Some(tip),
141                label.clone(),
142                super::series_key(data, i),
143                super::raw_value(&area_c[i]),
144            );
145            super::set_row_extras(backend, data, i);
146            backend.draw_rect(
147                (l, t),
148                (r, b),
149                &RectStyle {
150                    fill: Some(fill),
151                    stroke: Some(self.border),
152                    stroke_width: self.border_width,
153                    alpha: self.alpha,
154                    clip: true,
155                    corner_radius: 0.0,
156                },
157            )?;
158            if let Some(label) = label {
159                if let Some(size) =
160                    fit_size(&label, r - l, b - t, self.label_size, self.min_label_size)
161                {
162                    let pos = (l + 4.0, t + 3.0 + size / 2.0);
163                    tile_labels.push((label, pos, size, contrast_text(fill)));
164                }
165            }
166        }
167        super::clear_row_extras(backend);
168        super::clear_mark(backend);
169
170        let groups: Vec<usize> = (0..data.nrows())
171            .filter(|&i| part[i].key_str() == "group")
172            .collect();
173        for &i in &groups {
174            let ((l, t), (r, b)) = px(i);
175            backend.draw_rect(
176                (l, t),
177                (r, b),
178                &RectStyle {
179                    fill: None,
180                    stroke: Some(self.border),
181                    stroke_width: self.group_border_width,
182                    alpha: 1.0,
183                    clip: true,
184                    corner_radius: 0.0,
185                },
186            )?;
187        }
188        for (label, pos, size, color) in tile_labels {
189            backend.draw_text(
190                &label,
191                pos,
192                &TextStyle {
193                    color,
194                    size,
195                    anchor: TextAnchor::Start,
196                    angle: 0.0,
197                    family: family.clone(),
198                    face: FontFace::Plain,
199                    bg_colour: None,
200                    bg_r: 0.0,
201                },
202            )?;
203        }
204        if self.group_label_size > 0.0 {
205            for &i in &groups {
206                let Some(name) = sub_col.map(|c| super::tip_value(&c[i])) else {
207                    continue;
208                };
209                let ((l, t), (r, b)) = px(i);
210                if let Some(size) = fit_size(&name, r - l, b - t, self.group_label_size, 8.0) {
211                    let center = ((l + r) / 2.0, (t + b) / 2.0);
212                    backend.draw_text(
213                        &name,
214                        center,
215                        &TextStyle {
216                            color: (255, 255, 255),
217                            size,
218                            anchor: TextAnchor::Middle,
219                            angle: 0.0,
220                            family: family.clone(),
221                            face: FontFace::Bold,
222                            bg_colour: Some((40, 40, 40)),
223                            bg_r: 2.0,
224                        },
225                    )?;
226                }
227            }
228        }
229        Ok(())
230    }
231
232    fn required_aes(&self) -> Vec<Aesthetic> {
233        vec![Aesthetic::Weight]
234    }
235
236    fn default_stat(&self) -> Box<dyn Stat> {
237        Box::new(StatTreemap {
238            aspect: self.aspect,
239            group_padding: self.group_padding,
240        })
241    }
242
243    fn default_position(&self) -> Box<dyn Position> {
244        Box::new(PositionIdentity)
245    }
246
247    fn default_params(&self) -> GeomParams {
248        GeomParams::default()
249    }
250
251    fn name(&self) -> &str {
252        "treemap"
253    }
254
255    fn consumes_tooltips(&self) -> bool {
256        true
257    }
258
259    fn apply_element_geom(&mut self, el: &crate::theme::elements::ElementGeom) {
260        if let (true, Some(paper)) = (self.border == (255, 255, 255), el.paper) {
261            self.border = paper;
262        }
263    }
264
265    fn set_series_color(&mut self, color: (u8, u8, u8)) {
266        self.fill = color;
267    }
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273
274    #[test]
275    fn label_fitting_and_contrast() {
276        assert_eq!(fit_size("abc", 200.0, 50.0, 11.0, 6.0), Some(11.0));
277        assert!(fit_size("a long label", 30.0, 50.0, 11.0, 6.0).is_none());
278        assert_eq!(contrast_text((255, 255, 200)), (30, 30, 30));
279        assert_eq!(contrast_text((20, 40, 90)), (255, 255, 255));
280    }
281}