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(
117    x: &[f64],
118    y: &[f64],
119    gridsize: usize,
120    mincnt: usize,
121) -> HexBinResult {
122    if x.is_empty() || y.is_empty() {
123        return HexBinResult {
124            cells: Vec::new(),
125            max_count: 0,
126            min_count: 0,
127        };
128    }
129
130    let (xmin, xmax, ymin, ymax) = data_extent(x, y);
131    let x_range = (xmax - xmin).max(f64::EPSILON);
132    let _y_range = (ymax - ymin).max(f64::EPSILON);
133
134    // For flat-top hexagons:
135    //   width  = 2 * size
136    //   height = sqrt(3) * size
137    // We want `gridsize` hexes across the x range.
138    let size = x_range / (gridsize as f64 * 1.5 + 0.5);
139    let size = size.max(f64::EPSILON);
140
141    let sqrt3 = 3.0_f64.sqrt();
142
143    // Bin each point.
144    let mut counts: HashMap<HexCell, usize> = HashMap::new();
145
146    let n = x.len().min(y.len());
147    for i in 0..n {
148        let px = x[i];
149        let py = y[i];
150        if !px.is_finite() || !py.is_finite() {
151            continue;
152        }
153
154        // Convert Cartesian to fractional axial coordinates (flat-top).
155        let fq = (2.0 / 3.0 * (px - xmin)) / size;
156        let fr = (-(px - xmin) / 3.0 + sqrt3 / 3.0 * (py - ymin)) / size;
157
158        // Round to nearest hex using cube-coordinate rounding.
159        let (q, r) = axial_round(fq, fr);
160
161        let cell = HexCell { q, r };
162        *counts.entry(cell).or_insert(0) += 1;
163    }
164
165    // Filter by mincnt and compute centers.
166    let mut cells = Vec::with_capacity(counts.len());
167    let mut max_count = 0usize;
168    let mut min_count = usize::MAX;
169
170    for (cell, count) in &counts {
171        if *count < mincnt {
172            continue;
173        }
174        // Convert axial back to Cartesian.
175        let cx = xmin + size * (3.0 / 2.0 * cell.q as f64);
176        let cy = ymin + size * (sqrt3 / 2.0 * cell.q as f64 + sqrt3 * cell.r as f64);
177        cells.push((cx, cy, *count));
178        if *count > max_count {
179            max_count = *count;
180        }
181        if *count < min_count {
182            min_count = *count;
183        }
184    }
185
186    if cells.is_empty() {
187        min_count = 0;
188    }
189
190    // Sort cells for deterministic rendering order (bottom-left to top-right).
191    cells.sort_by(|a, b| {
192        a.1.partial_cmp(&b.1)
193            .unwrap_or(std::cmp::Ordering::Equal)
194            .then(a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal))
195    });
196
197    HexBinResult {
198        cells,
199        max_count,
200        min_count,
201    }
202}
203
204/// Returns the hex circumradius for a given gridsize and x-extent.
205///
206/// This is exposed so the rendering pipeline can compute hex size from
207/// the same parameters as the binning algorithm.
208pub fn hex_size_for_gridsize(x_range: f64, gridsize: usize) -> f64 {
209    let x_range = x_range.max(f64::EPSILON);
210    let size = x_range / (gridsize as f64 * 1.5 + 0.5);
211    size.max(f64::EPSILON)
212}
213
214// ---------------------------------------------------------------------------
215// Internal helpers
216// ---------------------------------------------------------------------------
217
218/// Rounds fractional axial coordinates to the nearest hex cell using
219/// cube-coordinate rounding.
220fn axial_round(fq: f64, fr: f64) -> (i64, i64) {
221    // Convert axial (q, r) to cube (x, y, z) where x=q, z=r, y=-x-z.
222    let fx = fq;
223    let fz = fr;
224    let fy = -fx - fz;
225
226    let mut rx = fx.round();
227    let mut ry = fy.round();
228    let mut rz = fz.round();
229
230    let dx = (rx - fx).abs();
231    let dy = (ry - fy).abs();
232    let dz = (rz - fz).abs();
233
234    if dx > dy && dx > dz {
235        rx = -ry - rz;
236    } else if dy > dz {
237        ry = -rx - rz;
238    } else {
239        rz = -rx - ry;
240    }
241
242    let _ = ry; // y is implicit in axial coordinates
243    (rx as i64, rz as i64)
244}
245
246/// Computes (xmin, xmax, ymin, ymax) for finite values in the data.
247fn data_extent(x: &[f64], y: &[f64]) -> (f64, f64, f64, f64) {
248    let mut xmin = f64::INFINITY;
249    let mut xmax = f64::NEG_INFINITY;
250    let mut ymin = f64::INFINITY;
251    let mut ymax = f64::NEG_INFINITY;
252
253    for &v in x {
254        if v.is_finite() {
255            if v < xmin { xmin = v; }
256            if v > xmax { xmax = v; }
257        }
258    }
259    for &v in y {
260        if v.is_finite() {
261            if v < ymin { ymin = v; }
262            if v > ymax { ymax = v; }
263        }
264    }
265
266    if !xmin.is_finite() { xmin = 0.0; }
267    if !xmax.is_finite() { xmax = 1.0; }
268    if !ymin.is_finite() { ymin = 0.0; }
269    if !ymax.is_finite() { ymax = 1.0; }
270    if (xmax - xmin).abs() < f64::EPSILON {
271        xmin -= 0.5;
272        xmax += 0.5;
273    }
274    if (ymax - ymin).abs() < f64::EPSILON {
275        ymin -= 0.5;
276        ymax += 0.5;
277    }
278
279    (xmin, xmax, ymin, ymax)
280}
281
282// ---------------------------------------------------------------------------
283// Tests
284// ---------------------------------------------------------------------------
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289    use crate::artist::HexbinArtist;
290
291    fn sample_hexbin() -> HexbinArtist {
292        HexbinArtist {
293            x: vec![0.0, 1.0, 2.0, 3.0, 4.0],
294            y: vec![0.0, 1.0, 2.0, 3.0, 4.0],
295            gridsize: 10,
296            cmap: Colormap::Viridis,
297            mincnt: 1,
298            alpha: 1.0,
299            color: Color::TAB_BLUE,
300            label: None,
301            edgecolor: None,
302            show_colorbar: false,
303        }
304    }
305
306    // -- Hexagon vertex tests --
307
308    #[test]
309    fn hexagon_vertices_count() {
310        let verts = hexagon_vertices(0.0, 0.0, 1.0);
311        assert_eq!(verts.len(), 6);
312    }
313
314    #[test]
315    fn hexagon_vertices_symmetry() {
316        let verts = hexagon_vertices(0.0, 0.0, 1.0);
317        // First vertex should be at (1, 0) for flat-top hex at origin with size 1.
318        assert!((verts[0].0 - 1.0).abs() < 1e-10);
319        assert!((verts[0].1 - 0.0).abs() < 1e-10);
320        // Opposite vertex (index 3) should be at (-1, 0).
321        assert!((verts[3].0 - (-1.0)).abs() < 1e-10);
322        assert!((verts[3].1 - 0.0).abs() < 1e-10);
323    }
324
325    #[test]
326    fn hexagon_vertices_at_center() {
327        let cx = 5.0;
328        let cy = 3.0;
329        let size = 2.0;
330        let verts = hexagon_vertices(cx, cy, size);
331        // First vertex: (cx + size, cy)
332        assert!((verts[0].0 - (cx + size)).abs() < 1e-10);
333        assert!((verts[0].1 - cy).abs() < 1e-10);
334    }
335
336    #[test]
337    fn hexagon_vertices_equidistant_from_center() {
338        let cx = 1.0;
339        let cy = 2.0;
340        let size = 3.0;
341        let verts = hexagon_vertices(cx, cy, size);
342        for (vx, vy) in &verts {
343            let dist = ((vx - cx).powi(2) + (vy - cy).powi(2)).sqrt();
344            assert!(
345                (dist - size).abs() < 1e-10,
346                "vertex ({vx}, {vy}) distance {dist} should equal size {size}"
347            );
348        }
349    }
350
351    // -- Binning algorithm tests --
352
353    #[test]
354    fn bin_empty_data() {
355        let result = bin_hexagonal(&[], &[], 10, 1);
356        assert!(result.cells.is_empty());
357        assert_eq!(result.max_count, 0);
358    }
359
360    #[test]
361    fn bin_single_point() {
362        let result = bin_hexagonal(&[1.0], &[1.0], 10, 1);
363        assert_eq!(result.cells.len(), 1);
364        assert_eq!(result.cells[0].2, 1); // count = 1
365    }
366
367    #[test]
368    fn bin_identical_points_same_cell() {
369        // All 100 points at the same location should land in one cell.
370        let x = vec![5.0; 100];
371        let y = vec![5.0; 100];
372        let result = bin_hexagonal(&x, &y, 10, 1);
373        assert_eq!(result.cells.len(), 1);
374        assert_eq!(result.cells[0].2, 100);
375        assert_eq!(result.max_count, 100);
376    }
377
378    #[test]
379    fn bin_count_accuracy() {
380        // Place 50 points at (0,0) and 30 points at (100,100).
381        let mut x = vec![0.0; 50];
382        let mut y = vec![0.0; 50];
383        x.extend(vec![100.0; 30]);
384        y.extend(vec![100.0; 30]);
385        let result = bin_hexagonal(&x, &y, 5, 1);
386
387        // Total points across all cells should be 80.
388        let total: usize = result.cells.iter().map(|c| c.2).sum();
389        assert_eq!(total, 80);
390        assert_eq!(result.max_count, 50);
391    }
392
393    #[test]
394    fn bin_mincnt_filtering() {
395        // Place 10 points at (0,0) and 1 point at (100,100) with mincnt=2.
396        let mut x = vec![0.0; 10];
397        let mut y = vec![0.0; 10];
398        x.push(100.0);
399        y.push(100.0);
400        let result = bin_hexagonal(&x, &y, 5, 2);
401
402        // The single-point cell should be filtered out.
403        for cell in &result.cells {
404            assert!(cell.2 >= 2, "all cells should have count >= 2");
405        }
406    }
407
408    #[test]
409    fn bin_gridsize_effect() {
410        // With a larger gridsize, we should get more cells (finer grid).
411        let x: Vec<f64> = (0..100).map(|i| i as f64 * 0.1).collect();
412        let y: Vec<f64> = (0..100).map(|i| i as f64 * 0.1).collect();
413
414        let result_coarse = bin_hexagonal(&x, &y, 5, 1);
415        let result_fine = bin_hexagonal(&x, &y, 20, 1);
416
417        assert!(
418            result_fine.cells.len() >= result_coarse.cells.len(),
419            "finer grid ({}) should produce >= cells than coarse grid ({})",
420            result_fine.cells.len(),
421            result_coarse.cells.len()
422        );
423    }
424
425    #[test]
426    fn bin_nan_points_skipped() {
427        let x = vec![1.0, f64::NAN, 3.0];
428        let y = vec![1.0, 2.0, f64::NAN];
429        let result = bin_hexagonal(&x, &y, 10, 1);
430        // Only (1.0, 1.0) is valid.
431        let total: usize = result.cells.iter().map(|c| c.2).sum();
432        assert_eq!(total, 1);
433    }
434
435    // -- Data bounds tests --
436
437    #[test]
438    fn data_bounds_basic() {
439        let h = HexbinArtist {
440            x: vec![1.0, 2.0, 3.0],
441            y: vec![10.0, 20.0, 30.0],
442            gridsize: 10,
443            cmap: Colormap::Viridis,
444            mincnt: 1,
445            alpha: 1.0,
446            color: Color::TAB_BLUE,
447            label: None,
448            edgecolor: None,
449            show_colorbar: false,
450        };
451        let (xmin, xmax, ymin, ymax) = h.data_bounds();
452        assert!((xmin - 1.0).abs() < f64::EPSILON);
453        assert!((xmax - 3.0).abs() < f64::EPSILON);
454        assert!((ymin - 10.0).abs() < f64::EPSILON);
455        assert!((ymax - 30.0).abs() < f64::EPSILON);
456    }
457
458    #[test]
459    fn data_bounds_empty() {
460        let h = HexbinArtist {
461            x: vec![],
462            y: vec![],
463            gridsize: 10,
464            cmap: Colormap::Viridis,
465            mincnt: 1,
466            alpha: 1.0,
467            color: Color::TAB_BLUE,
468            label: None,
469            edgecolor: None,
470            show_colorbar: false,
471        };
472        assert_eq!(h.data_bounds(), (0.0, 1.0, 0.0, 1.0));
473    }
474
475    // -- Builder method tests --
476
477    #[test]
478    fn builder_gridsize() {
479        let mut h = sample_hexbin();
480        h.gridsize(30);
481        assert_eq!(h.gridsize, 30);
482    }
483
484    #[test]
485    fn builder_colormap() {
486        let mut h = sample_hexbin();
487        h.colormap(Colormap::Plasma);
488        assert_eq!(h.cmap, Colormap::Plasma);
489    }
490
491    #[test]
492    fn builder_mincnt() {
493        let mut h = sample_hexbin();
494        h.mincnt(5);
495        assert_eq!(h.mincnt, 5);
496    }
497
498    #[test]
499    fn builder_alpha() {
500        let mut h = sample_hexbin();
501        h.alpha(0.5);
502        assert!((h.alpha - 0.5).abs() < f64::EPSILON);
503    }
504
505    #[test]
506    fn builder_alpha_clamp() {
507        let mut h = sample_hexbin();
508        h.alpha(2.0);
509        assert!((h.alpha - 1.0).abs() < f64::EPSILON);
510        h.alpha(-0.5);
511        assert!((h.alpha - 0.0).abs() < f64::EPSILON);
512    }
513
514    #[test]
515    fn builder_label() {
516        let mut h = sample_hexbin();
517        h.label("hexbin test");
518        assert_eq!(h.label.as_deref(), Some("hexbin test"));
519    }
520
521    #[test]
522    fn builder_edgecolor() {
523        let mut h = sample_hexbin();
524        h.edgecolor(Color::BLACK);
525        assert_eq!(h.edgecolor, Some(Color::BLACK));
526    }
527
528    #[test]
529    fn builder_chaining() {
530        let mut h = sample_hexbin();
531        h.gridsize(15)
532            .colormap(Colormap::Inferno)
533            .mincnt(3)
534            .alpha(0.7)
535            .label("chained")
536            .edgecolor(Color::WHITE);
537        assert_eq!(h.gridsize, 15);
538        assert_eq!(h.cmap, Colormap::Inferno);
539        assert_eq!(h.mincnt, 3);
540        assert!((h.alpha - 0.7).abs() < f64::EPSILON);
541        assert_eq!(h.label.as_deref(), Some("chained"));
542        assert_eq!(h.edgecolor, Some(Color::WHITE));
543    }
544
545    // -- Axial rounding tests --
546
547    #[test]
548    fn axial_round_at_origin() {
549        let (q, r) = axial_round(0.0, 0.0);
550        assert_eq!(q, 0);
551        assert_eq!(r, 0);
552    }
553
554    #[test]
555    fn axial_round_exact_integer() {
556        let (q, r) = axial_round(3.0, -2.0);
557        assert_eq!(q, 3);
558        assert_eq!(r, -2);
559    }
560
561    #[test]
562    fn axial_round_near_boundary() {
563        // Values very close to 0.5 should round deterministically.
564        let (q1, r1) = axial_round(0.49, 0.0);
565        let (q2, r2) = axial_round(0.51, 0.0);
566        // Both should resolve to valid hex cells.
567        assert!(q1 == 0 || q1 == 1);
568        assert!(q2 == 0 || q2 == 1);
569        let _ = (r1, r2); // suppress unused warnings
570    }
571
572    // -- hex_size_for_gridsize tests --
573
574    #[test]
575    fn hex_size_positive() {
576        let size = hex_size_for_gridsize(10.0, 20);
577        assert!(size > 0.0);
578    }
579
580    #[test]
581    fn hex_size_increases_with_range() {
582        let s1 = hex_size_for_gridsize(5.0, 10);
583        let s2 = hex_size_for_gridsize(10.0, 10);
584        assert!(s2 > s1);
585    }
586
587    #[test]
588    fn hex_size_decreases_with_gridsize() {
589        let s1 = hex_size_for_gridsize(10.0, 5);
590        let s2 = hex_size_for_gridsize(10.0, 20);
591        assert!(s2 < s1);
592    }
593}