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 key_col = data.column("key");
110        let sub_col = data.column("subgroup");
111        let area = backend.plot_area();
112        let px = |i: usize| {
113            let m = |aes: Aesthetic, v: &Value| scales.get(&aes).map(|s| s.map(v)).unwrap_or(0.0);
114            let (a, b) = (
115                coord.transform((m(Aesthetic::X, &x0c[i]), m(Aesthetic::Y, &y1c[i])), &area),
116                coord.transform((m(Aesthetic::X, &x1c[i]), m(Aesthetic::Y, &y0c[i])), &area),
117            );
118            ((a.0.min(b.0), a.1.min(b.1)), (a.0.max(b.0), a.1.max(b.1)))
119        };
120        let family = super::resolve_font_family("", theme);
121        let mut tile_labels = Vec::new();
122        for i in (0..data.nrows()).filter(|&i| part[i].key_str() == "tile") {
123            let ((l, t), (r, b)) = px(i);
124            let fill = fill_col
125                .and_then(|c| scales.map_color(&Aesthetic::Fill, &c[i]))
126                .unwrap_or(self.fill);
127            let label = label_col
128                .map(|c| super::tip_value(&c[i]))
129                .filter(|s| !s.is_empty());
130            let name = label
131                .clone()
132                .or_else(|| sub_col.map(|c| super::tip_value(&c[i])))
133                .unwrap_or_default();
134            let tip = if name.is_empty() {
135                super::tip_value(&area_c[i])
136            } else {
137                format!("{name}: {}", super::tip_value(&area_c[i]))
138            };
139            // data-x: the key (#476) where mapped, else the shown label.
140            let key = key_col
141                .map(|c| super::tip_value(&c[i]))
142                .filter(|s| !s.is_empty())
143                .or_else(|| label.clone());
144            super::set_mark(
145                backend,
146                Some(tip),
147                key,
148                super::series_key(data, i),
149                super::raw_value(&area_c[i]),
150            );
151            super::set_row_extras(backend, data, i);
152            backend.draw_rect(
153                (l, t),
154                (r, b),
155                &RectStyle {
156                    fill: Some(fill),
157                    stroke: Some(self.border),
158                    stroke_width: self.border_width,
159                    alpha: self.alpha,
160                    clip: true,
161                    corner_radius: 0.0,
162                },
163            )?;
164            if let Some(label) = label {
165                if let Some(size) =
166                    fit_size(&label, r - l, b - t, self.label_size, self.min_label_size)
167                {
168                    let pos = (l + 4.0, t + 3.0 + size / 2.0);
169                    tile_labels.push((label, pos, size, contrast_text(fill)));
170                }
171            }
172        }
173        super::clear_row_extras(backend);
174        super::clear_mark(backend);
175
176        let groups: Vec<usize> = (0..data.nrows())
177            .filter(|&i| part[i].key_str() == "group")
178            .collect();
179        for &i in &groups {
180            let ((l, t), (r, b)) = px(i);
181            backend.draw_rect(
182                (l, t),
183                (r, b),
184                &RectStyle {
185                    fill: None,
186                    stroke: Some(self.border),
187                    stroke_width: self.group_border_width,
188                    alpha: 1.0,
189                    clip: true,
190                    corner_radius: 0.0,
191                },
192            )?;
193        }
194        for (label, pos, size, color) in tile_labels {
195            backend.draw_text(
196                &label,
197                pos,
198                &TextStyle {
199                    color,
200                    size,
201                    anchor: TextAnchor::Start,
202                    angle: 0.0,
203                    family: family.clone(),
204                    face: FontFace::Plain,
205                    bg_colour: None,
206                    bg_r: 0.0,
207                },
208            )?;
209        }
210        if self.group_label_size > 0.0 {
211            for &i in &groups {
212                let Some(name) = sub_col.map(|c| super::tip_value(&c[i])) else {
213                    continue;
214                };
215                let ((l, t), (r, b)) = px(i);
216                if let Some(size) = fit_size(&name, r - l, b - t, self.group_label_size, 8.0) {
217                    let center = ((l + r) / 2.0, (t + b) / 2.0);
218                    backend.draw_text(
219                        &name,
220                        center,
221                        &TextStyle {
222                            color: (255, 255, 255),
223                            size,
224                            anchor: TextAnchor::Middle,
225                            angle: 0.0,
226                            family: family.clone(),
227                            face: FontFace::Bold,
228                            bg_colour: Some((40, 40, 40)),
229                            bg_r: 2.0,
230                        },
231                    )?;
232                }
233            }
234        }
235        Ok(())
236    }
237
238    fn required_aes(&self) -> Vec<Aesthetic> {
239        vec![Aesthetic::Weight]
240    }
241
242    fn default_stat(&self) -> Box<dyn Stat> {
243        Box::new(StatTreemap {
244            aspect: self.aspect,
245            group_padding: self.group_padding,
246        })
247    }
248
249    fn default_position(&self) -> Box<dyn Position> {
250        Box::new(PositionIdentity)
251    }
252
253    fn default_params(&self) -> GeomParams {
254        GeomParams::default()
255    }
256
257    fn name(&self) -> &str {
258        "treemap"
259    }
260
261    fn consumes_tooltips(&self) -> bool {
262        true
263    }
264
265    fn apply_element_geom(&mut self, el: &crate::theme::elements::ElementGeom) {
266        if let (true, Some(paper)) = (self.border == (255, 255, 255), el.paper) {
267            self.border = paper;
268        }
269    }
270
271    fn set_series_color(&mut self, color: (u8, u8, u8)) {
272        self.fill = color;
273    }
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279
280    #[test]
281    fn label_fitting_and_contrast() {
282        assert_eq!(fit_size("abc", 200.0, 50.0, 11.0, 6.0), Some(11.0));
283        assert!(fit_size("a long label", 30.0, 50.0, 11.0, 6.0).is_none());
284        assert_eq!(contrast_text((255, 255, 200)), (30, 30, 30));
285        assert_eq!(contrast_text((20, 40, 90)), (255, 255, 255));
286    }
287}