Skip to main content

ggplot_rs/geom/
tilemap.rs

1//! Tile-grid cartograms: `geom_tilemap`. Each row is one equal-size tile
2//! (square, hexagon, circle or diamond) at grid position `x` (column) /
3//! `y` (row, 0 = top), filled by `fill` and labelled by `label` — e.g. US
4//! states or EU countries as a hex map. The grid is fitted into the panel
5//! with one uniform scale, so tiles stay regular at any aspect ratio.
6
7use std::f64::consts::PI;
8
9use crate::aes::Aesthetic;
10use crate::coord::Coord;
11use crate::data::DataFrame;
12use crate::position::identity::PositionIdentity;
13use crate::position::Position;
14use crate::render::backend::{DrawBackend, FontFace, RectStyle, TextAnchor, TextStyle};
15use crate::render::RenderError;
16use crate::scale::ScaleSet;
17use crate::stat::identity::StatIdentity;
18use crate::stat::Stat;
19use crate::theme::Theme;
20
21use super::treemap::{contrast_text, fit_size};
22use super::{Geom, GeomParams};
23
24/// Tile shape.
25#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
26pub enum TileShape {
27    #[default]
28    Square,
29    /// Pointy-top hexagons; odd rows shift half a tile right.
30    Hexagon,
31    Circle,
32    Diamond,
33}
34
35/// Tile map. Map `x` (grid column), `y` (grid row, 0 at the top), `fill`
36/// and `label`.
37pub struct GeomTilemap {
38    pub shape: TileShape,
39    /// Gap between tiles as a fraction of the tile size.
40    pub gap: f64,
41    /// Fill when `fill` is unmapped (or a value is missing).
42    pub fill: (u8, u8, u8),
43    pub label_size: f64,
44}
45
46impl Default for GeomTilemap {
47    fn default() -> Self {
48        GeomTilemap {
49            shape: TileShape::Square,
50            gap: 0.08,
51            fill: (200, 200, 200),
52            label_size: 10.0,
53        }
54    }
55}
56
57/// Grid centre of a tile in tile units (x right, y down).
58fn centre(shape: TileShape, col: f64, row: f64) -> (f64, f64) {
59    match shape {
60        TileShape::Hexagon => {
61            let odd = (row.round() as i64).rem_euclid(2) == 1;
62            (col + if odd { 0.5 } else { 0.0 }, row * 3f64.sqrt() / 2.0)
63        }
64        _ => (col, row),
65    }
66}
67
68impl Geom for GeomTilemap {
69    fn draw(
70        &self,
71        data: &DataFrame,
72        _coord: &dyn Coord,
73        scales: &ScaleSet,
74        theme: &Theme,
75        backend: &mut dyn DrawBackend,
76    ) -> Result<(), RenderError> {
77        let col = |n: &str| {
78            data.column(n)
79                .ok_or_else(|| RenderError::MissingAesthetic(n.into()))
80        };
81        let (xc, yc) = (col("x")?, col("y")?);
82        let (fill_col, label_col) = (data.column("fill"), data.column("label"));
83        let tiles: Vec<(usize, (f64, f64))> = (0..data.nrows())
84            .filter_map(|i| Some((i, centre(self.shape, xc[i].as_f64()?, yc[i].as_f64()?))))
85            .collect();
86        if tiles.is_empty() {
87            return Ok(());
88        }
89        let area = backend.plot_area();
90        let (mut x0, mut x1, mut y0, mut y1) = (f64::MAX, f64::MIN, f64::MAX, f64::MIN);
91        for (_, (x, y)) in &tiles {
92            x0 = x0.min(*x);
93            x1 = x1.max(*x);
94            y0 = y0.min(*y);
95            y1 = y1.max(*y);
96        }
97        // One tile of margin around the grid; uniform px per tile unit.
98        let (gw, gh) = (x1 - x0 + 1.0, y1 - y0 + 1.0);
99        let unit = (area.width / gw).min(area.height / gh);
100        let ox = area.x + (area.width - unit * gw) / 2.0 + unit / 2.0 - x0 * unit;
101        let oy = area.y + (area.height - unit * gh) / 2.0 + unit / 2.0 - y0 * unit;
102        let r = unit * (1.0 - self.gap) / 2.0;
103        let family = super::resolve_font_family("", theme);
104        let mut labels = Vec::new();
105        for (i, (x, y)) in tiles {
106            let c = (ox + x * unit, oy + y * unit);
107            let fill = fill_col
108                .and_then(|f| scales.map_color(&Aesthetic::Fill, &f[i]))
109                .unwrap_or(self.fill);
110            let name = label_col
111                .map(|l| super::tip_value(&l[i]))
112                .unwrap_or_default();
113            let value = fill_col
114                .map(|f| super::tip_value(&f[i]))
115                .unwrap_or_default();
116            let tip = match (name.is_empty(), value.is_empty()) {
117                (false, false) => format!("{name}: {value}"),
118                (false, true) => name.clone(),
119                _ => value.clone(),
120            };
121            super::set_mark(
122                backend,
123                (!tip.is_empty()).then_some(tip),
124                (!name.is_empty()).then(|| name.clone()),
125                super::series_key(data, i),
126                fill_col.and_then(|f| super::raw_value(&f[i])),
127            );
128            super::set_row_extras(backend, data, i);
129            let style = RectStyle {
130                fill: Some(fill),
131                stroke: None,
132                stroke_width: 0.0,
133                alpha: 1.0,
134                clip: true,
135                corner_radius: 0.0,
136            };
137            let poly = |k: usize, rad: f64, phase: f64| -> Vec<(f64, f64)> {
138                (0..k)
139                    .map(|j| {
140                        let a = phase + 2.0 * PI * j as f64 / k as f64;
141                        (c.0 + rad * a.cos(), c.1 + rad * a.sin())
142                    })
143                    .collect()
144            };
145            match self.shape {
146                TileShape::Square => {
147                    backend.draw_rect((c.0 - r, c.1 - r), (c.0 + r, c.1 + r), &style)?
148                }
149                // Pointy-top: vertices at ±30°, ±90°, ±150°; circumradius so
150                // neighbours (one unit apart) just touch.
151                TileShape::Hexagon => {
152                    backend.draw_polygon(&poly(6, r * 2.0 / 3f64.sqrt(), PI / 6.0), &style)?
153                }
154                TileShape::Circle => backend.draw_polygon(&poly(48, r, 0.0), &style)?,
155                TileShape::Diamond => backend.draw_polygon(&poly(4, r, 0.0), &style)?,
156            }
157            if !name.is_empty() {
158                if let Some(size) = fit_size(&name, 1.6 * r, 1.6 * r, self.label_size, 5.0) {
159                    labels.push((name, c, size, contrast_text(fill)));
160                }
161            }
162        }
163        super::clear_mark(backend);
164        super::clear_row_extras(backend);
165        for (text, pos, size, color) in labels {
166            backend.draw_text(
167                &text,
168                pos,
169                &TextStyle {
170                    color,
171                    size,
172                    anchor: TextAnchor::Middle,
173                    angle: 0.0,
174                    family: family.clone(),
175                    face: FontFace::Bold,
176                    bg_colour: None,
177                    bg_r: 0.0,
178                },
179            )?;
180        }
181        Ok(())
182    }
183
184    fn required_aes(&self) -> Vec<Aesthetic> {
185        vec![Aesthetic::X, Aesthetic::Y]
186    }
187    fn default_stat(&self) -> Box<dyn Stat> {
188        Box::new(StatIdentity)
189    }
190    fn default_position(&self) -> Box<dyn Position> {
191        Box::new(PositionIdentity)
192    }
193    fn default_params(&self) -> GeomParams {
194        GeomParams::default()
195    }
196    fn name(&self) -> &str {
197        "tilemap"
198    }
199    fn consumes_tooltips(&self) -> bool {
200        true
201    }
202
203    /// Tiles with no `fill` mapping take the brand colour (#362).
204    fn set_series_color(&mut self, color: (u8, u8, u8)) {
205        self.fill = color;
206    }
207}
208
209/// A common square tile layout of the 50 US states + DC: `(abbreviation,
210/// column, row)` with row 0 at the top — join it to your data by state and
211/// map `x = column`, `y = row`.
212pub fn us_state_grid() -> Vec<(&'static str, i64, i64)> {
213    vec![
214        ("AK", 0, 0),
215        ("ME", 11, 0),
216        ("VT", 10, 1),
217        ("NH", 11, 1),
218        ("WA", 1, 2),
219        ("ID", 2, 2),
220        ("MT", 3, 2),
221        ("ND", 4, 2),
222        ("MN", 5, 2),
223        ("IL", 6, 2),
224        ("WI", 7, 2),
225        ("MI", 8, 2),
226        ("NY", 9, 2),
227        ("RI", 10, 2),
228        ("MA", 11, 2),
229        ("OR", 1, 3),
230        ("NV", 2, 3),
231        ("WY", 3, 3),
232        ("SD", 4, 3),
233        ("IA", 5, 3),
234        ("IN", 6, 3),
235        ("OH", 7, 3),
236        ("PA", 8, 3),
237        ("NJ", 9, 3),
238        ("CT", 10, 3),
239        ("CA", 1, 4),
240        ("UT", 2, 4),
241        ("CO", 3, 4),
242        ("NE", 4, 4),
243        ("MO", 5, 4),
244        ("KY", 6, 4),
245        ("WV", 7, 4),
246        ("VA", 8, 4),
247        ("MD", 9, 4),
248        ("DE", 10, 4),
249        ("AZ", 2, 5),
250        ("NM", 3, 5),
251        ("KS", 4, 5),
252        ("AR", 5, 5),
253        ("TN", 6, 5),
254        ("NC", 7, 5),
255        ("SC", 8, 5),
256        ("DC", 9, 5),
257        ("OK", 4, 6),
258        ("LA", 5, 6),
259        ("MS", 6, 6),
260        ("AL", 7, 6),
261        ("GA", 8, 6),
262        ("HI", 0, 7),
263        ("TX", 4, 7),
264        ("FL", 9, 7),
265    ]
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271
272    #[test]
273    fn grid_is_complete_and_unique() {
274        let g = us_state_grid();
275        assert_eq!(g.len(), 51);
276        let mut cells: Vec<(i64, i64)> = g.iter().map(|s| (s.1, s.2)).collect();
277        cells.sort();
278        cells.dedup();
279        assert_eq!(cells.len(), 51, "no two states share a cell");
280        assert_eq!(
281            centre(TileShape::Hexagon, 2.0, 1.0),
282            (2.5, 3f64.sqrt() / 2.0)
283        );
284        assert_eq!(centre(TileShape::Square, 2.0, 1.0), (2.0, 1.0));
285    }
286}