Skip to main content

plotkit_core/charts/
hexbin.rs

1//! Hexbin chart builder methods.
2//!
3//! Provides a fluent builder API for configuring [`HexbinArtist`] instances
4//! and the hexagonal binning algorithm. Each builder method returns `&mut Self`,
5//! allowing calls to be chained for concise, readable chart construction.
6
7use crate::artist::HexbinArtist;
8use crate::colormap::Colormap;
9use crate::primitives::Color;
10use std::collections::HashMap;
11
12// ---------------------------------------------------------------------------
13// Builder methods
14// ---------------------------------------------------------------------------
15
16impl HexbinArtist {
17    /// Sets the number of hexagons across the x-axis.
18    pub fn gridsize(&mut self, gridsize: usize) -> &mut Self {
19        self.gridsize = gridsize;
20        self
21    }
22
23    /// Sets the colormap used to map bin counts to colors.
24    pub fn colormap(&mut self, cmap: Colormap) -> &mut Self {
25        self.cmap = cmap;
26        self
27    }
28
29    /// Sets the minimum count threshold; bins with fewer points are hidden.
30    pub fn mincnt(&mut self, mincnt: usize) -> &mut Self {
31        self.mincnt = mincnt;
32        self
33    }
34
35    /// Sets the opacity from 0.0 (fully transparent) to 1.0 (fully opaque).
36    pub fn alpha(&mut self, alpha: f64) -> &mut Self {
37        self.alpha = alpha.clamp(0.0, 1.0);
38        self
39    }
40
41    /// Sets the legend label.
42    pub fn label(&mut self, label: &str) -> &mut Self {
43        self.label = Some(label.to_string());
44        self
45    }
46
47    /// Sets the hex edge (stroke) color.
48    pub fn edgecolor(&mut self, color: Color) -> &mut Self {
49        self.edgecolor = Some(color);
50        self
51    }
52
53    /// Enables or disables auto-attaching a colorbar when this hexbin is drawn.
54    pub fn colorbar(&mut self, show: bool) -> &mut Self {
55        self.show_colorbar = show;
56        self
57    }
58}
59
60// ---------------------------------------------------------------------------
61// Hexagonal binning algorithm
62// ---------------------------------------------------------------------------
63
64/// A hex cell key for the flat-top hexagonal grid.
65///
66/// We use axial coordinates (q, r) to identify each hexagon. Two `i64`
67/// values give us exact, hash-friendly keys without floating-point
68/// comparison issues.
69#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq)]
70pub struct HexCell {
71    /// Axial q-coordinate (column).
72    pub q: i64,
73    /// Axial r-coordinate (row).
74    pub r: i64,
75}
76
77/// Result of binning data points into hexagonal cells.
78#[derive(Debug, Clone)]
79pub struct HexBinResult {
80    /// Center (cx, cy) in data space and point count for each non-empty cell.
81    pub cells: Vec<(f64, f64, usize)>,
82    /// Maximum count across all cells.
83    pub max_count: usize,
84    /// Minimum count across all cells (among those >= mincnt).
85    pub min_count: usize,
86}
87
88/// Computes the 6 vertices of a flat-top regular hexagon centered at
89/// `(cx, cy)` with the given circumradius `size`.
90///
91/// Vertices are returned in counter-clockwise order starting from the
92/// rightmost point (angle = 0).
93pub fn hexagon_vertices(cx: f64, cy: f64, size: f64) -> [(f64, f64); 6] {
94    let mut verts = [(0.0, 0.0); 6];
95    for (i, vert) in verts.iter_mut().enumerate() {
96        let angle = std::f64::consts::PI / 3.0 * i as f64;
97        *vert = (cx + size * angle.cos(), cy + size * angle.sin());
98    }
99    verts
100}
101
102/// Bins `(x, y)` data points into a flat-top hexagonal grid.
103///
104/// The grid is sized so that roughly `gridsize` hexagons span the x-extent
105/// of the data. Only cells with at least `mincnt` points are included in
106/// the result.
107///
108/// # Algorithm
109///
110/// Uses the standard "nearest hex center" approach for flat-top hexagons:
111///   1. Compute hex size (circumradius) from the data extent and `gridsize`.
112///   2. For each point, convert to fractional axial coordinates then round
113///      to the nearest hex cell using cube-coordinate rounding.
114///   3. Accumulate counts in a hash map keyed by axial `(q, r)`.
115///   4. Convert each cell's axial coordinates back to Cartesian centers.
116pub fn bin_hexagonal(x: &[f64], y: &[f64], gridsize: usize, mincnt: usize) -> HexBinResult {
117    if x.is_empty() || y.is_empty() {
118        return HexBinResult {
119            cells: Vec::new(),
120            max_count: 0,
121            min_count: 0,
122        };
123    }
124
125    let (xmin, xmax, ymin, ymax) = data_extent(x, y);
126    let x_range = (xmax - xmin).max(f64::EPSILON);
127    let _y_range = (ymax - ymin).max(f64::EPSILON);
128
129    // For flat-top hexagons:
130    //   width  = 2 * size
131    //   height = sqrt(3) * size
132    // We want `gridsize` hexes across the x range.
133    let size = x_range / (gridsize as f64 * 1.5 + 0.5);
134    let size = size.max(f64::EPSILON);
135
136    let sqrt3 = 3.0_f64.sqrt();
137
138    // Bin each point.
139    let mut counts: HashMap<HexCell, usize> = HashMap::new();
140
141    let n = x.len().min(y.len());
142    for i in 0..n {
143        let px = x[i];
144        let py = y[i];
145        if !px.is_finite() || !py.is_finite() {
146            continue;
147        }
148
149        // Convert Cartesian to fractional axial coordinates (flat-top).
150        let fq = (2.0 / 3.0 * (px - xmin)) / size;
151        let fr = (-(px - xmin) / 3.0 + sqrt3 / 3.0 * (py - ymin)) / size;
152
153        // Round to nearest hex using cube-coordinate rounding.
154        let (q, r) = axial_round(fq, fr);
155
156        let cell = HexCell { q, r };
157        *counts.entry(cell).or_insert(0) += 1;
158    }
159
160    // Filter by mincnt and compute centers.
161    let mut cells = Vec::with_capacity(counts.len());
162    let mut max_count = 0usize;
163    let mut min_count = usize::MAX;
164
165    for (cell, count) in &counts {
166        if *count < mincnt {
167            continue;
168        }
169        // Convert axial back to Cartesian.
170        let cx = xmin + size * (3.0 / 2.0 * cell.q as f64);
171        let cy = ymin + size * (sqrt3 / 2.0 * cell.q as f64 + sqrt3 * cell.r as f64);
172        cells.push((cx, cy, *count));
173        if *count > max_count {
174            max_count = *count;
175        }
176        if *count < min_count {
177            min_count = *count;
178        }
179    }
180
181    if cells.is_empty() {
182        min_count = 0;
183    }
184
185    // Sort cells for deterministic rendering order (bottom-left to top-right).
186    cells.sort_by(|a, b| {
187        a.1.partial_cmp(&b.1)
188            .unwrap_or(std::cmp::Ordering::Equal)
189            .then(a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal))
190    });
191
192    HexBinResult {
193        cells,
194        max_count,
195        min_count,
196    }
197}
198
199/// Returns the hex circumradius for a given gridsize and x-extent.
200///
201/// This is exposed so the rendering pipeline can compute hex size from
202/// the same parameters as the binning algorithm.
203pub fn hex_size_for_gridsize(x_range: f64, gridsize: usize) -> f64 {
204    let x_range = x_range.max(f64::EPSILON);
205    let size = x_range / (gridsize as f64 * 1.5 + 0.5);
206    size.max(f64::EPSILON)
207}
208
209// ---------------------------------------------------------------------------
210// Internal helpers
211// ---------------------------------------------------------------------------
212
213/// Rounds fractional axial coordinates to the nearest hex cell using
214/// cube-coordinate rounding.
215fn axial_round(fq: f64, fr: f64) -> (i64, i64) {
216    // Convert axial (q, r) to cube (x, y, z) where x=q, z=r, y=-x-z.
217    let fx = fq;
218    let fz = fr;
219    let fy = -fx - fz;
220
221    let mut rx = fx.round();
222    let mut ry = fy.round();
223    let mut rz = fz.round();
224
225    let dx = (rx - fx).abs();
226    let dy = (ry - fy).abs();
227    let dz = (rz - fz).abs();
228
229    if dx > dy && dx > dz {
230        rx = -ry - rz;
231    } else if dy > dz {
232        ry = -rx - rz;
233    } else {
234        rz = -rx - ry;
235    }
236
237    let _ = ry; // y is implicit in axial coordinates
238    (rx as i64, rz as i64)
239}
240
241/// Computes (xmin, xmax, ymin, ymax) for finite values in the data.
242fn data_extent(x: &[f64], y: &[f64]) -> (f64, f64, f64, f64) {
243    let mut xmin = f64::INFINITY;
244    let mut xmax = f64::NEG_INFINITY;
245    let mut ymin = f64::INFINITY;
246    let mut ymax = f64::NEG_INFINITY;
247
248    for &v in x {
249        if v.is_finite() {
250            if v < xmin {
251                xmin = v;
252            }
253            if v > xmax {
254                xmax = v;
255            }
256        }
257    }
258    for &v in y {
259        if v.is_finite() {
260            if v < ymin {
261                ymin = v;
262            }
263            if v > ymax {
264                ymax = v;
265            }
266        }
267    }
268
269    if !xmin.is_finite() {
270        xmin = 0.0;
271    }
272    if !xmax.is_finite() {
273        xmax = 1.0;
274    }
275    if !ymin.is_finite() {
276        ymin = 0.0;
277    }
278    if !ymax.is_finite() {
279        ymax = 1.0;
280    }
281    if (xmax - xmin).abs() < f64::EPSILON {
282        xmin -= 0.5;
283        xmax += 0.5;
284    }
285    if (ymax - ymin).abs() < f64::EPSILON {
286        ymin -= 0.5;
287        ymax += 0.5;
288    }
289
290    (xmin, xmax, ymin, ymax)
291}
292
293// ---------------------------------------------------------------------------
294// Tests
295// ---------------------------------------------------------------------------
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300    use crate::artist::HexbinArtist;
301
302    fn sample_hexbin() -> HexbinArtist {
303        HexbinArtist {
304            x: vec![0.0, 1.0, 2.0, 3.0, 4.0],
305            y: vec![0.0, 1.0, 2.0, 3.0, 4.0],
306            gridsize: 10,
307            cmap: Colormap::Viridis,
308            mincnt: 1,
309            alpha: 1.0,
310            color: Color::TAB_BLUE,
311            label: None,
312            edgecolor: None,
313            show_colorbar: false,
314        }
315    }
316
317    // -- Hexagon vertex tests --
318
319    #[test]
320    fn hexagon_vertices_count() {
321        let verts = hexagon_vertices(0.0, 0.0, 1.0);
322        assert_eq!(verts.len(), 6);
323    }
324
325    #[test]
326    fn hexagon_vertices_symmetry() {
327        let verts = hexagon_vertices(0.0, 0.0, 1.0);
328        // First vertex should be at (1, 0) for flat-top hex at origin with size 1.
329        assert!((verts[0].0 - 1.0).abs() < 1e-10);
330        assert!((verts[0].1 - 0.0).abs() < 1e-10);
331        // Opposite vertex (index 3) should be at (-1, 0).
332        assert!((verts[3].0 - (-1.0)).abs() < 1e-10);
333        assert!((verts[3].1 - 0.0).abs() < 1e-10);
334    }
335
336    #[test]
337    fn hexagon_vertices_at_center() {
338        let cx = 5.0;
339        let cy = 3.0;
340        let size = 2.0;
341        let verts = hexagon_vertices(cx, cy, size);
342        // First vertex: (cx + size, cy)
343        assert!((verts[0].0 - (cx + size)).abs() < 1e-10);
344        assert!((verts[0].1 - cy).abs() < 1e-10);
345    }
346
347    #[test]
348    fn hexagon_vertices_equidistant_from_center() {
349        let cx = 1.0;
350        let cy = 2.0;
351        let size = 3.0;
352        let verts = hexagon_vertices(cx, cy, size);
353        for (vx, vy) in &verts {
354            let dist = ((vx - cx).powi(2) + (vy - cy).powi(2)).sqrt();
355            assert!(
356                (dist - size).abs() < 1e-10,
357                "vertex ({vx}, {vy}) distance {dist} should equal size {size}"
358            );
359        }
360    }
361
362    // -- Binning algorithm tests --
363
364    #[test]
365    fn bin_empty_data() {
366        let result = bin_hexagonal(&[], &[], 10, 1);
367        assert!(result.cells.is_empty());
368        assert_eq!(result.max_count, 0);
369    }
370
371    #[test]
372    fn bin_single_point() {
373        let result = bin_hexagonal(&[1.0], &[1.0], 10, 1);
374        assert_eq!(result.cells.len(), 1);
375        assert_eq!(result.cells[0].2, 1); // count = 1
376    }
377
378    #[test]
379    fn bin_identical_points_same_cell() {
380        // All 100 points at the same location should land in one cell.
381        let x = vec![5.0; 100];
382        let y = vec![5.0; 100];
383        let result = bin_hexagonal(&x, &y, 10, 1);
384        assert_eq!(result.cells.len(), 1);
385        assert_eq!(result.cells[0].2, 100);
386        assert_eq!(result.max_count, 100);
387    }
388
389    #[test]
390    fn bin_count_accuracy() {
391        // Place 50 points at (0,0) and 30 points at (100,100).
392        let mut x = vec![0.0; 50];
393        let mut y = vec![0.0; 50];
394        x.extend(vec![100.0; 30]);
395        y.extend(vec![100.0; 30]);
396        let result = bin_hexagonal(&x, &y, 5, 1);
397
398        // Total points across all cells should be 80.
399        let total: usize = result.cells.iter().map(|c| c.2).sum();
400        assert_eq!(total, 80);
401        assert_eq!(result.max_count, 50);
402    }
403
404    #[test]
405    fn bin_mincnt_filtering() {
406        // Place 10 points at (0,0) and 1 point at (100,100) with mincnt=2.
407        let mut x = vec![0.0; 10];
408        let mut y = vec![0.0; 10];
409        x.push(100.0);
410        y.push(100.0);
411        let result = bin_hexagonal(&x, &y, 5, 2);
412
413        // The single-point cell should be filtered out.
414        for cell in &result.cells {
415            assert!(cell.2 >= 2, "all cells should have count >= 2");
416        }
417    }
418
419    #[test]
420    fn bin_gridsize_effect() {
421        // With a larger gridsize, we should get more cells (finer grid).
422        let x: Vec<f64> = (0..100).map(|i| i as f64 * 0.1).collect();
423        let y: Vec<f64> = (0..100).map(|i| i as f64 * 0.1).collect();
424
425        let result_coarse = bin_hexagonal(&x, &y, 5, 1);
426        let result_fine = bin_hexagonal(&x, &y, 20, 1);
427
428        assert!(
429            result_fine.cells.len() >= result_coarse.cells.len(),
430            "finer grid ({}) should produce >= cells than coarse grid ({})",
431            result_fine.cells.len(),
432            result_coarse.cells.len()
433        );
434    }
435
436    #[test]
437    fn bin_nan_points_skipped() {
438        let x = vec![1.0, f64::NAN, 3.0];
439        let y = vec![1.0, 2.0, f64::NAN];
440        let result = bin_hexagonal(&x, &y, 10, 1);
441        // Only (1.0, 1.0) is valid.
442        let total: usize = result.cells.iter().map(|c| c.2).sum();
443        assert_eq!(total, 1);
444    }
445
446    // -- Data bounds tests --
447
448    #[test]
449    fn data_bounds_basic() {
450        let h = HexbinArtist {
451            x: vec![1.0, 2.0, 3.0],
452            y: vec![10.0, 20.0, 30.0],
453            gridsize: 10,
454            cmap: Colormap::Viridis,
455            mincnt: 1,
456            alpha: 1.0,
457            color: Color::TAB_BLUE,
458            label: None,
459            edgecolor: None,
460            show_colorbar: false,
461        };
462        let (xmin, xmax, ymin, ymax) = h.data_bounds();
463        assert!((xmin - 1.0).abs() < f64::EPSILON);
464        assert!((xmax - 3.0).abs() < f64::EPSILON);
465        assert!((ymin - 10.0).abs() < f64::EPSILON);
466        assert!((ymax - 30.0).abs() < f64::EPSILON);
467    }
468
469    #[test]
470    fn data_bounds_empty() {
471        let h = HexbinArtist {
472            x: vec![],
473            y: vec![],
474            gridsize: 10,
475            cmap: Colormap::Viridis,
476            mincnt: 1,
477            alpha: 1.0,
478            color: Color::TAB_BLUE,
479            label: None,
480            edgecolor: None,
481            show_colorbar: false,
482        };
483        assert_eq!(h.data_bounds(), (0.0, 1.0, 0.0, 1.0));
484    }
485
486    // -- Builder method tests --
487
488    #[test]
489    fn builder_gridsize() {
490        let mut h = sample_hexbin();
491        h.gridsize(30);
492        assert_eq!(h.gridsize, 30);
493    }
494
495    #[test]
496    fn builder_colormap() {
497        let mut h = sample_hexbin();
498        h.colormap(Colormap::Plasma);
499        assert_eq!(h.cmap, Colormap::Plasma);
500    }
501
502    #[test]
503    fn builder_mincnt() {
504        let mut h = sample_hexbin();
505        h.mincnt(5);
506        assert_eq!(h.mincnt, 5);
507    }
508
509    #[test]
510    fn builder_alpha() {
511        let mut h = sample_hexbin();
512        h.alpha(0.5);
513        assert!((h.alpha - 0.5).abs() < f64::EPSILON);
514    }
515
516    #[test]
517    fn builder_alpha_clamp() {
518        let mut h = sample_hexbin();
519        h.alpha(2.0);
520        assert!((h.alpha - 1.0).abs() < f64::EPSILON);
521        h.alpha(-0.5);
522        assert!((h.alpha - 0.0).abs() < f64::EPSILON);
523    }
524
525    #[test]
526    fn builder_label() {
527        let mut h = sample_hexbin();
528        h.label("hexbin test");
529        assert_eq!(h.label.as_deref(), Some("hexbin test"));
530    }
531
532    #[test]
533    fn builder_edgecolor() {
534        let mut h = sample_hexbin();
535        h.edgecolor(Color::BLACK);
536        assert_eq!(h.edgecolor, Some(Color::BLACK));
537    }
538
539    #[test]
540    fn builder_chaining() {
541        let mut h = sample_hexbin();
542        h.gridsize(15)
543            .colormap(Colormap::Inferno)
544            .mincnt(3)
545            .alpha(0.7)
546            .label("chained")
547            .edgecolor(Color::WHITE);
548        assert_eq!(h.gridsize, 15);
549        assert_eq!(h.cmap, Colormap::Inferno);
550        assert_eq!(h.mincnt, 3);
551        assert!((h.alpha - 0.7).abs() < f64::EPSILON);
552        assert_eq!(h.label.as_deref(), Some("chained"));
553        assert_eq!(h.edgecolor, Some(Color::WHITE));
554    }
555
556    // -- Axial rounding tests --
557
558    #[test]
559    fn axial_round_at_origin() {
560        let (q, r) = axial_round(0.0, 0.0);
561        assert_eq!(q, 0);
562        assert_eq!(r, 0);
563    }
564
565    #[test]
566    fn axial_round_exact_integer() {
567        let (q, r) = axial_round(3.0, -2.0);
568        assert_eq!(q, 3);
569        assert_eq!(r, -2);
570    }
571
572    #[test]
573    fn axial_round_near_boundary() {
574        // Values very close to 0.5 should round deterministically.
575        let (q1, r1) = axial_round(0.49, 0.0);
576        let (q2, r2) = axial_round(0.51, 0.0);
577        // Both should resolve to valid hex cells.
578        assert!(q1 == 0 || q1 == 1);
579        assert!(q2 == 0 || q2 == 1);
580        let _ = (r1, r2); // suppress unused warnings
581    }
582
583    // -- hex_size_for_gridsize tests --
584
585    #[test]
586    fn hex_size_positive() {
587        let size = hex_size_for_gridsize(10.0, 20);
588        assert!(size > 0.0);
589    }
590
591    #[test]
592    fn hex_size_increases_with_range() {
593        let s1 = hex_size_for_gridsize(5.0, 10);
594        let s2 = hex_size_for_gridsize(10.0, 10);
595        assert!(s2 > s1);
596    }
597
598    #[test]
599    fn hex_size_decreases_with_gridsize() {
600        let s1 = hex_size_for_gridsize(10.0, 5);
601        let s2 = hex_size_for_gridsize(10.0, 20);
602        assert!(s2 < s1);
603    }
604}