Skip to main content

ggplot_rs/geom/
hex.rs

1use crate::aes::Aesthetic;
2use crate::coord::Coord;
3use crate::data::DataFrame;
4use crate::position::identity::PositionIdentity;
5use crate::position::Position;
6use crate::render::backend::{DrawBackend, RectStyle};
7use crate::render::RenderError;
8use crate::scale::ScaleSet;
9use crate::stat::binhex::{HexLattice, StatBinHex};
10use crate::stat::Stat;
11use crate::theme::Theme;
12
13use super::{Geom, GeomParams};
14
15/// Fallback hex cell size from the data's own resolution (the smallest gap
16/// between distinct values, like ggplot2's `resolution()`), used when the
17/// stat didn't supply `width`/`height` columns — e.g. a consumer feeding
18/// `GeomHex` pre-binned cells through `StatIdentity`.
19///
20/// On a pointy-top hex lattice with cell width `w` and height `h` (as
21/// [`HexLattice`] defines them — `w` is the horizontal spacing between
22/// same-row centres, `h*sqrt(3)/2` the row spacing), the *observed*
23/// resolution of pooled x/y values is not `w`/`h` themselves:
24/// - the x resolution is `w/2`, because offset rows are staggered by `w/2`;
25/// - the y resolution is the row spacing `h*sqrt(3)/2`.
26///
27/// So the cell size is the inverse of that: `w = 2*res(x)`,
28/// `h = 2*res(y)/sqrt(3)`. Using the raw resolution directly (a previous
29/// version of this fallback) drew cells at half their correct size and not
30/// regular (`width != height`, even on a square lattice).
31fn fallback_hex_size(xs: impl Iterator<Item = f64>, ys: impl Iterator<Item = f64>) -> (f64, f64) {
32    let res_x = super::continuous_bar_half_width(xs, 2.0, 1.0);
33    let res_y = super::continuous_bar_half_width(ys, 2.0, 1.0);
34    (2.0 * res_x, 2.0 * res_y / 3.0_f64.sqrt())
35}
36
37/// Hexagonal binning geometry — draws a hexagon per cell, in data units
38/// (not a fixed pixel size): each vertex is computed in data space from the
39/// row's `width`/`height` (as [`StatBinHex`] outputs them) and only then
40/// mapped and projected, so the lattice stays correct under any coord
41/// (including `coord_sf`).
42pub struct GeomHex {
43    pub color: (u8, u8, u8),
44    pub alpha: f64,
45    pub line_width: f64,
46}
47
48impl Default for GeomHex {
49    fn default() -> Self {
50        GeomHex {
51            color: (50, 50, 50),
52            alpha: 1.0,
53            line_width: 0.2,
54        }
55    }
56}
57
58impl GeomHex {
59    pub fn color(mut self, color: (u8, u8, u8)) -> Self {
60        self.color = color;
61        self
62    }
63
64    /// Sets `line_width` (the hex outline's stroke width).
65    pub fn linewidth(mut self, width: f64) -> Self {
66        self.line_width = width;
67        self
68    }
69}
70
71impl Geom for GeomHex {
72    fn draw(
73        &self,
74        data: &DataFrame,
75        coord: &dyn Coord,
76        scales: &ScaleSet,
77        _theme: &Theme,
78        backend: &mut dyn DrawBackend,
79    ) -> Result<(), RenderError> {
80        let x_col = data
81            .column("x")
82            .ok_or(RenderError::MissingAesthetic("x".into()))?;
83        let y_col = data
84            .column("y")
85            .ok_or(RenderError::MissingAesthetic("y".into()))?;
86        let fill_col = data.column("fill");
87        let width_col = data.column("width");
88        let height_col = data.column("height");
89
90        let plot_area = backend.plot_area();
91        let x_scale = scales.get(&Aesthetic::X);
92        let y_scale = scales.get(&Aesthetic::Y);
93
94        let (fallback_w, fallback_h) = fallback_hex_size(
95            x_col.iter().filter_map(|v| v.as_f64()),
96            y_col.iter().filter_map(|v| v.as_f64()),
97        );
98
99        for i in 0..data.nrows() {
100            let (Some(cx), Some(cy)) = (x_col[i].as_f64(), y_col[i].as_f64()) else {
101                continue;
102            };
103            let width = width_col
104                .and_then(|wc| wc[i].as_f64())
105                .filter(|w| w.is_finite() && *w > 0.0)
106                .unwrap_or(fallback_w);
107            let height = height_col
108                .and_then(|hc| hc[i].as_f64())
109                .filter(|h| h.is_finite() && *h > 0.0)
110                .unwrap_or(fallback_h);
111
112            let fill_color = fill_col
113                .and_then(|fc| scales.map_color(&Aesthetic::Fill, &fc[i]))
114                .unwrap_or((97, 156, 255));
115
116            super::set_mark(
117                backend,
118                fill_col.map(|c| format!("n = {}", super::tip_value(&c[i]))),
119                None,
120                None,
121                fill_col.and_then(|c| super::raw_value(&c[i])),
122            );
123            super::set_mark_formatted(
124                backend,
125                scales,
126                None,
127                &Aesthetic::Fill,
128                fill_col.map(|c| &c[i]),
129            );
130            super::set_row_extras(backend, data, i);
131
132            // Vertices in data space, each individually mapped + projected —
133            // correct under any coord, including `coord_sf`.
134            let verts_data = HexLattice::new(width, height, (cx, cy)).vertices(0, 0);
135            let vertices: Vec<(f64, f64)> = verts_data
136                .iter()
137                .map(|&(vx, vy)| {
138                    let nx = x_scale
139                        .map(|s| s.map(&crate::data::Value::Float(vx)))
140                        .unwrap_or(0.0);
141                    let ny = y_scale
142                        .map(|s| s.map(&crate::data::Value::Float(vy)))
143                        .unwrap_or(0.0);
144                    coord.transform((nx, ny), &plot_area)
145                })
146                .collect();
147
148            backend.draw_polygon(
149                &vertices,
150                &RectStyle {
151                    fill: Some(fill_color),
152                    stroke: Some(self.color),
153                    stroke_width: self.line_width,
154                    alpha: self.alpha,
155                    clip: true,
156                    corner_radius: 0.0,
157                },
158            )?;
159        }
160        super::clear_mark(backend);
161        super::clear_row_extras(backend);
162
163        Ok(())
164    }
165
166    fn required_aes(&self) -> Vec<Aesthetic> {
167        vec![Aesthetic::X, Aesthetic::Y]
168    }
169
170    fn default_stat(&self) -> Box<dyn Stat> {
171        Box::new(StatBinHex::default())
172    }
173
174    fn default_position(&self) -> Box<dyn Position> {
175        Box::new(PositionIdentity)
176    }
177
178    fn default_params(&self) -> GeomParams {
179        GeomParams::default()
180    }
181
182    fn name(&self) -> &str {
183        "hex"
184    }
185
186    fn apply_element_geom(&mut self, el: &crate::theme::elements::ElementGeom) {
187        if let (true, Some(ink)) = (self.color == (50, 50, 50), el.ink) {
188            self.color = ink;
189        }
190    }
191
192    fn consumes_tooltips(&self) -> bool {
193        true
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::fallback_hex_size;
200    use crate::data::Value;
201    use crate::prelude::*;
202
203    /// Review finding #5: `w = 2*res(x)`, `h = 2*res(y)/sqrt(3)` — not the
204    /// raw resolution, which draws half-size, irregular hexagons.
205    #[test]
206    fn fallback_size_is_twice_the_resolution_scaled_for_the_lattice() {
207        let (w, h) = fallback_hex_size([0.0, 2.0].into_iter(), [0.0, 3.0].into_iter());
208        assert!((w - 4.0).abs() < 1e-9, "w={w}");
209        let expected_h = 2.0 * 3.0 / 3.0_f64.sqrt();
210        assert!(
211            (h - expected_h).abs() < 1e-9,
212            "h={h}, expected={expected_h}"
213        );
214    }
215
216    #[test]
217    fn fallback_size_gives_regular_hexagons_on_a_square_lattice() {
218        // res(x) = res(y) = W/2 (x) is not how a real square lattice's raw
219        // columns look, but the fallback must still reproduce w == h when
220        // fed resolutions consistent with a *square* lattice: res(x) = W/2,
221        // res(y) = W*sqrt(3)/2 (see HexLattice::centre's row spacing).
222        let w_lattice = 5.0;
223        let res_x = w_lattice / 2.0;
224        let res_y = w_lattice * 3.0_f64.sqrt() / 2.0;
225        let (w, h) = fallback_hex_size([0.0, res_x].into_iter(), [0.0, res_y].into_iter());
226        assert!((w - w_lattice).abs() < 1e-9, "w={w}");
227        assert!((h - w_lattice).abs() < 1e-9, "h={h}");
228    }
229
230    /// End-to-end: pre-binned cells through `StatIdentity` (no width/height
231    /// columns) still draw regular hexagons at the full (not half) size.
232    /// Uses `coord_sf` (feature `sf`) for a true isotropic (equal
233    /// pixels-per-data-unit in x and y) panel: `coord_fixed`'s `ratio`
234    /// compares the normalized `[0, 1]` axes, not the real data range, so
235    /// it can't give this guarantee when x and y have different ranges (as
236    /// they deliberately do here).
237    #[test]
238    #[cfg(feature = "sf")]
239    fn geom_hex_without_width_height_columns_draws_regular_hexagons() {
240        // x/y resolutions consistent with a *square* (w == h) source
241        // lattice — res(y) = res(x)*sqrt(3), see `HexLattice::centre`'s row
242        // spacing — so the fallback size comes out regular (w == h).
243        let res_x = 5.0;
244        let res_y = res_x * 3.0_f64.sqrt();
245        let svg = GGPlot::new(vec![
246            ("x".into(), vec![Value::Float(0.0), Value::Float(res_x)]),
247            ("y".into(), vec![Value::Float(0.0), Value::Float(res_y)]),
248        ])
249        .aes(Aes::new().x("x").y("y"))
250        .geom_hex_with(GeomHex::default())
251        .stat(crate::stat::identity::StatIdentity)
252        .coord_sf()
253        .render_svg_native_with_size(400, 400)
254        .unwrap();
255
256        assert!(!svg.contains("NaN"));
257        let poly = svg.split("<polygon").nth(1).expect("a hex polygon");
258        let points_attr = poly
259            .split("points=\"")
260            .nth(1)
261            .unwrap()
262            .split('"')
263            .next()
264            .unwrap();
265        let pts: Vec<(f64, f64)> = points_attr
266            .split(' ')
267            .map(|p| {
268                let mut it = p.split(',');
269                let x: f64 = it.next().unwrap().parse().unwrap();
270                let y: f64 = it.next().unwrap().parse().unwrap();
271                (x, y)
272            })
273            .collect();
274        assert_eq!(pts.len(), 6);
275        let edge =
276            |a: (f64, f64), b: (f64, f64)| ((a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)).sqrt();
277        let edges: Vec<f64> = (0..6).map(|i| edge(pts[i], pts[(i + 1) % 6])).collect();
278        let first = edges[0];
279        for e in &edges {
280            assert!((e - first).abs() < 0.05, "edges should be equal: {edges:?}");
281        }
282    }
283
284    /// `.color()`/`.linewidth()` builders actually change the rendered hex
285    /// outline, not just the struct's fields.
286    #[test]
287    fn color_and_linewidth_builders_change_the_rendered_outline() {
288        let svg = GGPlot::new(vec![
289            ("x".into(), vec![Value::Float(0.0), Value::Float(1.0)]),
290            ("y".into(), vec![Value::Float(0.0), Value::Float(1.0)]),
291        ])
292        .aes(Aes::new().x("x").y("y"))
293        .geom_hex_with(GeomHex::default().color((10, 20, 30)).linewidth(3.0))
294        .render_svg_native_with_size(200, 200)
295        .unwrap();
296        assert!(svg.contains("stroke=\"#0A141E\""));
297        assert!(svg.contains("stroke-width=\"3.00\""));
298    }
299
300    /// A row with a non-numeric x/y is skipped, not rendered as a
301    /// degenerate hexagon at the origin or a NaN polygon.
302    #[test]
303    fn a_row_with_non_numeric_position_is_skipped() {
304        let svg = GGPlot::new(vec![
305            (
306                "x".into(),
307                vec![Value::Float(0.0), Value::Na, Value::Float(5.0)],
308            ),
309            (
310                "y".into(),
311                vec![Value::Float(0.0), Value::Float(1.0), Value::Float(5.0)],
312            ),
313        ])
314        .aes(Aes::new().x("x").y("y"))
315        .geom_hex_with(GeomHex::default())
316        .stat(crate::stat::identity::StatIdentity)
317        .render_svg_native_with_size(200, 200)
318        .unwrap();
319        assert!(!svg.contains("NaN"));
320        assert_eq!(svg.matches("<polygon").count(), 2);
321    }
322}