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::clip::{ClipKind, ClipPayload, CopySource};
8use facett_core::{Elm, FacetCaps, Semantics, a11y_node, theme};
9use serde::{Deserialize, Serialize};
10
11use crate::boxplot::paint_tooltip;
12use crate::ramp::{Ramp, normalize};
13
14/// Persistent, observable interaction state (FC-3): the `(row, col)` cell the
15/// pointer last hovered. Serialized so it round-trips headlessly.
16#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
17pub struct HeatmapState {
18    /// The hovered cell coordinate `(row, col)`, or `None`.
19    pub hover: Option<(usize, usize)>,
20}
21
22/// FC-2 input surface. One input: the hovered cell changing (hit-tested purely in
23/// [`HeatmapView::render`], applied in [`HeatmapView::update`]).
24#[derive(Clone, Debug, PartialEq)]
25pub enum HeatmapMsg {
26    /// Set (or clear, with `None`) the hovered `(row, col)` cell.
27    Hover(Option<(usize, usize)>),
28}
29
30/// FC-8: the heatmap does no I/O.
31#[derive(Clone, Debug)]
32pub enum HeatmapEffect {}
33
34/// A dense matrix in row-major order with optional axis labels.
35pub struct HeatmapView {
36    pub title: String,
37    pub rows: usize,
38    pub cols: usize,
39    /// Row-major: `data[r * cols + c]`.
40    pub data: Vec<f64>,
41    pub row_labels: Vec<String>,
42    pub col_labels: Vec<String>,
43    /// Use the diverging ramp (around a midpoint) instead of sequential.
44    pub diverging: bool,
45    /// Observable interaction state (FC-3).
46    pub state: HeatmapState,
47}
48
49/// The pixel rect of cell `(row, col)` inside `grid`, given `rows × cols`.
50/// Pure geometry — no painter needed, so it's directly testable.
51pub fn cell_rect(grid: Rect, rows: usize, cols: usize, row: usize, col: usize) -> Rect {
52    let cw = grid.width() / cols.max(1) as f32;
53    let ch = grid.height() / rows.max(1) as f32;
54    let x = grid.left() + cw * col as f32;
55    let y = grid.top() + ch * row as f32;
56    Rect::from_min_size(Pos2::new(x, y), vec2(cw, ch))
57}
58
59impl HeatmapView {
60    pub fn new(title: impl Into<String>, rows: usize, cols: usize, data: Vec<f64>) -> Self {
61        Self {
62            title: title.into(),
63            rows,
64            cols,
65            data,
66            row_labels: Vec::new(),
67            col_labels: Vec::new(),
68            diverging: false,
69            state: HeatmapState::default(),
70        }
71    }
72
73    /// FC-3: read the complete observable interaction state.
74    pub fn state(&self) -> &HeatmapState {
75        &self.state
76    }
77
78    /// FC-2/FC-8: the single mutation path. Apply one [`HeatmapMsg`]; no effects.
79    pub fn update(&mut self, msg: HeatmapMsg) -> Vec<HeatmapEffect> {
80        match msg {
81            HeatmapMsg::Hover(h) => self.state.hover = h,
82        }
83        Vec::new()
84    }
85
86    pub fn with_labels(mut self, rows: Vec<String>, cols: Vec<String>) -> Self {
87        self.row_labels = rows;
88        self.col_labels = cols;
89        self
90    }
91
92    pub fn diverging(mut self) -> Self {
93        self.diverging = true;
94        self
95    }
96
97    /// Value at `(row, col)`, or `None` if out of bounds / ragged data.
98    pub fn get(&self, row: usize, col: usize) -> Option<f64> {
99        if row >= self.rows || col >= self.cols {
100            return None;
101        }
102        self.data.get(row * self.cols + col).copied()
103    }
104
105    /// `(min, max)` over the finite cells.
106    pub fn value_range(&self) -> Option<(f64, f64)> {
107        let mut r: Option<(f64, f64)> = None;
108        for &x in &self.data {
109            if x.is_finite() {
110                r = Some(match r {
111                    None => (x, x),
112                    Some((lo, hi)) => (lo.min(x), hi.max(x)),
113                });
114            }
115        }
116        r
117    }
118
119    fn to_csv(&self) -> String {
120        let mut s = String::new();
121        if !self.col_labels.is_empty() {
122            s.push_str(&format!(",{}\n", self.col_labels.join(",")));
123        }
124        for r in 0..self.rows {
125            if let Some(lbl) = self.row_labels.get(r) {
126                s.push_str(lbl);
127            } else {
128                s.push_str(&format!("r{r}"));
129            }
130            for c in 0..self.cols {
131                s.push(',');
132                s.push_str(&format!("{}", self.get(r, c).unwrap_or(f64::NAN)));
133            }
134            s.push('\n');
135        }
136        s
137    }
138}
139
140// ── typed copy (§16) — the chart dataset as CSV Text ──────────────────
141impl CopySource for HeatmapView {
142    fn copy_kinds(&self) -> &[ClipKind] {
143        &[ClipKind::Text]
144    }
145    fn copy_payload(&self) -> Option<ClipPayload> {
146        let csv = self.to_csv();
147        (!csv.is_empty()).then_some(ClipPayload::Text(csv))
148    }
149}
150
151impl HeatmapView {
152    /// FC-9 pure view: paints the grid + legend + hover tooltip (from the
153    /// *stored* hover cell) and **returns** the hovered-cell change as a `Msg`.
154    fn render(&self, ui: &mut Ui) -> Vec<HeatmapMsg> {
155        let th = theme(ui);
156        let ramp = if self.diverging { Ramp::diverging(&th) } else { Ramp::sequential(&th) };
157        let (rect, resp) = ui.allocate_exact_size(ui.available_size(), Sense::hover());
158        let painter = ui.painter_at(rect);
159        painter.rect_filled(rect, egui::CornerRadius::ZERO, th.bg);
160        let Some((vmin, vmax)) = self.value_range() else {
161            // ── render-lane emit: this Facet::ui path RAN ─────────────────────────
162            #[cfg(feature = "testmatrix")]
163            facett_core::testmatrix::emit(
164                "facett-plotters::HeatmapView::ui",
165                "ui_render",
166                self.data.is_empty(),
167                &format!("drew=empty_hint rows={} cols={} cells={}", self.rows, self.cols, self.data.len()),
168            );
169            painter.text(rect.center(), Align2::CENTER_CENTER, "no data", FontId::proportional(13.0), th.text_dim);
170            return hover_msg(self.state.hover, None);
171        };
172        // Reserve a strip on the right for the legend.
173        let legend_w = 56.0;
174        let grid = rect.shrink2(vec2(8.0, 8.0));
175        let grid = Rect::from_min_max(grid.min, Pos2::new(grid.right() - legend_w, grid.bottom()));
176        let hover = resp.hover_pos();
177        let mut new_hover: Option<(usize, usize)> = None;
178        let base = ui.id().with("heatmap");
179
180        for r in 0..self.rows {
181            for c in 0..self.cols {
182                let Some(v) = self.get(r, c) else { continue };
183                let cr = cell_rect(grid, self.rows, self.cols, r, c);
184                let color = ramp.at(normalize(v, vmin, vmax));
185                painter.rect_filled(cr.shrink(0.5), egui::CornerRadius::ZERO, color);
186                let rl = self.row_labels.get(r).cloned().unwrap_or_else(|| format!("r{r}"));
187                let cl = self.col_labels.get(c).cloned().unwrap_or_else(|| format!("c{c}"));
188                // AccessKit node over each cell (FC-4): role=Image, label =
189                // "row × col" (unique per cell), numeric_value = the cell value.
190                // Keyed on the (r,c) coordinate (stable, FC-5).
191                a11y_node(
192                    ui,
193                    base,
194                    (r, c),
195                    Sense::hover(),
196                    cr,
197                    Semantics::image(format!("{rl} × {cl}")).value(v),
198                );
199                if let Some(hp) = hover && cr.contains(hp) {
200                    new_hover = Some((r, c));
201                }
202            }
203        }
204
205        // Legend gradient (top = max, bottom = min).
206        let lx = rect.right() - legend_w + 14.0;
207        let lrect = Rect::from_min_max(Pos2::new(lx, grid.top()), Pos2::new(lx + 16.0, grid.bottom()));
208        let steps = 48;
209        for i in 0..steps {
210            let t = i as f32 / (steps - 1) as f32;
211            let y0 = lrect.bottom() - lrect.height() * (i as f32 / steps as f32);
212            let y1 = lrect.bottom() - lrect.height() * ((i + 1) as f32 / steps as f32);
213            painter.rect_filled(
214                Rect::from_min_max(Pos2::new(lrect.left(), y1), Pos2::new(lrect.right(), y0)),
215                egui::CornerRadius::ZERO,
216                ramp.at(t),
217            );
218        }
219        painter.rect_stroke(lrect, egui::CornerRadius::ZERO, Stroke::new(1.0_f32, th.panel_stroke), egui::StrokeKind::Outside);
220        painter.text(Pos2::new(lrect.right() + 4.0, lrect.top()), Align2::LEFT_CENTER, format!("{vmax:.2}"), FontId::proportional(10.0), th.text_dim);
221        painter.text(Pos2::new(lrect.right() + 4.0, lrect.bottom()), Align2::LEFT_CENTER, format!("{vmin:.2}"), FontId::proportional(10.0), th.text_dim);
222
223        // Tooltip painted last (on top) from the *persisted* hover cell (set by
224        // `update` last frame), keeping `render` a pure function of `&self`.
225        if let Some(hp) = hover
226            && let Some((r, c)) = self.state.hover
227            && let Some(v) = self.get(r, c)
228        {
229            let rl = self.row_labels.get(r).cloned().unwrap_or_else(|| format!("r{r}"));
230            let cl = self.col_labels.get(c).cloned().unwrap_or_else(|| format!("c{c}"));
231            paint_tooltip(&painter, &th, hp, &format!("{rl} × {cl}\n{v:.4}"));
232        }
233        // ── render-lane emit: this Facet::ui path RAN ─────────────────────────
234        #[cfg(feature = "testmatrix")]
235        facett_core::testmatrix::emit(
236            "facett-plotters::HeatmapView::ui",
237            "ui_render",
238            !self.data.is_empty() && self.rows * self.cols <= self.data.len(),
239            &format!("rows={} cols={} cells={}", self.rows, self.cols, self.data.len()),
240        );
241        hover_msg(self.state.hover, new_hover)
242    }
243}
244
245/// Emit a `Hover` message only when the hovered cell actually changed.
246fn hover_msg(current: Option<(usize, usize)>, next: Option<(usize, usize)>) -> Vec<HeatmapMsg> {
247    if current != next { vec![HeatmapMsg::Hover(next)] } else { Vec::new() }
248}
249
250impl Elm for HeatmapView {
251    type Model = HeatmapState;
252    type Msg = HeatmapMsg;
253    type Effect = HeatmapEffect;
254
255    fn title(&self) -> &str {
256        &self.title
257    }
258    fn state(&self) -> &HeatmapState {
259        &self.state
260    }
261    fn update(&mut self, msg: HeatmapMsg) -> Vec<HeatmapEffect> {
262        HeatmapView::update(self, msg)
263    }
264    fn view(&self, ui: &mut Ui) -> Vec<HeatmapMsg> {
265        facett_core::navgraph::declare_own_draw_order(ui); // atoms follow the data order (navgraph §9)
266        self.render(ui)
267    }
268}
269
270// Form 3: richer derived `state_json` (shape + value range + ramp mode) preserved
271// exactly, so the macro's default `serde(state())` is suppressed.
272facett_core::impl_facet_via_elm!(HeatmapView, custom_state_json, {
273    fn caps(&self) -> FacetCaps {
274        FacetCaps::NONE.themeable().copyable().resizable()
275    }
276    fn copy(&mut self) -> Option<String> {
277        Some(self.to_csv())
278    }
279    fn state_json(&self) -> serde_json::Value {
280        let (mn, mx) = self.value_range().unwrap_or((0.0, 0.0));
281        serde_json::json!({
282            "kind": "heatmap",
283            "rows": self.rows, "cols": self.cols,
284            "cells": self.data.len(),
285            "min": mn, "max": mx,
286            "diverging": self.diverging,
287        })
288    }
289});
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294
295    #[test]
296    fn typed_copy_is_the_dataset_csv() {
297        use facett_core::clip::{ClipKind, CopySource};
298        let v = HeatmapView::new("h", 2, 2, vec![1.0, 2.0, 3.0, 4.0]);
299        let p = v.copy_payload().expect("data copies");
300        assert_eq!(p.kind(), ClipKind::Text);
301        assert_eq!(p.as_text(), v.to_csv());
302    }
303
304    fn grid() -> Rect {
305        Rect::from_min_size(Pos2::new(0.0, 0.0), vec2(100.0, 80.0))
306    }
307
308    #[test]
309    fn cell_rects_tile_without_gaps() {
310        let g = grid();
311        let (rows, cols) = (4, 5);
312        // first cell starts at origin
313        let c00 = cell_rect(g, rows, cols, 0, 0);
314        assert_eq!(c00.min, Pos2::new(0.0, 0.0));
315        assert!((c00.width() - 20.0).abs() < 1e-4);
316        assert!((c00.height() - 20.0).abs() < 1e-4);
317        // adjacent column starts where the previous ends
318        let c01 = cell_rect(g, rows, cols, 0, 1);
319        assert!((c01.left() - c00.right()).abs() < 1e-4);
320        // last cell ends at the grid's far corner
321        let last = cell_rect(g, rows, cols, rows - 1, cols - 1);
322        assert!((last.right() - g.right()).abs() < 1e-4);
323        assert!((last.bottom() - g.bottom()).abs() < 1e-4);
324    }
325
326    #[test]
327    fn get_bounds_and_row_major() {
328        let h = HeatmapView::new("t", 2, 3, vec![0.0, 1.0, 2.0, 3.0, 4.0, 5.0]);
329        assert_eq!(h.get(0, 0), Some(0.0));
330        assert_eq!(h.get(0, 2), Some(2.0));
331        assert_eq!(h.get(1, 0), Some(3.0));
332        assert_eq!(h.get(1, 2), Some(5.0));
333        assert_eq!(h.get(2, 0), None); // row OOB
334        assert_eq!(h.get(0, 3), None); // col OOB
335    }
336
337    #[test]
338    fn value_range_ignores_nan() {
339        let h = HeatmapView::new("t", 1, 3, vec![1.0, f64::NAN, 9.0]);
340        assert_eq!(h.value_range(), Some((1.0, 9.0)));
341    }
342
343    #[test]
344    fn csv_round_trips_shape() {
345        let h = HeatmapView::new("t", 2, 2, vec![1.0, 2.0, 3.0, 4.0])
346            .with_labels(vec!["a".into(), "b".into()], vec!["x".into(), "y".into()]);
347        let csv = h.to_csv();
348        assert!(csv.contains(",x,y\n"));
349        assert!(csv.contains("a,1,2\n"));
350        assert!(csv.contains("b,3,4\n"));
351    }
352
353    // ── Elm (FC-2/FC-3) harness properties ────────────────────────────────
354    #[test]
355    fn hover_transitions_are_observable() {
356        let mut v = HeatmapView::new("t", 2, 2, vec![1.0, 2.0, 3.0, 4.0]);
357        assert_eq!(v.state().hover, None);
358        let snap = facett_core::harness::snapshot(&mut v, [HeatmapMsg::Hover(Some((1, 0)))]);
359        assert_eq!(snap.hover, Some((1, 0)));
360        let snap = facett_core::harness::snapshot(&mut v, [HeatmapMsg::Hover(None)]);
361        assert_eq!(snap.hover, None);
362    }
363
364    #[test]
365    fn drive_produces_no_effects() {
366        let mut v = HeatmapView::new("t", 2, 2, vec![1.0, 2.0, 3.0, 4.0]);
367        let effects = facett_core::harness::drive(&mut v, [HeatmapMsg::Hover(Some((0, 1))), HeatmapMsg::Hover(None)]);
368        assert!(effects.is_empty());
369    }
370
371    #[test]
372    fn state_serde_round_trips() {
373        let st = HeatmapState { hover: Some((3, 2)) };
374        let back: HeatmapState = serde_json::from_value(serde_json::to_value(&st).unwrap()).unwrap();
375        assert_eq!(st, back);
376    }
377}