1use egui::{Align2, FontId, Pos2, Rect, Sense, Stroke, Ui, vec2};
7use facett_core::clip::{ClipKind, ClipPayload, CopySource};
8use facett_core::{Facet, FacetCaps, Semantics, a11y_node, theme};
9
10use crate::boxplot::paint_tooltip;
11use crate::ramp::{Ramp, normalize};
12
13pub struct HeatmapView {
15 pub title: String,
16 pub rows: usize,
17 pub cols: usize,
18 pub data: Vec<f64>,
20 pub row_labels: Vec<String>,
21 pub col_labels: Vec<String>,
22 pub diverging: bool,
24}
25
26pub fn cell_rect(grid: Rect, rows: usize, cols: usize, row: usize, col: usize) -> Rect {
29 let cw = grid.width() / cols.max(1) as f32;
30 let ch = grid.height() / rows.max(1) as f32;
31 let x = grid.left() + cw * col as f32;
32 let y = grid.top() + ch * row as f32;
33 Rect::from_min_size(Pos2::new(x, y), vec2(cw, ch))
34}
35
36impl HeatmapView {
37 pub fn new(title: impl Into<String>, rows: usize, cols: usize, data: Vec<f64>) -> Self {
38 Self {
39 title: title.into(),
40 rows,
41 cols,
42 data,
43 row_labels: Vec::new(),
44 col_labels: Vec::new(),
45 diverging: false,
46 }
47 }
48
49 pub fn with_labels(mut self, rows: Vec<String>, cols: Vec<String>) -> Self {
50 self.row_labels = rows;
51 self.col_labels = cols;
52 self
53 }
54
55 pub fn diverging(mut self) -> Self {
56 self.diverging = true;
57 self
58 }
59
60 pub fn get(&self, row: usize, col: usize) -> Option<f64> {
62 if row >= self.rows || col >= self.cols {
63 return None;
64 }
65 self.data.get(row * self.cols + col).copied()
66 }
67
68 pub fn value_range(&self) -> Option<(f64, f64)> {
70 let mut r: Option<(f64, f64)> = None;
71 for &x in &self.data {
72 if x.is_finite() {
73 r = Some(match r {
74 None => (x, x),
75 Some((lo, hi)) => (lo.min(x), hi.max(x)),
76 });
77 }
78 }
79 r
80 }
81
82 fn to_csv(&self) -> String {
83 let mut s = String::new();
84 if !self.col_labels.is_empty() {
85 s.push_str(&format!(",{}\n", self.col_labels.join(",")));
86 }
87 for r in 0..self.rows {
88 if let Some(lbl) = self.row_labels.get(r) {
89 s.push_str(lbl);
90 } else {
91 s.push_str(&format!("r{r}"));
92 }
93 for c in 0..self.cols {
94 s.push(',');
95 s.push_str(&format!("{}", self.get(r, c).unwrap_or(f64::NAN)));
96 }
97 s.push('\n');
98 }
99 s
100 }
101}
102
103impl CopySource for HeatmapView {
105 fn copy_kinds(&self) -> &[ClipKind] {
106 &[ClipKind::Text]
107 }
108 fn copy_payload(&self) -> Option<ClipPayload> {
109 let csv = self.to_csv();
110 (!csv.is_empty()).then_some(ClipPayload::Text(csv))
111 }
112}
113
114impl Facet for HeatmapView {
115 fn title(&self) -> &str {
116 &self.title
117 }
118 fn caps(&self) -> FacetCaps {
119 FacetCaps::NONE.themeable().copyable().resizable()
120 }
121 fn copy(&mut self) -> Option<String> {
122 Some(self.to_csv())
123 }
124 fn ui(&mut self, ui: &mut Ui) {
125 let th = theme(ui);
126 let ramp = if self.diverging { Ramp::diverging(&th) } else { Ramp::sequential(&th) };
127 let (rect, resp) = ui.allocate_exact_size(ui.available_size(), Sense::hover());
128 let painter = ui.painter_at(rect);
129 painter.rect_filled(rect, egui::CornerRadius::ZERO, th.bg);
130 let Some((vmin, vmax)) = self.value_range() else {
131 #[cfg(feature = "testmatrix")]
133 facett_core::testmatrix::emit(
134 "facett-plotters::HeatmapView::ui",
135 "ui_render",
136 self.data.is_empty(),
137 &format!("drew=empty_hint rows={} cols={} cells={}", self.rows, self.cols, self.data.len()),
138 );
139 painter.text(rect.center(), Align2::CENTER_CENTER, "no data", FontId::proportional(13.0), th.text_dim);
140 return;
141 };
142 let legend_w = 56.0;
144 let grid = rect.shrink2(vec2(8.0, 8.0));
145 let grid = Rect::from_min_max(grid.min, Pos2::new(grid.right() - legend_w, grid.bottom()));
146 let hover = resp.hover_pos();
147 let mut tip: Option<(Pos2, String)> = None;
148 let base = ui.id().with("heatmap");
149
150 for r in 0..self.rows {
151 for c in 0..self.cols {
152 let Some(v) = self.get(r, c) else { continue };
153 let cr = cell_rect(grid, self.rows, self.cols, r, c);
154 let color = ramp.at(normalize(v, vmin, vmax));
155 painter.rect_filled(cr.shrink(0.5), egui::CornerRadius::ZERO, color);
156 let rl = self.row_labels.get(r).cloned().unwrap_or_else(|| format!("r{r}"));
157 let cl = self.col_labels.get(c).cloned().unwrap_or_else(|| format!("c{c}"));
158 a11y_node(
162 ui,
163 base,
164 (r, c),
165 Sense::hover(),
166 cr,
167 Semantics::image(format!("{rl} × {cl}")).value(v),
168 );
169 if let Some(hp) = hover {
170 if cr.contains(hp) {
171 tip = Some((hp, format!("{rl} × {cl}\n{v:.4}")));
172 }
173 }
174 }
175 }
176
177 let lx = rect.right() - legend_w + 14.0;
179 let lrect = Rect::from_min_max(Pos2::new(lx, grid.top()), Pos2::new(lx + 16.0, grid.bottom()));
180 let steps = 48;
181 for i in 0..steps {
182 let t = i as f32 / (steps - 1) as f32;
183 let y0 = lrect.bottom() - lrect.height() * (i as f32 / steps as f32);
184 let y1 = lrect.bottom() - lrect.height() * ((i + 1) as f32 / steps as f32);
185 painter.rect_filled(
186 Rect::from_min_max(Pos2::new(lrect.left(), y1), Pos2::new(lrect.right(), y0)),
187 egui::CornerRadius::ZERO,
188 ramp.at(t),
189 );
190 }
191 painter.rect_stroke(lrect, egui::CornerRadius::ZERO, Stroke::new(1.0, th.panel_stroke), egui::StrokeKind::Outside);
192 painter.text(Pos2::new(lrect.right() + 4.0, lrect.top()), Align2::LEFT_CENTER, format!("{vmax:.2}"), FontId::proportional(10.0), th.text_dim);
193 painter.text(Pos2::new(lrect.right() + 4.0, lrect.bottom()), Align2::LEFT_CENTER, format!("{vmin:.2}"), FontId::proportional(10.0), th.text_dim);
194
195 if let Some((at, text)) = tip {
196 paint_tooltip(&painter, &th, at, &text);
197 }
198 #[cfg(feature = "testmatrix")]
200 facett_core::testmatrix::emit(
201 "facett-plotters::HeatmapView::ui",
202 "ui_render",
203 !self.data.is_empty() && self.rows * self.cols <= self.data.len(),
204 &format!("rows={} cols={} cells={}", self.rows, self.cols, self.data.len()),
205 );
206 }
207 fn state_json(&self) -> serde_json::Value {
208 let (mn, mx) = self.value_range().unwrap_or((0.0, 0.0));
209 serde_json::json!({
210 "kind": "heatmap",
211 "rows": self.rows, "cols": self.cols,
212 "cells": self.data.len(),
213 "min": mn, "max": mx,
214 "diverging": self.diverging,
215 })
216 }
217}
218
219#[cfg(test)]
220mod tests {
221 use super::*;
222
223 #[test]
224 fn typed_copy_is_the_dataset_csv() {
225 use facett_core::clip::{ClipKind, CopySource};
226 let v = HeatmapView::new("h", 2, 2, vec![1.0, 2.0, 3.0, 4.0]);
227 let p = v.copy_payload().expect("data copies");
228 assert_eq!(p.kind(), ClipKind::Text);
229 assert_eq!(p.as_text(), v.to_csv());
230 }
231
232 fn grid() -> Rect {
233 Rect::from_min_size(Pos2::new(0.0, 0.0), vec2(100.0, 80.0))
234 }
235
236 #[test]
237 fn cell_rects_tile_without_gaps() {
238 let g = grid();
239 let (rows, cols) = (4, 5);
240 let c00 = cell_rect(g, rows, cols, 0, 0);
242 assert_eq!(c00.min, Pos2::new(0.0, 0.0));
243 assert!((c00.width() - 20.0).abs() < 1e-4);
244 assert!((c00.height() - 20.0).abs() < 1e-4);
245 let c01 = cell_rect(g, rows, cols, 0, 1);
247 assert!((c01.left() - c00.right()).abs() < 1e-4);
248 let last = cell_rect(g, rows, cols, rows - 1, cols - 1);
250 assert!((last.right() - g.right()).abs() < 1e-4);
251 assert!((last.bottom() - g.bottom()).abs() < 1e-4);
252 }
253
254 #[test]
255 fn get_bounds_and_row_major() {
256 let h = HeatmapView::new("t", 2, 3, vec![0.0, 1.0, 2.0, 3.0, 4.0, 5.0]);
257 assert_eq!(h.get(0, 0), Some(0.0));
258 assert_eq!(h.get(0, 2), Some(2.0));
259 assert_eq!(h.get(1, 0), Some(3.0));
260 assert_eq!(h.get(1, 2), Some(5.0));
261 assert_eq!(h.get(2, 0), None); assert_eq!(h.get(0, 3), None); }
264
265 #[test]
266 fn value_range_ignores_nan() {
267 let h = HeatmapView::new("t", 1, 3, vec![1.0, f64::NAN, 9.0]);
268 assert_eq!(h.value_range(), Some((1.0, 9.0)));
269 }
270
271 #[test]
272 fn csv_round_trips_shape() {
273 let h = HeatmapView::new("t", 2, 2, vec![1.0, 2.0, 3.0, 4.0])
274 .with_labels(vec!["a".into(), "b".into()], vec!["x".into(), "y".into()]);
275 let csv = h.to_csv();
276 assert!(csv.contains(",x,y\n"));
277 assert!(csv.contains("a,1,2\n"));
278 assert!(csv.contains("b,3,4\n"));
279 }
280}