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 {
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 }
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 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 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 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 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 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 #[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 #[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 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 #[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 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 #[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 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 #[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 #[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 #[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 #[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}