Skip to main content

ggplot_rs/stat/
binhex.rs

1use crate::aes::Aesthetic;
2use crate::data::{DataFrame, Value};
3use crate::scale::ScaleSet;
4
5use super::Stat;
6
7/// A pointy-top hex lattice in data units: points are assigned to the
8/// nearest centre via cube rounding, so bins are fixed by `(w, h, origin)`
9/// alone — unlike floor-binned rectangles, they don't move when the data
10/// extent changes.
11///
12/// `w`/`h` are the horizontal/vertical centre-to-centre spacing; equal
13/// `w == h` gives a regular hexagon, independent values stretch it (as
14/// ggplot2's `hexbin` does for non-square bin counts).
15#[derive(Clone, Copy, Debug, PartialEq)]
16pub struct HexLattice {
17    pub w: f64,
18    pub h: f64,
19    pub origin: (f64, f64),
20}
21
22impl HexLattice {
23    pub fn new(w: f64, h: f64, origin: (f64, f64)) -> Self {
24        HexLattice { w, h, origin }
25    }
26
27    /// The centre of cell `(q, r)` in data units: `x = ox + w*(q + r/2)`,
28    /// `y = oy + r*h*sqrt(3)/2`.
29    pub fn centre(&self, q: i64, r: i64) -> (f64, f64) {
30        let x = self.origin.0 + self.w * (q as f64 + r as f64 / 2.0);
31        let y = self.origin.1 + r as f64 * self.h * SQRT_3 / 2.0;
32        (x, y)
33    }
34
35    /// The 6 vertices of cell `(q, r)`, pointy-top, in data units.
36    pub fn vertices(&self, q: i64, r: i64) -> [(f64, f64); 6] {
37        let (cx, cy) = self.centre(q, r);
38        let radius = 1.0 / SQRT_3;
39        std::array::from_fn(|k| {
40            let angle = std::f64::consts::FRAC_PI_2 + std::f64::consts::FRAC_PI_3 * k as f64;
41            (
42                cx + radius * self.w * angle.cos(),
43                cy + radius * self.h * angle.sin(),
44            )
45        })
46    }
47
48    /// The cell nearest `(x, y)`, via cube rounding on normalized axial
49    /// coordinates (the inverse of [`centre`](Self::centre)).
50    pub fn locate(&self, x: f64, y: f64) -> (i64, i64) {
51        let r_frac = (y - self.origin.1) * 2.0 / (self.h * SQRT_3);
52        let q_frac = (x - self.origin.0) / self.w - r_frac / 2.0;
53        let cx = q_frac;
54        let cz = r_frac;
55        let cy = -cx - cz;
56        let (rx, _ry, rz) = cube_round(cx, cy, cz);
57        (rx as i64, rz as i64)
58    }
59}
60
61const SQRT_3: f64 = 1.732_050_807_568_877_2;
62
63fn cube_round(x: f64, y: f64, z: f64) -> (f64, f64, f64) {
64    let mut rx = x.round();
65    let mut ry = y.round();
66    let mut rz = z.round();
67    let dx = (rx - x).abs();
68    let dy = (ry - y).abs();
69    let dz = (rz - z).abs();
70    if dx > dy && dx > dz {
71        rx = -ry - rz;
72    } else if dy > dz {
73        ry = -rx - rz;
74    } else {
75        rz = -rx - ry;
76    }
77    (rx, ry, rz)
78}
79
80fn minmax(xs: &[f64]) -> (f64, f64) {
81    let min = xs.iter().cloned().fold(f64::INFINITY, f64::min);
82    let max = xs.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
83    (min, max)
84}
85
86/// Hexagonal binning on a fixed hex lattice (data units), unlike the
87/// floor-binned staggered rectangles this replaces (see the 0.20.0
88/// CHANGELOG). Output: `x, y` (hex centres), `count`, `density`, `ncount`,
89/// `ndensity`, `width`, `height`, `fill` (= `count`, back-compat), `n`
90/// (unweighted point count), `q`, `r`.
91pub struct StatBinHex {
92    pub bins_x: usize,
93    pub bins_y: usize,
94    pub binwidth: Option<(f64, f64)>,
95    pub origin: Option<(f64, f64)>,
96}
97
98impl Default for StatBinHex {
99    fn default() -> Self {
100        StatBinHex {
101            bins_x: 30,
102            bins_y: 30,
103            binwidth: None,
104            origin: None,
105        }
106    }
107}
108
109impl StatBinHex {
110    pub fn new() -> Self {
111        Self::default()
112    }
113
114    /// Same bin count on both axes.
115    pub fn bins(mut self, n: usize) -> Self {
116        self.bins_x = n;
117        self.bins_y = n;
118        self
119    }
120
121    pub fn bins_xy(mut self, x: usize, y: usize) -> Self {
122        self.bins_x = x;
123        self.bins_y = y;
124        self
125    }
126
127    /// Fix the bin width/height in data units (overrides `bins`).
128    pub fn binwidth(mut self, w: f64, h: f64) -> Self {
129        self.binwidth = Some((w, h));
130        self
131    }
132
133    /// Fix the lattice origin in data units.
134    pub fn origin(mut self, x: f64, y: f64) -> Self {
135        self.origin = Some((x, y));
136        self
137    }
138
139    /// Build the [`HexLattice`] this stat would use for `(xs, ys)`, following
140    /// the origin rules:
141    /// - `binwidth` and `origin` both given: the lattice is fixed exactly.
142    /// - `binwidth` only: anchored at `(0, 0)`.
143    /// - neither: `w, h` from the range and bin counts, anchored at the data
144    ///   minimum (ggplot2) — or at `origin`, if given without `binwidth`.
145    pub fn lattice(&self, xs: &[f64], ys: &[f64]) -> HexLattice {
146        if let Some((w, h)) = self.binwidth {
147            if w.is_finite() && w > 0.0 && h.is_finite() && h > 0.0 {
148                return HexLattice::new(w, h, self.origin.unwrap_or((0.0, 0.0)));
149            }
150            // NaN, zero or negative: using it as-is would produce a
151            // degenerate (every point in one cell) or non-finite (NaN
152            // centres/vertices) lattice — fall back to the range/bins
153            // lattice instead. `validate()` (called once per layer, with
154            // access to the plot's warnings channel — `compute_group`/
155            // `lattice` have none) reports this; `lattice` itself stays
156            // silent so it's safe to call from a test or a consumer
157            // without a `GGPlot` build around it.
158        }
159        let (x_min, x_max) = minmax(xs);
160        let (y_min, y_max) = minmax(ys);
161        let x_range = if (x_max - x_min).abs() < f64::EPSILON {
162            1.0
163        } else {
164            x_max - x_min
165        };
166        let y_range = if (y_max - y_min).abs() < f64::EPSILON {
167            1.0
168        } else {
169            y_max - y_min
170        };
171        let w = x_range / self.bins_x.max(1) as f64;
172        let h = y_range / self.bins_y.max(1) as f64;
173        let origin = self.origin.unwrap_or((x_min, y_min));
174        HexLattice::new(w, h, origin)
175    }
176
177    /// The cell `(q, r)` nearest `(x, y)` on `lattice`.
178    pub fn locate(x: f64, y: f64, lattice: &HexLattice) -> (i64, i64) {
179        lattice.locate(x, y)
180    }
181}
182
183impl Stat for StatBinHex {
184    fn compute_group(&self, data: &DataFrame, _scales: &ScaleSet) -> DataFrame {
185        let x_col = match data.column("x") {
186            Some(c) => c,
187            None => return DataFrame::new(),
188        };
189        let y_col = match data.column("y") {
190            Some(c) => c,
191            None => return DataFrame::new(),
192        };
193        let weight_col = data.column("weight");
194
195        let n_rows = x_col.len().min(y_col.len());
196        let mut xs = Vec::with_capacity(n_rows);
197        let mut ys = Vec::with_capacity(n_rows);
198        let mut weights = Vec::with_capacity(n_rows);
199        for i in 0..n_rows {
200            let (Some(x), Some(y)) = (x_col[i].as_f64(), y_col[i].as_f64()) else {
201                continue;
202            };
203            if !(x.is_finite() && y.is_finite()) {
204                continue;
205            }
206            xs.push(x);
207            ys.push(y);
208            weights.push(weight_col.and_then(|wc| wc[i].as_f64()));
209        }
210        if xs.is_empty() {
211            return DataFrame::new();
212        }
213
214        let lattice = self.lattice(&xs, &ys);
215
216        // Row-major (r, q) key order: deterministic, and y never decreases
217        // along the output (matches the pre-0.20 convention).
218        let mut cells: std::collections::BTreeMap<(i64, i64), (f64, usize)> =
219            std::collections::BTreeMap::new();
220        for i in 0..xs.len() {
221            let (q, r) = lattice.locate(xs[i], ys[i]);
222            // No `weight` column: every point counts as weight 1 (ggplot2's
223            // default). A `weight` column that evaluates to non-finite for
224            // this row contributes 0 but still creates the cell (D8).
225            let w = if weight_col.is_none() {
226                1.0
227            } else {
228                match weights[i] {
229                    Some(w) if w.is_finite() => w,
230                    _ => 0.0,
231                }
232            };
233            let entry = cells.entry((r, q)).or_insert((0.0, 0));
234            entry.0 += w;
235            entry.1 += 1;
236        }
237
238        let total_count: f64 = cells.values().map(|&(c, _)| c).sum();
239        let max_count = cells.values().map(|&(c, _)| c).fold(0.0, f64::max);
240
241        let mut x_vals = Vec::new();
242        let mut y_vals = Vec::new();
243        let mut count_vals = Vec::new();
244        let mut density_vals = Vec::new();
245        let mut ncount_vals = Vec::new();
246        let mut ndensity_vals = Vec::new();
247        let mut width_vals = Vec::new();
248        let mut height_vals = Vec::new();
249        let mut fill_vals = Vec::new();
250        let mut n_vals = Vec::new();
251        let mut q_vals = Vec::new();
252        let mut r_vals = Vec::new();
253
254        for (&(r, q), &(count, n)) in &cells {
255            let (cx, cy) = lattice.centre(q, r);
256            let density = if total_count > 0.0 {
257                count / total_count
258            } else {
259                0.0
260            };
261            // density is a constant multiple of count (1/total_count), so
262            // its own max-normalized form is identical to ncount.
263            let ncount = if max_count > 0.0 {
264                count / max_count
265            } else {
266                0.0
267            };
268            let ndensity = ncount;
269
270            x_vals.push(Value::Float(cx));
271            y_vals.push(Value::Float(cy));
272            count_vals.push(Value::Float(count));
273            density_vals.push(Value::Float(density));
274            ncount_vals.push(Value::Float(ncount));
275            ndensity_vals.push(Value::Float(ndensity));
276            width_vals.push(Value::Float(lattice.w));
277            height_vals.push(Value::Float(lattice.h));
278            fill_vals.push(Value::Float(count));
279            n_vals.push(Value::Integer(n as i64));
280            q_vals.push(Value::Integer(q));
281            r_vals.push(Value::Integer(r));
282        }
283
284        let mut result = DataFrame::new();
285        result.add_column("x".to_string(), x_vals);
286        result.add_column("y".to_string(), y_vals);
287        result.add_column("count".to_string(), count_vals);
288        result.add_column("density".to_string(), density_vals);
289        result.add_column("ncount".to_string(), ncount_vals);
290        result.add_column("ndensity".to_string(), ndensity_vals);
291        result.add_column("width".to_string(), width_vals);
292        result.add_column("height".to_string(), height_vals);
293        result.add_column("fill".to_string(), fill_vals);
294        result.add_column("n".to_string(), n_vals);
295        result.add_column("q".to_string(), q_vals);
296        result.add_column("r".to_string(), r_vals);
297
298        result
299    }
300
301    fn required_aes(&self) -> Vec<Aesthetic> {
302        vec![Aesthetic::X, Aesthetic::Y]
303    }
304
305    fn name(&self) -> &str {
306        "binhex"
307    }
308
309    fn validate(&self, warnings: &mut Vec<String>) {
310        if let Some((w, h)) = self.binwidth {
311            if !(w.is_finite() && w > 0.0 && h.is_finite() && h > 0.0) {
312                warnings.push(format!(
313                    "stat_binhex: binwidth({w}, {h}) must be finite and positive; \
314                     falling back to bins({}, {})",
315                    self.bins_x, self.bins_y
316                ));
317            }
318        }
319    }
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325    use crate::coord::cartesian::CoordCartesian;
326    use crate::coord::Coord;
327    use crate::render::Rect;
328
329    fn col_f64(df: &DataFrame, name: &str) -> Vec<f64> {
330        df.column(name)
331            .unwrap()
332            .iter()
333            .map(|v| v.as_f64().unwrap())
334            .collect()
335    }
336
337    #[test]
338    fn test_binhex_basic() {
339        let mut data = DataFrame::new();
340        let x_vals: Vec<Value> = (0..100).map(|i| Value::Float(i as f64 / 10.0)).collect();
341        let y_vals: Vec<Value> = (0..100).map(|i| Value::Float(i as f64 / 5.0)).collect();
342        data.add_column("x".to_string(), x_vals);
343        data.add_column("y".to_string(), y_vals);
344
345        let stat = StatBinHex::new().bins_xy(5, 5);
346        let scales = ScaleSet::new();
347        let result = stat.compute_group(&data, &scales);
348
349        assert!(result.nrows() > 0);
350        for col in [
351            "x", "y", "count", "density", "ncount", "ndensity", "width", "height", "fill", "n",
352            "q", "r",
353        ] {
354            assert!(result.column(col).is_some(), "missing column {col}");
355        }
356    }
357
358    #[test]
359    fn bins_builder_sets_both_axes_to_the_same_count() {
360        let via_bins = StatBinHex::new().bins(3);
361        let via_bins_xy = StatBinHex::new().bins_xy(3, 3);
362        assert_eq!(via_bins.bins_x, via_bins_xy.bins_x);
363        assert_eq!(via_bins.bins_y, via_bins_xy.bins_y);
364
365        let mut data = DataFrame::new();
366        data.add_column(
367            "x".to_string(),
368            (0..30).map(|i| Value::Float(i as f64)).collect(),
369        );
370        data.add_column(
371            "y".to_string(),
372            (0..30).map(|i| Value::Float(i as f64)).collect(),
373        );
374        let scales = ScaleSet::new();
375        assert_eq!(
376            via_bins.compute_group(&data, &scales).nrows(),
377            via_bins_xy.compute_group(&data, &scales).nrows()
378        );
379    }
380
381    #[test]
382    fn missing_x_or_y_column_gives_an_empty_result_not_a_panic() {
383        let stat = StatBinHex::new();
384        let scales = ScaleSet::new();
385
386        let mut only_y = DataFrame::new();
387        only_y.add_column("y".to_string(), vec![Value::Float(1.0)]);
388        assert_eq!(stat.compute_group(&only_y, &scales).nrows(), 0);
389
390        let mut only_x = DataFrame::new();
391        only_x.add_column("x".to_string(), vec![Value::Float(1.0)]);
392        assert_eq!(stat.compute_group(&only_x, &scales).nrows(), 0);
393    }
394
395    #[test]
396    fn non_numeric_and_non_finite_rows_are_skipped_not_counted() {
397        let mut data = DataFrame::new();
398        data.add_column(
399            "x".to_string(),
400            vec![
401                Value::Float(0.0),
402                Value::Str("nope".into()),
403                Value::Float(f64::NAN),
404                Value::Float(f64::INFINITY),
405                Value::Float(1.0),
406            ],
407        );
408        data.add_column(
409            "y".to_string(),
410            vec![
411                Value::Float(0.0),
412                Value::Float(0.0),
413                Value::Float(0.0),
414                Value::Float(0.0),
415                Value::Float(1.0),
416            ],
417        );
418        let stat = StatBinHex::new().binwidth(1.0, 1.0).origin(0.0, 0.0);
419        let scales = ScaleSet::new();
420        let result = stat.compute_group(&data, &scales);
421        let total_n: i64 = result
422            .column("n")
423            .unwrap()
424            .iter()
425            .map(|v| v.as_f64().unwrap() as i64)
426            .sum();
427        // Only the 2 finite numeric rows (0.0 and 1.0) count.
428        assert_eq!(total_n, 2);
429    }
430
431    #[test]
432    fn all_rows_filtered_out_gives_an_empty_result() {
433        let mut data = DataFrame::new();
434        data.add_column("x".to_string(), vec![Value::Str("a".into())]);
435        data.add_column("y".to_string(), vec![Value::Float(f64::NAN)]);
436        let stat = StatBinHex::new();
437        let scales = ScaleSet::new();
438        assert_eq!(stat.compute_group(&data, &scales).nrows(), 0);
439    }
440
441    #[test]
442    fn output_order_is_deterministic_and_conserves_the_total() {
443        let mut data = DataFrame::new();
444        let x: Vec<Value> = (0..500)
445            .map(|i| Value::Float(((i * 37) % 101) as f64))
446            .collect();
447        let y: Vec<Value> = (0..500)
448            .map(|i| Value::Float(((i * 53) % 97) as f64))
449            .collect();
450        data.add_column("x".to_string(), x);
451        data.add_column("y".to_string(), y);
452        let stat = StatBinHex::new().bins_xy(12, 12);
453        let scales = ScaleSet::new();
454        let a = stat.compute_group(&data, &scales);
455        for _ in 0..5 {
456            let b = stat.compute_group(&data, &scales);
457            assert_eq!(a.column("x"), b.column("x"));
458            assert_eq!(a.column("count"), b.column("count"));
459            assert_eq!(a.column("q"), b.column("q"));
460            assert_eq!(a.column("r"), b.column("r"));
461        }
462        let ys = col_f64(&a, "y");
463        assert!(ys.windows(2).all(|w| w[0] <= w[1]));
464        let total: f64 = col_f64(&a, "count").iter().sum();
465        assert_eq!(total, 500.0);
466        let n_total: i64 = a
467            .column("n")
468            .unwrap()
469            .iter()
470            .map(|v| v.as_f64().unwrap() as i64)
471            .sum();
472        assert_eq!(n_total, 500);
473    }
474
475    /// 1. Points land in their nearest centre.
476    #[test]
477    fn points_land_in_their_nearest_centre() {
478        let lattice = HexLattice::new(1.0, 1.0, (0.0, 0.0));
479        for q in -3..=3 {
480            for r in -3..=3 {
481                let (cx, cy) = lattice.centre(q, r);
482                assert_eq!(lattice.locate(cx, cy), (q, r));
483            }
484        }
485    }
486
487    /// 2. D7: the cells shared with a superset of the data have identical centres.
488    #[test]
489    fn fixed_binwidth_and_origin_give_identical_centres_on_a_superset() {
490        let mut small = DataFrame::new();
491        small.add_column(
492            "x".to_string(),
493            vec![Value::Float(1.0), Value::Float(2.0), Value::Float(1.5)],
494        );
495        small.add_column(
496            "y".to_string(),
497            vec![Value::Float(1.0), Value::Float(2.0), Value::Float(1.5)],
498        );
499
500        let mut big = DataFrame::new();
501        big.add_column(
502            "x".to_string(),
503            vec![
504                Value::Float(1.0),
505                Value::Float(2.0),
506                Value::Float(1.5),
507                Value::Float(50.0),
508                Value::Float(-50.0),
509            ],
510        );
511        big.add_column(
512            "y".to_string(),
513            vec![
514                Value::Float(1.0),
515                Value::Float(2.0),
516                Value::Float(1.5),
517                Value::Float(50.0),
518                Value::Float(-50.0),
519            ],
520        );
521
522        let stat = StatBinHex::new().binwidth(1.0, 1.0).origin(0.0, 0.0);
523        let scales = ScaleSet::new();
524        let a = stat.compute_group(&small, &scales);
525        let b = stat.compute_group(&big, &scales);
526
527        let centres = |df: &DataFrame| -> std::collections::HashMap<(i64, i64), (f64, f64)> {
528            df.column("q")
529                .unwrap()
530                .iter()
531                .zip(df.column("r").unwrap().iter())
532                .zip(df.column("x").unwrap().iter())
533                .zip(df.column("y").unwrap().iter())
534                .map(|(((q, r), x), y)| {
535                    (
536                        (q.as_f64().unwrap() as i64, r.as_f64().unwrap() as i64),
537                        (x.as_f64().unwrap(), y.as_f64().unwrap()),
538                    )
539                })
540                .collect()
541        };
542        let a_centres = centres(&a);
543        let b_centres = centres(&b);
544        // Every cell `small` produced is also produced by `big` (a superset
545        // of the same data), with an identical centre — the lattice is
546        // fixed by binwidth+origin, not the data extent.
547        for (key, centre) in &a_centres {
548            let b_centre = b_centres
549                .get(key)
550                .unwrap_or_else(|| panic!("{key:?} missing from the superset"));
551            assert_eq!(centre, b_centre);
552        }
553    }
554
555    /// 3. D8: weights `[2, NaN, 3]` give count = 5, n = 3; an all-NaN-weight
556    ///    cell has count = 0.
557    #[test]
558    fn weights_sum_with_non_finite_as_zero_but_still_create_the_cell() {
559        let mut data = DataFrame::new();
560        data.add_column(
561            "x".to_string(),
562            vec![Value::Float(0.0), Value::Float(0.0), Value::Float(0.0)],
563        );
564        data.add_column(
565            "y".to_string(),
566            vec![Value::Float(0.0), Value::Float(0.0), Value::Float(0.0)],
567        );
568        data.add_column(
569            "weight".to_string(),
570            vec![Value::Float(2.0), Value::Float(f64::NAN), Value::Float(3.0)],
571        );
572        let stat = StatBinHex::new().binwidth(1.0, 1.0).origin(0.0, 0.0);
573        let scales = ScaleSet::new();
574        let result = stat.compute_group(&data, &scales);
575        assert_eq!(result.nrows(), 1);
576        assert_eq!(result.column("count").unwrap()[0].as_f64(), Some(5.0));
577        assert_eq!(result.column("n").unwrap()[0].as_f64(), Some(3.0));
578
579        // An all-NaN-weight cell still exists, with count = 0.
580        let mut nan_only = DataFrame::new();
581        nan_only.add_column("x".to_string(), vec![Value::Float(0.0)]);
582        nan_only.add_column("y".to_string(), vec![Value::Float(0.0)]);
583        nan_only.add_column("weight".to_string(), vec![Value::Float(f64::NAN)]);
584        let result2 = stat.compute_group(&nan_only, &scales);
585        assert_eq!(result2.nrows(), 1);
586        assert_eq!(result2.column("count").unwrap()[0].as_f64(), Some(0.0));
587        assert_eq!(result2.column("n").unwrap()[0].as_f64(), Some(1.0));
588    }
589
590    /// 5. Under coord_sf with square data, the hex edges are equal within 0.05 px.
591    #[test]
592    fn hex_edges_are_equal_under_identity_coord() {
593        let lattice = HexLattice::new(1.0, 1.0, (0.0, 0.0));
594        let coord = CoordCartesian::default();
595        let plot_area = Rect {
596            x: 0.0,
597            y: 0.0,
598            width: 1000.0,
599            height: 1000.0,
600        };
601        // Map data-space vertices through an identity-like transform (same
602        // scale factor in x and y) and check the resulting polygon's edges
603        // are equal, as a regular hexagon's should be.
604        let verts_data = lattice.vertices(0, 0);
605        let verts_px: Vec<(f64, f64)> = verts_data
606            .iter()
607            .map(|&(x, y)| coord.transform((x / 100.0 + 0.5, y / 100.0 + 0.5), &plot_area))
608            .collect();
609        let edge_len =
610            |a: (f64, f64), b: (f64, f64)| ((a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)).sqrt();
611        let edges: Vec<f64> = (0..6)
612            .map(|i| edge_len(verts_px[i], verts_px[(i + 1) % 6]))
613            .collect();
614        let first = edges[0];
615        for e in &edges {
616            assert!((e - first).abs() < 0.05, "edges should be equal: {edges:?}");
617        }
618    }
619
620    /// 6. `locate()` agrees with `compute_group` on 500 random points.
621    #[test]
622    fn locate_agrees_with_compute_group() {
623        let mut rng_state: u64 = 42;
624        let mut next = move || {
625            rng_state ^= rng_state << 13;
626            rng_state ^= rng_state >> 7;
627            rng_state ^= rng_state << 17;
628            (rng_state % 10000) as f64 / 100.0 - 50.0
629        };
630        let xs: Vec<f64> = (0..500).map(|_| next()).collect();
631        let ys: Vec<f64> = (0..500).map(|_| next()).collect();
632
633        let mut data = DataFrame::new();
634        data.add_column(
635            "x".to_string(),
636            xs.iter().map(|&v| Value::Float(v)).collect(),
637        );
638        data.add_column(
639            "y".to_string(),
640            ys.iter().map(|&v| Value::Float(v)).collect(),
641        );
642
643        let stat = StatBinHex::new().bins_xy(15, 15);
644        let scales = ScaleSet::new();
645        let lattice = stat.lattice(&xs, &ys);
646        let result = stat.compute_group(&data, &scales);
647
648        let produced_qr: std::collections::HashSet<(i64, i64)> = result
649            .column("q")
650            .unwrap()
651            .iter()
652            .zip(result.column("r").unwrap().iter())
653            .map(|(q, r)| (q.as_f64().unwrap() as i64, r.as_f64().unwrap() as i64))
654            .collect();
655
656        for i in 0..xs.len() {
657            let (q, r) = StatBinHex::locate(xs[i], ys[i], &lattice);
658            assert!(
659                produced_qr.contains(&(q, r)),
660                "locate({}, {}) = ({q}, {r}) not in compute_group's cells",
661                xs[i],
662                ys[i]
663            );
664        }
665    }
666
667    /// Review finding #6 (binwidth validation): NaN, zero or negative
668    /// `binwidth` must fall back to the range/bins lattice (finite,
669    /// non-degenerate cells), not silently produce NaN or a single
670    /// everything-in-one-cell bin.
671    #[test]
672    fn invalid_binwidth_falls_back_to_bins_instead_of_degenerating() {
673        let mut data = DataFrame::new();
674        let x: Vec<Value> = (0..100).map(|i| Value::Float(i as f64 / 10.0)).collect();
675        let y: Vec<Value> = (0..100).map(|i| Value::Float(i as f64 / 5.0)).collect();
676        data.add_column("x".to_string(), x.clone());
677        data.add_column("y".to_string(), y.clone());
678        let scales = ScaleSet::new();
679
680        let xs: Vec<f64> = x.iter().map(|v| v.as_f64().unwrap()).collect();
681        let ys: Vec<f64> = y.iter().map(|v| v.as_f64().unwrap()).collect();
682
683        for (w, h) in [
684            (f64::NAN, f64::NAN),
685            (0.0, 0.0),
686            (-1.0, 2.0),
687            (1.0, f64::INFINITY),
688        ] {
689            let stat = StatBinHex::new().bins_xy(5, 5).binwidth(w, h);
690            let lattice = stat.lattice(&xs, &ys);
691            assert!(lattice.w.is_finite() && lattice.w > 0.0, "w={}", lattice.w);
692            assert!(lattice.h.is_finite() && lattice.h > 0.0, "h={}", lattice.h);
693
694            let result = stat.compute_group(&data, &scales);
695            assert!(
696                result.nrows() > 1,
697                "expected multiple cells, got {}",
698                result.nrows()
699            );
700            for col in ["x", "y", "count"] {
701                for v in result.column(col).unwrap() {
702                    assert!(v.as_f64().unwrap().is_finite());
703                }
704            }
705            let total: f64 = result
706                .column("count")
707                .unwrap()
708                .iter()
709                .map(|v| v.as_f64().unwrap())
710                .sum();
711            assert_eq!(total, 100.0);
712        }
713    }
714
715    /// Review R-4: the invalid-binwidth diagnostic goes through `Stat::validate`
716    /// (called once per layer at build time, routed to `GGPlot`'s own
717    /// warnings channel) instead of `eprintln!`, which is lost on wasm and
718    /// isn't visible to a library caller at all.
719    #[test]
720    fn invalid_binwidth_warns_via_stat_validate() {
721        let stat = StatBinHex::new().binwidth(f64::NAN, 1.0);
722        let mut warnings = Vec::new();
723        Stat::validate(&stat, &mut warnings);
724        assert_eq!(warnings.len(), 1);
725        assert!(warnings[0].contains("binwidth"));
726
727        let valid = StatBinHex::new().binwidth(1.0, 1.0);
728        let mut warnings2 = Vec::new();
729        Stat::validate(&valid, &mut warnings2);
730        assert!(warnings2.is_empty());
731    }
732
733    /// End-to-end: the warning reaches `GGPlot`'s own warnings channel.
734    #[test]
735    fn invalid_binwidth_warning_reaches_the_plot_warnings() {
736        use crate::prelude::*;
737
738        let (_svg, warnings) = GGPlot::new(vec![
739            ("x".into(), vec![Value::Float(0.0), Value::Float(1.0)]),
740            ("y".into(), vec![Value::Float(0.0), Value::Float(1.0)]),
741        ])
742        .aes(Aes::new().x("x").y("y"))
743        .geom_hex()
744        .stat(StatBinHex::new().binwidth(0.0, 0.0))
745        .render_svg_native_with_warnings(300, 300)
746        .unwrap();
747
748        assert!(
749            warnings.iter().any(|w| w.contains("binwidth")),
750            "{warnings:?}"
751        );
752    }
753}