Skip to main content

facett_plotters/
heatmap.rs

1//! **Heatmap** — a `rows × cols` matrix painted as a colour grid using a
2//! theme-derived [`Ramp`](crate::ramp::Ramp), with a gradient legend. The
3//! cell-layout math ([`cell_rect`]) is a pure function with tests so the grid
4//! geometry can be verified headlessly.
5
6use egui::{Align2, FontId, Pos2, Rect, Sense, Stroke, Ui, vec2};
7use facett_core::{Facet, FacetCaps, theme};
8
9use crate::boxplot::paint_tooltip;
10use crate::ramp::{Ramp, normalize};
11
12/// A dense matrix in row-major order with optional axis labels.
13pub struct HeatmapView {
14    pub title: String,
15    pub rows: usize,
16    pub cols: usize,
17    /// Row-major: `data[r * cols + c]`.
18    pub data: Vec<f64>,
19    pub row_labels: Vec<String>,
20    pub col_labels: Vec<String>,
21    /// Use the diverging ramp (around a midpoint) instead of sequential.
22    pub diverging: bool,
23}
24
25/// The pixel rect of cell `(row, col)` inside `grid`, given `rows × cols`.
26/// Pure geometry — no painter needed, so it's directly testable.
27pub fn cell_rect(grid: Rect, rows: usize, cols: usize, row: usize, col: usize) -> Rect {
28    let cw = grid.width() / cols.max(1) as f32;
29    let ch = grid.height() / rows.max(1) as f32;
30    let x = grid.left() + cw * col as f32;
31    let y = grid.top() + ch * row as f32;
32    Rect::from_min_size(Pos2::new(x, y), vec2(cw, ch))
33}
34
35impl HeatmapView {
36    pub fn new(title: impl Into<String>, rows: usize, cols: usize, data: Vec<f64>) -> Self {
37        Self {
38            title: title.into(),
39            rows,
40            cols,
41            data,
42            row_labels: Vec::new(),
43            col_labels: Vec::new(),
44            diverging: false,
45        }
46    }
47
48    pub fn with_labels(mut self, rows: Vec<String>, cols: Vec<String>) -> Self {
49        self.row_labels = rows;
50        self.col_labels = cols;
51        self
52    }
53
54    pub fn diverging(mut self) -> Self {
55        self.diverging = true;
56        self
57    }
58
59    /// Value at `(row, col)`, or `None` if out of bounds / ragged data.
60    pub fn get(&self, row: usize, col: usize) -> Option<f64> {
61        if row >= self.rows || col >= self.cols {
62            return None;
63        }
64        self.data.get(row * self.cols + col).copied()
65    }
66
67    /// `(min, max)` over the finite cells.
68    pub fn value_range(&self) -> Option<(f64, f64)> {
69        let mut r: Option<(f64, f64)> = None;
70        for &x in &self.data {
71            if x.is_finite() {
72                r = Some(match r {
73                    None => (x, x),
74                    Some((lo, hi)) => (lo.min(x), hi.max(x)),
75                });
76            }
77        }
78        r
79    }
80
81    fn to_csv(&self) -> String {
82        let mut s = String::new();
83        if !self.col_labels.is_empty() {
84            s.push_str(&format!(",{}\n", self.col_labels.join(",")));
85        }
86        for r in 0..self.rows {
87            if let Some(lbl) = self.row_labels.get(r) {
88                s.push_str(lbl);
89            } else {
90                s.push_str(&format!("r{r}"));
91            }
92            for c in 0..self.cols {
93                s.push(',');
94                s.push_str(&format!("{}", self.get(r, c).unwrap_or(f64::NAN)));
95            }
96            s.push('\n');
97        }
98        s
99    }
100}
101
102impl Facet for HeatmapView {
103    fn title(&self) -> &str {
104        &self.title
105    }
106    fn caps(&self) -> FacetCaps {
107        FacetCaps::NONE.themeable().copyable().resizable()
108    }
109    fn copy(&mut self) -> Option<String> {
110        Some(self.to_csv())
111    }
112    fn ui(&mut self, ui: &mut Ui) {
113        let th = theme(ui);
114        let ramp = if self.diverging { Ramp::diverging(&th) } else { Ramp::sequential(&th) };
115        let (rect, resp) = ui.allocate_exact_size(ui.available_size(), Sense::hover());
116        let painter = ui.painter_at(rect);
117        painter.rect_filled(rect, egui::CornerRadius::ZERO, th.bg);
118        let Some((vmin, vmax)) = self.value_range() else {
119            painter.text(rect.center(), Align2::CENTER_CENTER, "no data", FontId::proportional(13.0), th.text_dim);
120            return;
121        };
122        // Reserve a strip on the right for the legend.
123        let legend_w = 56.0;
124        let grid = rect.shrink2(vec2(8.0, 8.0));
125        let grid = Rect::from_min_max(grid.min, Pos2::new(grid.right() - legend_w, grid.bottom()));
126        let hover = resp.hover_pos();
127        let mut tip: Option<(Pos2, String)> = None;
128
129        for r in 0..self.rows {
130            for c in 0..self.cols {
131                let Some(v) = self.get(r, c) else { continue };
132                let cr = cell_rect(grid, self.rows, self.cols, r, c);
133                let color = ramp.at(normalize(v, vmin, vmax));
134                painter.rect_filled(cr.shrink(0.5), egui::CornerRadius::ZERO, color);
135                if let Some(hp) = hover {
136                    if cr.contains(hp) {
137                        let rl = self.row_labels.get(r).cloned().unwrap_or_else(|| format!("r{r}"));
138                        let cl = self.col_labels.get(c).cloned().unwrap_or_else(|| format!("c{c}"));
139                        tip = Some((hp, format!("{rl} × {cl}\n{v:.4}")));
140                    }
141                }
142            }
143        }
144
145        // Legend gradient (top = max, bottom = min).
146        let lx = rect.right() - legend_w + 14.0;
147        let lrect = Rect::from_min_max(Pos2::new(lx, grid.top()), Pos2::new(lx + 16.0, grid.bottom()));
148        let steps = 48;
149        for i in 0..steps {
150            let t = i as f32 / (steps - 1) as f32;
151            let y0 = lrect.bottom() - lrect.height() * (i as f32 / steps as f32);
152            let y1 = lrect.bottom() - lrect.height() * ((i + 1) as f32 / steps as f32);
153            painter.rect_filled(
154                Rect::from_min_max(Pos2::new(lrect.left(), y1), Pos2::new(lrect.right(), y0)),
155                egui::CornerRadius::ZERO,
156                ramp.at(t),
157            );
158        }
159        painter.rect_stroke(lrect, egui::CornerRadius::ZERO, Stroke::new(1.0, th.panel_stroke), egui::StrokeKind::Outside);
160        painter.text(Pos2::new(lrect.right() + 4.0, lrect.top()), Align2::LEFT_CENTER, format!("{vmax:.2}"), FontId::proportional(10.0), th.text_dim);
161        painter.text(Pos2::new(lrect.right() + 4.0, lrect.bottom()), Align2::LEFT_CENTER, format!("{vmin:.2}"), FontId::proportional(10.0), th.text_dim);
162
163        if let Some((at, text)) = tip {
164            paint_tooltip(&painter, &th, at, &text);
165        }
166    }
167    fn state_json(&self) -> serde_json::Value {
168        let (mn, mx) = self.value_range().unwrap_or((0.0, 0.0));
169        serde_json::json!({
170            "kind": "heatmap",
171            "rows": self.rows, "cols": self.cols,
172            "cells": self.data.len(),
173            "min": mn, "max": mx,
174            "diverging": self.diverging,
175        })
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    fn grid() -> Rect {
184        Rect::from_min_size(Pos2::new(0.0, 0.0), vec2(100.0, 80.0))
185    }
186
187    #[test]
188    fn cell_rects_tile_without_gaps() {
189        let g = grid();
190        let (rows, cols) = (4, 5);
191        // first cell starts at origin
192        let c00 = cell_rect(g, rows, cols, 0, 0);
193        assert_eq!(c00.min, Pos2::new(0.0, 0.0));
194        assert!((c00.width() - 20.0).abs() < 1e-4);
195        assert!((c00.height() - 20.0).abs() < 1e-4);
196        // adjacent column starts where the previous ends
197        let c01 = cell_rect(g, rows, cols, 0, 1);
198        assert!((c01.left() - c00.right()).abs() < 1e-4);
199        // last cell ends at the grid's far corner
200        let last = cell_rect(g, rows, cols, rows - 1, cols - 1);
201        assert!((last.right() - g.right()).abs() < 1e-4);
202        assert!((last.bottom() - g.bottom()).abs() < 1e-4);
203    }
204
205    #[test]
206    fn get_bounds_and_row_major() {
207        let h = HeatmapView::new("t", 2, 3, vec![0.0, 1.0, 2.0, 3.0, 4.0, 5.0]);
208        assert_eq!(h.get(0, 0), Some(0.0));
209        assert_eq!(h.get(0, 2), Some(2.0));
210        assert_eq!(h.get(1, 0), Some(3.0));
211        assert_eq!(h.get(1, 2), Some(5.0));
212        assert_eq!(h.get(2, 0), None); // row OOB
213        assert_eq!(h.get(0, 3), None); // col OOB
214    }
215
216    #[test]
217    fn value_range_ignores_nan() {
218        let h = HeatmapView::new("t", 1, 3, vec![1.0, f64::NAN, 9.0]);
219        assert_eq!(h.value_range(), Some((1.0, 9.0)));
220    }
221
222    #[test]
223    fn csv_round_trips_shape() {
224        let h = HeatmapView::new("t", 2, 2, vec![1.0, 2.0, 3.0, 4.0])
225            .with_labels(vec!["a".into(), "b".into()], vec!["x".into(), "y".into()]);
226        let csv = h.to_csv();
227        assert!(csv.contains(",x,y\n"));
228        assert!(csv.contains("a,1,2\n"));
229        assert!(csv.contains("b,3,4\n"));
230    }
231}