Skip to main content

plotters_statistical/figures/
correlation_heatmap.rs

1//! Correlation-matrix heatmap figure: labeled grid + colorbar, rendered onto a
2//! drawing area.
3
4use plotters::coord::Shift;
5use plotters::prelude::*;
6use plotters::style::text_anchor::{HPos, VPos};
7
8use super::colorbar::draw_colorbar;
9use super::{contrast_text, text_style};
10use crate::colormap::{GradientColorMap, Normalization};
11use crate::stats::{correlation_matrix, CorrelationMethod, StatsError};
12
13const MISSING_COLOR: RGBColor = RGBColor(235, 235, 235);
14
15/// A correlation heatmap: a square, labeled, color-mapped grid of a correlation
16/// matrix, with an optional colorbar and per-cell value annotations.
17///
18/// Defaults suit correlations: a diverging `RdBu` colormap on a symmetric
19/// `[-1, 1]` normalization (0 → white).
20#[derive(Debug, Clone)]
21pub struct CorrelationHeatmap {
22    matrix: Vec<Vec<f64>>,
23    labels: Vec<String>,
24    colormap: GradientColorMap,
25    norm: Normalization,
26    annotate: bool,
27    precision: usize,
28    show_colorbar: bool,
29    gridlines: bool,
30    title: Option<String>,
31}
32
33impl CorrelationHeatmap {
34    /// Compute the correlation matrix of `columns` (one slice per variable, all
35    /// the same length) and build a heatmap. `labels` names the variables.
36    ///
37    /// # Errors
38    /// * [`StatsError::LengthMismatch`] if `labels` and `columns` differ in count.
39    /// * plus any error from [`correlation_matrix`].
40    pub fn from_columns(
41        columns: &[Vec<f64>],
42        labels: Vec<String>,
43        method: CorrelationMethod,
44    ) -> Result<Self, StatsError> {
45        if labels.len() != columns.len() {
46            return Err(StatsError::LengthMismatch {
47                scores: columns.len(),
48                labels: labels.len(),
49            });
50        }
51        let matrix = correlation_matrix(columns, method)?;
52        Ok(Self::from_matrix(matrix, labels))
53    }
54
55    /// Build directly from a precomputed square matrix and its labels.
56    pub fn from_matrix(matrix: Vec<Vec<f64>>, labels: Vec<String>) -> Self {
57        Self {
58            matrix,
59            labels,
60            colormap: GradientColorMap::rd_bu(),
61            norm: Normalization::Symmetric {
62                center: 0.0,
63                half: 1.0,
64            },
65            annotate: true,
66            precision: 2,
67            show_colorbar: true,
68            gridlines: true,
69            title: None,
70        }
71    }
72
73    /// Set the color map.
74    pub fn colormap(mut self, colormap: GradientColorMap) -> Self {
75        self.colormap = colormap;
76        self
77    }
78
79    /// Set the value→color normalization.
80    pub fn normalization(mut self, norm: Normalization) -> Self {
81        self.norm = norm;
82        self
83    }
84
85    /// Toggle per-cell value labels.
86    pub fn annotate(mut self, on: bool) -> Self {
87        self.annotate = on;
88        self
89    }
90
91    /// Decimal places for cell annotations.
92    pub fn precision(mut self, precision: usize) -> Self {
93        self.precision = precision;
94        self
95    }
96
97    /// Toggle the colorbar.
98    pub fn colorbar(mut self, on: bool) -> Self {
99        self.show_colorbar = on;
100        self
101    }
102
103    /// Toggle white cell gridlines.
104    pub fn gridlines(mut self, on: bool) -> Self {
105        self.gridlines = on;
106        self
107    }
108
109    /// Set a title drawn above the grid.
110    pub fn title(mut self, title: impl Into<String>) -> Self {
111        self.title = Some(title.into());
112        self
113    }
114
115    /// Render the heatmap onto `area`.
116    pub fn draw<DB: DrawingBackend>(
117        &self,
118        area: &DrawingArea<DB, Shift>,
119    ) -> Result<(), Box<dyn std::error::Error>>
120    where
121        DB::ErrorType: 'static,
122    {
123        let n = self.labels.len();
124        if n == 0 || self.matrix.is_empty() {
125            return Ok(());
126        }
127        let (full_w, _) = area.dim_in_pixel();
128        let (main, cbar) = if self.show_colorbar {
129            let (m, c) = area.split_horizontally(full_w as i32 - 84);
130            (m, Some(c))
131        } else {
132            (area.clone(), None)
133        };
134
135        let (w, h) = main.dim_in_pixel();
136        let (w, h) = (w as i32, h as i32);
137        let top = if self.title.is_some() { 34 } else { 12 };
138        let left = 92;
139        let bottom = 66;
140        let gx0 = left;
141        let gy0 = top;
142        let gx1 = (w - 12).max(gx0 + 1);
143        let gy1 = (h - bottom).max(gy0 + 1);
144        let gw = gx1 - gx0;
145        let gh = gy1 - gy0;
146        let ni = n as i32;
147
148        if let Some(t) = &self.title {
149            area.draw(&Text::new(
150                t.clone(),
151                ((gx0 + gx1) / 2, 8),
152                text_style(HPos::Center, VPos::Top, 18, &BLACK),
153            ))?;
154        }
155
156        for i in 0..n {
157            for j in 0..n {
158                let x0 = gx0 + gw * j as i32 / ni;
159                let x1 = gx0 + gw * (j as i32 + 1) / ni;
160                let y0 = gy0 + gh * i as i32 / ni;
161                let y1 = gy0 + gh * (i as i32 + 1) / ni;
162                let v = self.matrix[i][j];
163                let color = if v.is_finite() {
164                    self.colormap.color(self.norm.t(v))
165                } else {
166                    MISSING_COLOR
167                };
168                main.draw(&Rectangle::new([(x0, y0), (x1, y1)], color.filled()))?;
169                if self.gridlines {
170                    main.draw(&Rectangle::new(
171                        [(x0, y0), (x1, y1)],
172                        RGBColor(255, 255, 255).stroke_width(1),
173                    ))?;
174                }
175                if self.annotate && v.is_finite() {
176                    main.draw(&Text::new(
177                        format!("{:.*}", self.precision, v),
178                        ((x0 + x1) / 2, (y0 + y1) / 2),
179                        text_style(HPos::Center, VPos::Center, 12, &contrast_text(color)),
180                    ))?;
181                }
182            }
183        }
184
185        // Row labels (right-aligned to the left of the grid).
186        for (i, label) in self.labels.iter().enumerate() {
187            let yc = gy0 + gh * (2 * i as i32 + 1) / (2 * ni);
188            main.draw(&Text::new(
189                label.clone(),
190                (gx0 - 6, yc),
191                text_style(HPos::Right, VPos::Center, 13, &BLACK),
192            ))?;
193        }
194        // Column labels (below the grid).
195        for (j, label) in self.labels.iter().enumerate() {
196            let xc = gx0 + gw * (2 * j as i32 + 1) / (2 * ni);
197            main.draw(&Text::new(
198                label.clone(),
199                (xc, gy1 + 8),
200                text_style(HPos::Center, VPos::Top, 13, &BLACK),
201            ))?;
202        }
203
204        if let Some(c) = cbar {
205            draw_colorbar(&c, &self.colormap, &self.norm, 5)?;
206        }
207        Ok(())
208    }
209}