1use crate::aes::Aesthetic;
2use crate::data::{DataFrame, Value};
3use crate::scale::ScaleSet;
4
5use super::Stat;
6
7#[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 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 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 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
86pub 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 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 pub fn binwidth(mut self, w: f64, h: f64) -> Self {
129 self.binwidth = Some((w, h));
130 self
131 }
132
133 pub fn origin(mut self, x: f64, y: f64) -> Self {
135 self.origin = Some((x, y));
136 self
137 }
138
139 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 }
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 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 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 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 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 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
328pub 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 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 #[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 #[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 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 #[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 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 #[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 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 #[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 #[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 #[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 #[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}