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 rounded down to a whole bin, less 1e-6 (ggplot2's
145    ///   `hex_bounds`) — or at `origin`, if given without `binwidth`.
146    pub fn lattice(&self, xs: &[f64], ys: &[f64]) -> HexLattice {
147        if let Some((w, h)) = self.binwidth {
148            if w.is_finite() && w > 0.0 && h.is_finite() && h > 0.0 {
149                return HexLattice::new(w, h, self.origin.unwrap_or((0.0, 0.0)));
150            }
151            // NaN, zero or negative: using it as-is would produce a
152            // degenerate (every point in one cell) or non-finite (NaN
153            // centres/vertices) lattice — fall back to the range/bins
154            // lattice instead. `validate()` (called once per layer, with
155            // access to the plot's warnings channel — `compute_group`/
156            // `lattice` have none) reports this; `lattice` itself stays
157            // silent so it's safe to call from a test or a consumer
158            // without a `GGPlot` build around it.
159        }
160        let (x_min, x_max) = minmax(xs);
161        let (y_min, y_max) = minmax(ys);
162        let x_range = if (x_max - x_min).abs() < f64::EPSILON {
163            1.0
164        } else {
165            x_max - x_min
166        };
167        let y_range = if (y_max - y_min).abs() < f64::EPSILON {
168            1.0
169        } else {
170            y_max - y_min
171        };
172        let w = x_range / self.bins_x.max(1) as f64;
173        let h = y_range / self.bins_y.max(1) as f64;
174        // ggplot2's `hex_bounds`: the lattice starts at the data minimum
175        // rounded down to a whole bin width, less 1e-6.
176        let origin = self.origin.unwrap_or((
177            (x_min / w).floor() * w - 1e-6,
178            (y_min / h).floor() * h - 1e-6,
179        ));
180        HexLattice::new(w, h, origin)
181    }
182
183    /// The cell `(q, r)` nearest `(x, y)` on `lattice`.
184    pub fn locate(x: f64, y: f64, lattice: &HexLattice) -> (i64, i64) {
185        lattice.locate(x, y)
186    }
187}
188
189impl Stat for StatBinHex {
190    fn compute_group(&self, data: &DataFrame, _scales: &ScaleSet) -> DataFrame {
191        let x_col = match data.column("x") {
192            Some(c) => c,
193            None => return DataFrame::new(),
194        };
195        let y_col = match data.column("y") {
196            Some(c) => c,
197            None => return DataFrame::new(),
198        };
199        let weight_col = data.column("weight");
200
201        let n_rows = x_col.len().min(y_col.len());
202        let mut xs = Vec::with_capacity(n_rows);
203        let mut ys = Vec::with_capacity(n_rows);
204        let mut weights = Vec::with_capacity(n_rows);
205        for i in 0..n_rows {
206            let (Some(x), Some(y)) = (x_col[i].as_f64(), y_col[i].as_f64()) else {
207                continue;
208            };
209            if !(x.is_finite() && y.is_finite()) {
210                continue;
211            }
212            xs.push(x);
213            ys.push(y);
214            weights.push(weight_col.and_then(|wc| wc[i].as_f64()));
215        }
216        if xs.is_empty() {
217            return DataFrame::new();
218        }
219
220        let lattice = self.lattice(&xs, &ys);
221
222        // Row-major (r, q) key order: deterministic, and y never decreases
223        // along the output (matches the pre-0.20 convention).
224        let mut cells: std::collections::BTreeMap<(i64, i64), (f64, usize)> =
225            std::collections::BTreeMap::new();
226        for i in 0..xs.len() {
227            let (q, r) = lattice.locate(xs[i], ys[i]);
228            // No `weight` column: every point counts as weight 1 (ggplot2's
229            // default). A `weight` column that evaluates to non-finite for
230            // this row contributes 0 but still creates the cell (D8).
231            let w = if weight_col.is_none() {
232                1.0
233            } else {
234                match weights[i] {
235                    Some(w) if w.is_finite() => w,
236                    _ => 0.0,
237                }
238            };
239            let entry = cells.entry((r, q)).or_insert((0.0, 0));
240            entry.0 += w;
241            entry.1 += 1;
242        }
243
244        let total_count: f64 = cells.values().map(|&(c, _)| c).sum();
245        let max_count = cells.values().map(|&(c, _)| c).fold(0.0, f64::max);
246
247        let mut x_vals = Vec::new();
248        let mut y_vals = Vec::new();
249        let mut count_vals = Vec::new();
250        let mut density_vals = Vec::new();
251        let mut ncount_vals = Vec::new();
252        let mut ndensity_vals = Vec::new();
253        let mut width_vals = Vec::new();
254        let mut height_vals = Vec::new();
255        let mut fill_vals = Vec::new();
256        let mut n_vals = Vec::new();
257        let mut q_vals = Vec::new();
258        let mut r_vals = Vec::new();
259
260        for (&(r, q), &(count, n)) in &cells {
261            let (cx, cy) = lattice.centre(q, r);
262            let density = if total_count > 0.0 {
263                count / total_count
264            } else {
265                0.0
266            };
267            // density is a constant multiple of count (1/total_count), so
268            // its own max-normalized form is identical to ncount.
269            let ncount = if max_count > 0.0 {
270                count / max_count
271            } else {
272                0.0
273            };
274            let ndensity = ncount;
275
276            x_vals.push(Value::Float(cx));
277            y_vals.push(Value::Float(cy));
278            count_vals.push(Value::Float(count));
279            density_vals.push(Value::Float(density));
280            ncount_vals.push(Value::Float(ncount));
281            ndensity_vals.push(Value::Float(ndensity));
282            width_vals.push(Value::Float(lattice.w));
283            height_vals.push(Value::Float(lattice.h));
284            fill_vals.push(Value::Float(count));
285            n_vals.push(Value::Integer(n as i64));
286            q_vals.push(Value::Integer(q));
287            r_vals.push(Value::Integer(r));
288        }
289
290        let mut result = DataFrame::new();
291        result.add_column("x".to_string(), x_vals);
292        result.add_column("y".to_string(), y_vals);
293        result.add_column("count".to_string(), count_vals);
294        result.add_column("density".to_string(), density_vals);
295        result.add_column("ncount".to_string(), ncount_vals);
296        result.add_column("ndensity".to_string(), ndensity_vals);
297        result.add_column("width".to_string(), width_vals);
298        result.add_column("height".to_string(), height_vals);
299        result.add_column("fill".to_string(), fill_vals);
300        result.add_column("n".to_string(), n_vals);
301        result.add_column("q".to_string(), q_vals);
302        result.add_column("r".to_string(), r_vals);
303
304        result
305    }
306
307    fn required_aes(&self) -> Vec<Aesthetic> {
308        vec![Aesthetic::X, Aesthetic::Y]
309    }
310
311    fn name(&self) -> &str {
312        "binhex"
313    }
314
315    fn validate(&self, warnings: &mut Vec<String>) {
316        if let Some((w, h)) = self.binwidth {
317            if !(w.is_finite() && w > 0.0 && h.is_finite() && h > 0.0) {
318                warnings.push(format!(
319                    "stat_binhex: binwidth({w}, {h}) must be finite and positive; \
320                     falling back to bins({}, {})",
321                    self.bins_x, self.bins_y
322                ));
323            }
324        }
325    }
326}
327
328/// Summary of `z` per hexagon (ggplot2's `stat_summary_hex`): bins x/y on
329/// [`StatBinHex`]'s lattice and applies `fun` to the `z` values of each cell,
330/// emitting `fill` (= `value`) for `geom_hex`.
331pub struct StatSummaryHex {
332    pub bins: StatBinHex,
333    pub fun: crate::stat::summary::SummaryFun,
334}
335
336impl StatSummaryHex {
337    pub fn new(fun: crate::stat::summary::SummaryFun) -> Self {
338        StatSummaryHex {
339            bins: StatBinHex::default(),
340            fun,
341        }
342    }
343
344    pub fn with_bins(mut self, bins_x: usize, bins_y: usize) -> Self {
345        self.bins.bins_x = bins_x.max(1);
346        self.bins.bins_y = bins_y.max(1);
347        self
348    }
349}
350
351impl Stat for StatSummaryHex {
352    fn compute_group(&self, data: &DataFrame, _scales: &ScaleSet) -> DataFrame {
353        let (Some(xc), Some(yc), Some(zc)) = (data.column("x"), data.column("y"), data.column("z"))
354        else {
355            return DataFrame::new();
356        };
357        let rows: Vec<(f64, f64, f64)> = (0..data.nrows())
358            .filter_map(|i| Some((xc[i].as_f64()?, yc[i].as_f64()?, zc[i].as_f64()?)))
359            .filter(|(x, y, z)| x.is_finite() && y.is_finite() && z.is_finite())
360            .collect();
361        if rows.is_empty() {
362            return DataFrame::new();
363        }
364        let xs: Vec<f64> = rows.iter().map(|r| r.0).collect();
365        let ys: Vec<f64> = rows.iter().map(|r| r.1).collect();
366        let lattice = self.bins.lattice(&xs, &ys);
367        let mut cells: std::collections::BTreeMap<(i64, i64), Vec<f64>> =
368            std::collections::BTreeMap::new();
369        for &(x, y, z) in &rows {
370            let (q, r) = lattice.locate(x, y);
371            cells.entry((r, q)).or_default().push(z);
372        }
373        let (mut x, mut y, mut value, mut w, mut h) =
374            (Vec::new(), Vec::new(), Vec::new(), Vec::new(), Vec::new());
375        for (&(r, q), zs) in &cells {
376            let (cx, cy) = lattice.centre(q, r);
377            x.push(Value::Float(cx));
378            y.push(Value::Float(cy));
379            value.push(Value::Float(self.fun.apply(zs)));
380            w.push(Value::Float(lattice.w));
381            h.push(Value::Float(lattice.h));
382        }
383        let mut out = DataFrame::new();
384        out.add_column("x".into(), x);
385        out.add_column("y".into(), y);
386        out.add_column("value".into(), value.clone());
387        out.add_column("fill".into(), value);
388        out.add_column("width".into(), w);
389        out.add_column("height".into(), h);
390        out
391    }
392
393    fn required_aes(&self) -> Vec<Aesthetic> {
394        vec![Aesthetic::X, Aesthetic::Y]
395    }
396
397    fn name(&self) -> &str {
398        "summary_hex"
399    }
400}
401
402#[cfg(test)]
403mod tests {
404    use super::*;
405    use crate::coord::cartesian::CoordCartesian;
406    use crate::coord::Coord;
407    use crate::render::Rect;
408
409    fn col_f64(df: &DataFrame, name: &str) -> Vec<f64> {
410        df.column(name)
411            .unwrap()
412            .iter()
413            .map(|v| v.as_f64().unwrap())
414            .collect()
415    }
416
417    #[test]
418    fn test_binhex_basic() {
419        let mut data = DataFrame::new();
420        let x_vals: Vec<Value> = (0..100).map(|i| Value::Float(i as f64 / 10.0)).collect();
421        let y_vals: Vec<Value> = (0..100).map(|i| Value::Float(i as f64 / 5.0)).collect();
422        data.add_column("x".to_string(), x_vals);
423        data.add_column("y".to_string(), y_vals);
424
425        let stat = StatBinHex::new().bins_xy(5, 5);
426        let scales = ScaleSet::new();
427        let result = stat.compute_group(&data, &scales);
428
429        assert!(result.nrows() > 0);
430        for col in [
431            "x", "y", "count", "density", "ncount", "ndensity", "width", "height", "fill", "n",
432            "q", "r",
433        ] {
434            assert!(result.column(col).is_some(), "missing column {col}");
435        }
436    }
437
438    #[test]
439    fn bins_builder_sets_both_axes_to_the_same_count() {
440        let via_bins = StatBinHex::new().bins(3);
441        let via_bins_xy = StatBinHex::new().bins_xy(3, 3);
442        assert_eq!(via_bins.bins_x, via_bins_xy.bins_x);
443        assert_eq!(via_bins.bins_y, via_bins_xy.bins_y);
444
445        let mut data = DataFrame::new();
446        data.add_column(
447            "x".to_string(),
448            (0..30).map(|i| Value::Float(i as f64)).collect(),
449        );
450        data.add_column(
451            "y".to_string(),
452            (0..30).map(|i| Value::Float(i as f64)).collect(),
453        );
454        let scales = ScaleSet::new();
455        assert_eq!(
456            via_bins.compute_group(&data, &scales).nrows(),
457            via_bins_xy.compute_group(&data, &scales).nrows()
458        );
459    }
460
461    #[test]
462    fn missing_x_or_y_column_gives_an_empty_result_not_a_panic() {
463        let stat = StatBinHex::new();
464        let scales = ScaleSet::new();
465
466        let mut only_y = DataFrame::new();
467        only_y.add_column("y".to_string(), vec![Value::Float(1.0)]);
468        assert_eq!(stat.compute_group(&only_y, &scales).nrows(), 0);
469
470        let mut only_x = DataFrame::new();
471        only_x.add_column("x".to_string(), vec![Value::Float(1.0)]);
472        assert_eq!(stat.compute_group(&only_x, &scales).nrows(), 0);
473    }
474
475    #[test]
476    fn non_numeric_and_non_finite_rows_are_skipped_not_counted() {
477        let mut data = DataFrame::new();
478        data.add_column(
479            "x".to_string(),
480            vec![
481                Value::Float(0.0),
482                Value::Str("nope".into()),
483                Value::Float(f64::NAN),
484                Value::Float(f64::INFINITY),
485                Value::Float(1.0),
486            ],
487        );
488        data.add_column(
489            "y".to_string(),
490            vec![
491                Value::Float(0.0),
492                Value::Float(0.0),
493                Value::Float(0.0),
494                Value::Float(0.0),
495                Value::Float(1.0),
496            ],
497        );
498        let stat = StatBinHex::new().binwidth(1.0, 1.0).origin(0.0, 0.0);
499        let scales = ScaleSet::new();
500        let result = stat.compute_group(&data, &scales);
501        let total_n: i64 = result
502            .column("n")
503            .unwrap()
504            .iter()
505            .map(|v| v.as_f64().unwrap() as i64)
506            .sum();
507        // Only the 2 finite numeric rows (0.0 and 1.0) count.
508        assert_eq!(total_n, 2);
509    }
510
511    #[test]
512    fn all_rows_filtered_out_gives_an_empty_result() {
513        let mut data = DataFrame::new();
514        data.add_column("x".to_string(), vec![Value::Str("a".into())]);
515        data.add_column("y".to_string(), vec![Value::Float(f64::NAN)]);
516        let stat = StatBinHex::new();
517        let scales = ScaleSet::new();
518        assert_eq!(stat.compute_group(&data, &scales).nrows(), 0);
519    }
520
521    #[test]
522    fn output_order_is_deterministic_and_conserves_the_total() {
523        let mut data = DataFrame::new();
524        let x: Vec<Value> = (0..500)
525            .map(|i| Value::Float(((i * 37) % 101) as f64))
526            .collect();
527        let y: Vec<Value> = (0..500)
528            .map(|i| Value::Float(((i * 53) % 97) as f64))
529            .collect();
530        data.add_column("x".to_string(), x);
531        data.add_column("y".to_string(), y);
532        let stat = StatBinHex::new().bins_xy(12, 12);
533        let scales = ScaleSet::new();
534        let a = stat.compute_group(&data, &scales);
535        for _ in 0..5 {
536            let b = stat.compute_group(&data, &scales);
537            assert_eq!(a.column("x"), b.column("x"));
538            assert_eq!(a.column("count"), b.column("count"));
539            assert_eq!(a.column("q"), b.column("q"));
540            assert_eq!(a.column("r"), b.column("r"));
541        }
542        let ys = col_f64(&a, "y");
543        assert!(ys.windows(2).all(|w| w[0] <= w[1]));
544        let total: f64 = col_f64(&a, "count").iter().sum();
545        assert_eq!(total, 500.0);
546        let n_total: i64 = a
547            .column("n")
548            .unwrap()
549            .iter()
550            .map(|v| v.as_f64().unwrap() as i64)
551            .sum();
552        assert_eq!(n_total, 500);
553    }
554
555    /// 1. Points land in their nearest centre.
556    #[test]
557    fn points_land_in_their_nearest_centre() {
558        let lattice = HexLattice::new(1.0, 1.0, (0.0, 0.0));
559        for q in -3..=3 {
560            for r in -3..=3 {
561                let (cx, cy) = lattice.centre(q, r);
562                assert_eq!(lattice.locate(cx, cy), (q, r));
563            }
564        }
565    }
566
567    /// 2. D7: the cells shared with a superset of the data have identical centres.
568    #[test]
569    fn fixed_binwidth_and_origin_give_identical_centres_on_a_superset() {
570        let mut small = DataFrame::new();
571        small.add_column(
572            "x".to_string(),
573            vec![Value::Float(1.0), Value::Float(2.0), Value::Float(1.5)],
574        );
575        small.add_column(
576            "y".to_string(),
577            vec![Value::Float(1.0), Value::Float(2.0), Value::Float(1.5)],
578        );
579
580        let mut big = DataFrame::new();
581        big.add_column(
582            "x".to_string(),
583            vec![
584                Value::Float(1.0),
585                Value::Float(2.0),
586                Value::Float(1.5),
587                Value::Float(50.0),
588                Value::Float(-50.0),
589            ],
590        );
591        big.add_column(
592            "y".to_string(),
593            vec![
594                Value::Float(1.0),
595                Value::Float(2.0),
596                Value::Float(1.5),
597                Value::Float(50.0),
598                Value::Float(-50.0),
599            ],
600        );
601
602        let stat = StatBinHex::new().binwidth(1.0, 1.0).origin(0.0, 0.0);
603        let scales = ScaleSet::new();
604        let a = stat.compute_group(&small, &scales);
605        let b = stat.compute_group(&big, &scales);
606
607        let centres = |df: &DataFrame| -> std::collections::HashMap<(i64, i64), (f64, f64)> {
608            df.column("q")
609                .unwrap()
610                .iter()
611                .zip(df.column("r").unwrap().iter())
612                .zip(df.column("x").unwrap().iter())
613                .zip(df.column("y").unwrap().iter())
614                .map(|(((q, r), x), y)| {
615                    (
616                        (q.as_f64().unwrap() as i64, r.as_f64().unwrap() as i64),
617                        (x.as_f64().unwrap(), y.as_f64().unwrap()),
618                    )
619                })
620                .collect()
621        };
622        let a_centres = centres(&a);
623        let b_centres = centres(&b);
624        // Every cell `small` produced is also produced by `big` (a superset
625        // of the same data), with an identical centre — the lattice is
626        // fixed by binwidth+origin, not the data extent.
627        for (key, centre) in &a_centres {
628            let b_centre = b_centres
629                .get(key)
630                .unwrap_or_else(|| panic!("{key:?} missing from the superset"));
631            assert_eq!(centre, b_centre);
632        }
633    }
634
635    /// 3. D8: weights `[2, NaN, 3]` give count = 5, n = 3; an all-NaN-weight
636    ///    cell has count = 0.
637    #[test]
638    fn weights_sum_with_non_finite_as_zero_but_still_create_the_cell() {
639        let mut data = DataFrame::new();
640        data.add_column(
641            "x".to_string(),
642            vec![Value::Float(0.0), Value::Float(0.0), Value::Float(0.0)],
643        );
644        data.add_column(
645            "y".to_string(),
646            vec![Value::Float(0.0), Value::Float(0.0), Value::Float(0.0)],
647        );
648        data.add_column(
649            "weight".to_string(),
650            vec![Value::Float(2.0), Value::Float(f64::NAN), Value::Float(3.0)],
651        );
652        let stat = StatBinHex::new().binwidth(1.0, 1.0).origin(0.0, 0.0);
653        let scales = ScaleSet::new();
654        let result = stat.compute_group(&data, &scales);
655        assert_eq!(result.nrows(), 1);
656        assert_eq!(result.column("count").unwrap()[0].as_f64(), Some(5.0));
657        assert_eq!(result.column("n").unwrap()[0].as_f64(), Some(3.0));
658
659        // An all-NaN-weight cell still exists, with count = 0.
660        let mut nan_only = DataFrame::new();
661        nan_only.add_column("x".to_string(), vec![Value::Float(0.0)]);
662        nan_only.add_column("y".to_string(), vec![Value::Float(0.0)]);
663        nan_only.add_column("weight".to_string(), vec![Value::Float(f64::NAN)]);
664        let result2 = stat.compute_group(&nan_only, &scales);
665        assert_eq!(result2.nrows(), 1);
666        assert_eq!(result2.column("count").unwrap()[0].as_f64(), Some(0.0));
667        assert_eq!(result2.column("n").unwrap()[0].as_f64(), Some(1.0));
668    }
669
670    /// 5. Under coord_sf with square data, the hex edges are equal within 0.05 px.
671    #[test]
672    fn hex_edges_are_equal_under_identity_coord() {
673        let lattice = HexLattice::new(1.0, 1.0, (0.0, 0.0));
674        let coord = CoordCartesian::default();
675        let plot_area = Rect {
676            x: 0.0,
677            y: 0.0,
678            width: 1000.0,
679            height: 1000.0,
680        };
681        // Map data-space vertices through an identity-like transform (same
682        // scale factor in x and y) and check the resulting polygon's edges
683        // are equal, as a regular hexagon's should be.
684        let verts_data = lattice.vertices(0, 0);
685        let verts_px: Vec<(f64, f64)> = verts_data
686            .iter()
687            .map(|&(x, y)| coord.transform((x / 100.0 + 0.5, y / 100.0 + 0.5), &plot_area))
688            .collect();
689        let edge_len =
690            |a: (f64, f64), b: (f64, f64)| ((a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)).sqrt();
691        let edges: Vec<f64> = (0..6)
692            .map(|i| edge_len(verts_px[i], verts_px[(i + 1) % 6]))
693            .collect();
694        let first = edges[0];
695        for e in &edges {
696            assert!((e - first).abs() < 0.05, "edges should be equal: {edges:?}");
697        }
698    }
699
700    /// 6. `locate()` agrees with `compute_group` on 500 random points.
701    #[test]
702    fn locate_agrees_with_compute_group() {
703        let mut rng_state: u64 = 42;
704        let mut next = move || {
705            rng_state ^= rng_state << 13;
706            rng_state ^= rng_state >> 7;
707            rng_state ^= rng_state << 17;
708            (rng_state % 10000) as f64 / 100.0 - 50.0
709        };
710        let xs: Vec<f64> = (0..500).map(|_| next()).collect();
711        let ys: Vec<f64> = (0..500).map(|_| next()).collect();
712
713        let mut data = DataFrame::new();
714        data.add_column(
715            "x".to_string(),
716            xs.iter().map(|&v| Value::Float(v)).collect(),
717        );
718        data.add_column(
719            "y".to_string(),
720            ys.iter().map(|&v| Value::Float(v)).collect(),
721        );
722
723        let stat = StatBinHex::new().bins_xy(15, 15);
724        let scales = ScaleSet::new();
725        let lattice = stat.lattice(&xs, &ys);
726        let result = stat.compute_group(&data, &scales);
727
728        let produced_qr: std::collections::HashSet<(i64, i64)> = result
729            .column("q")
730            .unwrap()
731            .iter()
732            .zip(result.column("r").unwrap().iter())
733            .map(|(q, r)| (q.as_f64().unwrap() as i64, r.as_f64().unwrap() as i64))
734            .collect();
735
736        for i in 0..xs.len() {
737            let (q, r) = StatBinHex::locate(xs[i], ys[i], &lattice);
738            assert!(
739                produced_qr.contains(&(q, r)),
740                "locate({}, {}) = ({q}, {r}) not in compute_group's cells",
741                xs[i],
742                ys[i]
743            );
744        }
745    }
746
747    /// Review finding #6 (binwidth validation): NaN, zero or negative
748    /// `binwidth` must fall back to the range/bins lattice (finite,
749    /// non-degenerate cells), not silently produce NaN or a single
750    /// everything-in-one-cell bin.
751    #[test]
752    fn invalid_binwidth_falls_back_to_bins_instead_of_degenerating() {
753        let mut data = DataFrame::new();
754        let x: Vec<Value> = (0..100).map(|i| Value::Float(i as f64 / 10.0)).collect();
755        let y: Vec<Value> = (0..100).map(|i| Value::Float(i as f64 / 5.0)).collect();
756        data.add_column("x".to_string(), x.clone());
757        data.add_column("y".to_string(), y.clone());
758        let scales = ScaleSet::new();
759
760        let xs: Vec<f64> = x.iter().map(|v| v.as_f64().unwrap()).collect();
761        let ys: Vec<f64> = y.iter().map(|v| v.as_f64().unwrap()).collect();
762
763        for (w, h) in [
764            (f64::NAN, f64::NAN),
765            (0.0, 0.0),
766            (-1.0, 2.0),
767            (1.0, f64::INFINITY),
768        ] {
769            let stat = StatBinHex::new().bins_xy(5, 5).binwidth(w, h);
770            let lattice = stat.lattice(&xs, &ys);
771            assert!(lattice.w.is_finite() && lattice.w > 0.0, "w={}", lattice.w);
772            assert!(lattice.h.is_finite() && lattice.h > 0.0, "h={}", lattice.h);
773
774            let result = stat.compute_group(&data, &scales);
775            assert!(
776                result.nrows() > 1,
777                "expected multiple cells, got {}",
778                result.nrows()
779            );
780            for col in ["x", "y", "count"] {
781                for v in result.column(col).unwrap() {
782                    assert!(v.as_f64().unwrap().is_finite());
783                }
784            }
785            let total: f64 = result
786                .column("count")
787                .unwrap()
788                .iter()
789                .map(|v| v.as_f64().unwrap())
790                .sum();
791            assert_eq!(total, 100.0);
792        }
793    }
794
795    /// Review R-4: the invalid-binwidth diagnostic goes through `Stat::validate`
796    /// (called once per layer at build time, routed to `GGPlot`'s own
797    /// warnings channel) instead of `eprintln!`, which is lost on wasm and
798    /// isn't visible to a library caller at all.
799    #[test]
800    fn invalid_binwidth_warns_via_stat_validate() {
801        let stat = StatBinHex::new().binwidth(f64::NAN, 1.0);
802        let mut warnings = Vec::new();
803        Stat::validate(&stat, &mut warnings);
804        assert_eq!(warnings.len(), 1);
805        assert!(warnings[0].contains("binwidth"));
806
807        let valid = StatBinHex::new().binwidth(1.0, 1.0);
808        let mut warnings2 = Vec::new();
809        Stat::validate(&valid, &mut warnings2);
810        assert!(warnings2.is_empty());
811    }
812
813    /// End-to-end: the warning reaches `GGPlot`'s own warnings channel.
814    #[test]
815    fn invalid_binwidth_warning_reaches_the_plot_warnings() {
816        use crate::prelude::*;
817
818        let (_svg, warnings) = GGPlot::new(vec![
819            ("x".into(), vec![Value::Float(0.0), Value::Float(1.0)]),
820            ("y".into(), vec![Value::Float(0.0), Value::Float(1.0)]),
821        ])
822        .aes(Aes::new().x("x").y("y"))
823        .geom_hex()
824        .stat(StatBinHex::new().binwidth(0.0, 0.0))
825        .render_svg_native_with_warnings(300, 300)
826        .unwrap();
827
828        assert!(
829            warnings.iter().any(|w| w.contains("binwidth")),
830            "{warnings:?}"
831        );
832    }
833}