1use presentar_core::{
7 Brick, BrickAssertion, BrickBudget, BrickVerification, Canvas, Color, Constraints, Event,
8 LayoutResult, Point, Rect, Size, TextStyle, TypeId, Widget,
9};
10use std::any::Any;
11use std::time::Duration;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
15pub enum HeatmapPalette {
16 #[default]
18 BlueRed,
19 Viridis,
21 GreenRed,
23 Grayscale,
25 Mono(u8, u8, u8),
27}
28
29impl HeatmapPalette {
30 #[must_use]
32 pub fn color(&self, value: f64) -> Color {
33 let t = value.clamp(0.0, 1.0) as f32;
34 match self {
35 Self::BlueRed => {
36 if t < 0.5 {
37 let s = t * 2.0;
38 Color::new(s, s, 1.0, 1.0)
39 } else {
40 let s = (t - 0.5) * 2.0;
41 Color::new(1.0, 1.0 - s, 1.0 - s, 1.0)
42 }
43 }
44 Self::Viridis => {
45 let colors = [
46 (0.27, 0.00, 0.33),
47 (0.28, 0.14, 0.45),
48 (0.26, 0.24, 0.53),
49 (0.22, 0.34, 0.55),
50 (0.18, 0.44, 0.56),
51 (0.12, 0.56, 0.55),
52 (0.20, 0.72, 0.47),
53 (0.99, 0.91, 0.15),
54 ];
55 let idx = ((t * 7.0) as usize).min(6);
56 let frac = (t * 7.0) - idx as f32;
57 let (r1, g1, b1) = colors[idx];
58 let (r2, g2, b2) = colors[(idx + 1).min(7)];
59 Color::new(
60 r1 + (r2 - r1) * frac,
61 g1 + (g2 - g1) * frac,
62 b1 + (b2 - b1) * frac,
63 1.0,
64 )
65 }
66 Self::GreenRed => Color::new(t, 1.0 - t, 0.0, 1.0),
67 Self::Grayscale => Color::new(t, t, t, 1.0),
68 Self::Mono(r, g, b) => {
69 let r = (*r as f32 / 255.0) * t;
70 let g = (*g as f32 / 255.0) * t;
71 let b = (*b as f32 / 255.0) * t;
72 Color::new(r, g, b, 1.0)
73 }
74 }
75 }
76}
77
78#[derive(Debug, Clone)]
80pub struct HeatmapCell {
81 pub value: f64,
83 pub label: Option<String>,
85}
86
87impl HeatmapCell {
88 #[must_use]
90 pub fn new(value: f64) -> Self {
91 Self { value, label: None }
92 }
93
94 #[must_use]
96 pub fn with_label(value: f64, label: impl Into<String>) -> Self {
97 Self {
98 value,
99 label: Some(label.into()),
100 }
101 }
102}
103
104#[derive(Debug, Clone)]
106pub struct Heatmap {
107 data: Vec<Vec<HeatmapCell>>,
109 row_labels: Vec<String>,
111 col_labels: Vec<String>,
113 palette: HeatmapPalette,
115 min: f64,
117 max: f64,
119 show_values: bool,
121 cell_width: u16,
123 cell_height: u16,
125 bounds: Rect,
127}
128
129impl Default for Heatmap {
130 fn default() -> Self {
131 Self::new(vec![])
132 }
133}
134
135impl Heatmap {
136 #[must_use]
138 pub fn new(data: Vec<Vec<HeatmapCell>>) -> Self {
139 let (min, max) = Self::compute_range(&data);
140 Self {
141 data,
142 row_labels: vec![],
143 col_labels: vec![],
144 palette: HeatmapPalette::default(),
145 min,
146 max,
147 show_values: false,
148 cell_width: 4,
149 cell_height: 1,
150 bounds: Rect::default(),
151 }
152 }
153
154 #[must_use]
156 pub fn from_values(values: Vec<Vec<f64>>) -> Self {
157 let data: Vec<Vec<HeatmapCell>> = values
158 .into_iter()
159 .map(|row| row.into_iter().map(HeatmapCell::new).collect())
160 .collect();
161 Self::new(data)
162 }
163
164 #[must_use]
166 pub fn with_row_labels(mut self, labels: Vec<String>) -> Self {
167 self.row_labels = labels;
168 self
169 }
170
171 #[must_use]
173 pub fn with_col_labels(mut self, labels: Vec<String>) -> Self {
174 self.col_labels = labels;
175 self
176 }
177
178 #[must_use]
180 pub fn with_palette(mut self, palette: HeatmapPalette) -> Self {
181 self.palette = palette;
182 self
183 }
184
185 #[must_use]
187 pub fn with_range(mut self, min: f64, max: f64) -> Self {
188 self.min = min;
189 self.max = max.max(min + 0.001);
190 self
191 }
192
193 #[must_use]
195 pub fn with_values(mut self, show: bool) -> Self {
196 self.show_values = show;
197 self
198 }
199
200 #[must_use]
202 pub fn with_cell_size(mut self, width: u16, height: u16) -> Self {
203 self.cell_width = width.max(1);
204 self.cell_height = height.max(1);
205 self
206 }
207
208 #[must_use]
210 pub fn rows(&self) -> usize {
211 self.data.len()
212 }
213
214 #[must_use]
216 pub fn cols(&self) -> usize {
217 self.data.first().map_or(0, Vec::len)
218 }
219
220 fn compute_range(data: &[Vec<HeatmapCell>]) -> (f64, f64) {
221 let mut min = f64::MAX;
222 let mut max = f64::MIN;
223 for row in data {
224 for cell in row {
225 min = min.min(cell.value);
226 max = max.max(cell.value);
227 }
228 }
229 if min == f64::MAX {
230 (0.0, 1.0)
231 } else if (max - min).abs() < f64::EPSILON {
232 (min - 0.5, max + 0.5)
233 } else {
234 (min, max)
235 }
236 }
237
238 fn normalize(&self, value: f64) -> f64 {
239 let range = self.max - self.min;
240 if range.abs() < f64::EPSILON {
241 0.5
242 } else {
243 ((value - self.min) / range).clamp(0.0, 1.0)
244 }
245 }
246}
247
248impl Brick for Heatmap {
249 fn brick_name(&self) -> &'static str {
250 "heatmap"
251 }
252
253 fn assertions(&self) -> &[BrickAssertion] {
254 static ASSERTIONS: &[BrickAssertion] = &[BrickAssertion::max_latency_ms(16)];
255 ASSERTIONS
256 }
257
258 fn budget(&self) -> BrickBudget {
259 BrickBudget::uniform(16)
260 }
261
262 fn verify(&self) -> BrickVerification {
263 BrickVerification {
264 passed: self.assertions().to_vec(),
265 failed: vec![],
266 verification_time: Duration::from_micros(10),
267 }
268 }
269
270 fn to_html(&self) -> String {
271 String::new()
272 }
273
274 fn to_css(&self) -> String {
275 String::new()
276 }
277}
278
279impl Widget for Heatmap {
280 fn type_id(&self) -> TypeId {
281 TypeId::of::<Self>()
282 }
283
284 fn measure(&self, constraints: Constraints) -> Size {
285 let label_width = self.row_labels.iter().map(String::len).max().unwrap_or(0) as f32;
286 let width = label_width + (self.cols() as f32 * self.cell_width as f32);
287 let height = if self.col_labels.is_empty() { 0.0 } else { 1.0 }
288 + (self.rows() as f32 * self.cell_height as f32);
289 constraints.constrain(Size::new(width, height))
290 }
291
292 fn layout(&mut self, bounds: Rect) -> LayoutResult {
293 self.bounds = bounds;
294 LayoutResult {
295 size: Size::new(bounds.width, bounds.height),
296 }
297 }
298
299 fn paint(&self, canvas: &mut dyn Canvas) {
300 if self.data.is_empty() {
301 return;
302 }
303
304 let label_width = self.row_labels.iter().map(String::len).max().unwrap_or(0) as f32;
305 let start_x = self.bounds.x + label_width;
306 let mut start_y = self.bounds.y;
307
308 if !self.col_labels.is_empty() {
310 let label_style = TextStyle {
311 color: Color::new(0.7, 0.7, 0.7, 1.0),
312 ..Default::default()
313 };
314 for (col, label) in self.col_labels.iter().enumerate() {
315 let x = start_x + (col as f32 * self.cell_width as f32);
316 let truncated: String = label.chars().take(self.cell_width as usize).collect();
317 canvas.draw_text(&truncated, Point::new(x, start_y), &label_style);
318 }
319 start_y += 1.0;
320 }
321
322 for (row_idx, row) in self.data.iter().enumerate() {
324 let y = start_y + (row_idx as f32 * self.cell_height as f32);
325
326 if let Some(label) = self.row_labels.get(row_idx) {
328 let label_style = TextStyle {
329 color: Color::new(0.7, 0.7, 0.7, 1.0),
330 ..Default::default()
331 };
332 canvas.draw_text(label, Point::new(self.bounds.x, y), &label_style);
333 }
334
335 for (col_idx, cell) in row.iter().enumerate() {
337 let x = start_x + (col_idx as f32 * self.cell_width as f32);
338 let norm = self.normalize(cell.value);
339 let color = self.palette.color(norm);
340
341 canvas.fill_rect(
343 Rect::new(x, y, self.cell_width as f32, self.cell_height as f32),
344 color,
345 );
346
347 if self.show_values {
349 let text = cell
350 .label
351 .clone()
352 .unwrap_or_else(|| format!("{:.1}", cell.value));
353 let text: String = text.chars().take(self.cell_width as usize).collect();
354
355 let text_color = if norm > 0.5 {
357 Color::BLACK
358 } else {
359 Color::WHITE
360 };
361 let text_style = TextStyle {
362 color: text_color,
363 ..Default::default()
364 };
365 canvas.draw_text(&text, Point::new(x, y), &text_style);
366 }
367 }
368 }
369 }
370
371 fn event(&mut self, _event: &Event) -> Option<Box<dyn Any + Send>> {
372 None
373 }
374
375 fn children(&self) -> &[Box<dyn Widget>] {
376 &[]
377 }
378
379 fn children_mut(&mut self) -> &mut [Box<dyn Widget>] {
380 &mut []
381 }
382}
383
384#[cfg(test)]
385mod tests {
386 use super::*;
387
388 struct MockCanvas {
389 texts: Vec<(String, Point)>,
390 rects: Vec<(Rect, Color)>,
391 }
392
393 impl MockCanvas {
394 fn new() -> Self {
395 Self {
396 texts: vec![],
397 rects: vec![],
398 }
399 }
400 }
401
402 impl Canvas for MockCanvas {
403 fn fill_rect(&mut self, rect: Rect, color: Color) {
404 self.rects.push((rect, color));
405 }
406 fn stroke_rect(&mut self, _rect: Rect, _color: Color, _width: f32) {}
407 fn draw_text(&mut self, text: &str, position: Point, _style: &TextStyle) {
408 self.texts.push((text.to_string(), position));
409 }
410 fn draw_line(&mut self, _from: Point, _to: Point, _color: Color, _width: f32) {}
411 fn fill_circle(&mut self, _center: Point, _radius: f32, _color: Color) {}
412 fn stroke_circle(&mut self, _center: Point, _radius: f32, _color: Color, _width: f32) {}
413 fn fill_arc(&mut self, _c: Point, _r: f32, _s: f32, _e: f32, _color: Color) {}
414 fn draw_path(&mut self, _points: &[Point], _color: Color, _width: f32) {}
415 fn fill_polygon(&mut self, _points: &[Point], _color: Color) {}
416 fn push_clip(&mut self, _rect: Rect) {}
417 fn pop_clip(&mut self) {}
418 fn push_transform(&mut self, _transform: presentar_core::Transform2D) {}
419 fn pop_transform(&mut self) {}
420 }
421
422 #[test]
423 fn test_heatmap_creation() {
424 let data = vec![
425 vec![HeatmapCell::new(1.0), HeatmapCell::new(2.0)],
426 vec![HeatmapCell::new(3.0), HeatmapCell::new(4.0)],
427 ];
428 let heatmap = Heatmap::new(data);
429 assert_eq!(heatmap.rows(), 2);
430 assert_eq!(heatmap.cols(), 2);
431 }
432
433 #[test]
434 fn test_heatmap_from_values() {
435 let heatmap = Heatmap::from_values(vec![vec![1.0, 2.0], vec![3.0, 4.0]]);
436 assert_eq!(heatmap.rows(), 2);
437 assert_eq!(heatmap.cols(), 2);
438 }
439
440 #[test]
441 fn test_heatmap_assertions() {
442 let heatmap = Heatmap::default();
443 assert!(!heatmap.assertions().is_empty());
444 }
445
446 #[test]
447 fn test_heatmap_verify() {
448 let heatmap = Heatmap::default();
449 assert!(heatmap.verify().is_valid());
450 }
451
452 #[test]
453 fn test_heatmap_with_palette() {
454 let heatmap = Heatmap::default().with_palette(HeatmapPalette::Viridis);
455 assert_eq!(heatmap.palette, HeatmapPalette::Viridis);
456 }
457
458 #[test]
459 fn test_heatmap_with_range() {
460 let heatmap = Heatmap::default().with_range(0.0, 100.0);
461 assert_eq!(heatmap.min, 0.0);
462 assert_eq!(heatmap.max, 100.0);
463 }
464
465 #[test]
466 fn test_heatmap_with_values() {
467 let heatmap = Heatmap::default().with_values(true);
468 assert!(heatmap.show_values);
469 }
470
471 #[test]
472 fn test_heatmap_with_cell_size() {
473 let heatmap = Heatmap::default().with_cell_size(6, 2);
474 assert_eq!(heatmap.cell_width, 6);
475 assert_eq!(heatmap.cell_height, 2);
476 }
477
478 #[test]
479 fn test_heatmap_with_labels() {
480 let heatmap = Heatmap::default()
481 .with_row_labels(vec!["A".to_string(), "B".to_string()])
482 .with_col_labels(vec!["X".to_string(), "Y".to_string()]);
483 assert_eq!(heatmap.row_labels.len(), 2);
484 assert_eq!(heatmap.col_labels.len(), 2);
485 }
486
487 #[test]
488 fn test_heatmap_paint() {
489 let mut heatmap = Heatmap::from_values(vec![vec![1.0, 2.0], vec![3.0, 4.0]]);
490 heatmap.bounds = Rect::new(0.0, 0.0, 20.0, 10.0);
491 let mut canvas = MockCanvas::new();
492 heatmap.paint(&mut canvas);
493 assert!(!canvas.rects.is_empty());
494 }
495
496 #[test]
497 fn test_heatmap_paint_with_values() {
498 let mut heatmap = Heatmap::from_values(vec![vec![1.0, 2.0]]).with_values(true);
499 heatmap.bounds = Rect::new(0.0, 0.0, 20.0, 10.0);
500 let mut canvas = MockCanvas::new();
501 heatmap.paint(&mut canvas);
502 assert!(!canvas.texts.is_empty());
503 }
504
505 #[test]
506 fn test_heatmap_paint_with_labels() {
507 let mut heatmap = Heatmap::from_values(vec![vec![1.0]])
508 .with_row_labels(vec!["Row".to_string()])
509 .with_col_labels(vec!["Col".to_string()]);
510 heatmap.bounds = Rect::new(0.0, 0.0, 20.0, 10.0);
511 let mut canvas = MockCanvas::new();
512 heatmap.paint(&mut canvas);
513 assert!(!canvas.texts.is_empty());
514 }
515
516 #[test]
517 fn test_heatmap_empty() {
518 let mut heatmap = Heatmap::default();
519 heatmap.bounds = Rect::new(0.0, 0.0, 20.0, 10.0);
520 let mut canvas = MockCanvas::new();
521 heatmap.paint(&mut canvas);
522 assert!(canvas.rects.is_empty());
523 }
524
525 #[test]
526 fn test_palette_blue_red() {
527 let palette = HeatmapPalette::BlueRed;
528 let _low = palette.color(0.0);
529 let _mid = palette.color(0.5);
530 let _high = palette.color(1.0);
531 }
532
533 #[test]
534 fn test_palette_viridis() {
535 let palette = HeatmapPalette::Viridis;
536 let _low = palette.color(0.0);
537 let _mid = palette.color(0.5);
538 let _high = palette.color(1.0);
539 }
540
541 #[test]
542 fn test_palette_green_red() {
543 let palette = HeatmapPalette::GreenRed;
544 let low = palette.color(0.0);
545 let high = palette.color(1.0);
546 assert!(low.g > low.r);
547 assert!(high.r > high.g);
548 }
549
550 #[test]
551 fn test_palette_grayscale() {
552 let palette = HeatmapPalette::Grayscale;
553 let mid = palette.color(0.5);
554 assert!((mid.r - 0.5).abs() < 0.01);
555 }
556
557 #[test]
558 fn test_palette_mono() {
559 let palette = HeatmapPalette::Mono(255, 0, 0);
560 let full = palette.color(1.0);
561 assert!((full.r - 1.0).abs() < 0.01);
562 }
563
564 #[test]
565 fn test_heatmap_cell_with_label() {
566 let cell = HeatmapCell::with_label(5.0, "test");
567 assert_eq!(cell.value, 5.0);
568 assert_eq!(cell.label, Some("test".to_string()));
569 }
570
571 #[test]
572 fn test_heatmap_measure() {
573 let heatmap = Heatmap::from_values(vec![vec![1.0, 2.0], vec![3.0, 4.0]]);
574 let size = heatmap.measure(Constraints::loose(Size::new(100.0, 100.0)));
575 assert!(size.width > 0.0);
576 assert!(size.height > 0.0);
577 }
578
579 #[test]
580 fn test_heatmap_layout() {
581 let mut heatmap = Heatmap::from_values(vec![vec![1.0]]);
582 let bounds = Rect::new(5.0, 10.0, 30.0, 20.0);
583 let result = heatmap.layout(bounds);
584 assert_eq!(result.size.width, 30.0);
585 assert_eq!(heatmap.bounds, bounds);
586 }
587
588 #[test]
589 fn test_heatmap_brick_name() {
590 let heatmap = Heatmap::default();
591 assert_eq!(heatmap.brick_name(), "heatmap");
592 }
593
594 #[test]
595 fn test_heatmap_type_id() {
596 let heatmap = Heatmap::default();
597 assert_eq!(Widget::type_id(&heatmap), TypeId::of::<Heatmap>());
598 }
599
600 #[test]
601 fn test_heatmap_children() {
602 let heatmap = Heatmap::default();
603 assert!(heatmap.children().is_empty());
604 }
605
606 #[test]
607 fn test_heatmap_event() {
608 let mut heatmap = Heatmap::default();
609 let event = Event::KeyDown {
610 key: presentar_core::Key::Enter,
611 };
612 assert!(heatmap.event(&event).is_none());
613 }
614
615 #[test]
616 fn test_heatmap_children_mut() {
617 let mut heatmap = Heatmap::default();
618 assert!(heatmap.children_mut().is_empty());
619 }
620
621 #[test]
622 fn test_heatmap_to_html() {
623 let heatmap = Heatmap::default();
624 assert!(heatmap.to_html().is_empty());
625 }
626
627 #[test]
628 fn test_heatmap_to_css() {
629 let heatmap = Heatmap::default();
630 assert!(heatmap.to_css().is_empty());
631 }
632
633 #[test]
634 fn test_heatmap_budget() {
635 let heatmap = Heatmap::default();
636 let budget = heatmap.budget();
637 assert_eq!(budget.total_ms, 16);
638 }
639
640 #[test]
641 fn test_heatmap_same_value_range() {
642 let heatmap = Heatmap::from_values(vec![vec![5.0, 5.0], vec![5.0, 5.0]]);
644 assert!(heatmap.min < heatmap.max);
646 }
647
648 #[test]
649 fn test_heatmap_with_cell_labels() {
650 let data = vec![vec![
651 HeatmapCell::with_label(1.0, "A"),
652 HeatmapCell::with_label(2.0, "B"),
653 ]];
654 let mut heatmap = Heatmap::new(data).with_values(true);
655 heatmap.bounds = Rect::new(0.0, 0.0, 20.0, 10.0);
656 let mut canvas = MockCanvas::new();
657 heatmap.paint(&mut canvas);
658 assert!(canvas.texts.iter().any(|(t, _)| t == "A" || t == "B"));
660 }
661
662 #[test]
663 fn test_heatmap_high_value_contrast() {
664 let mut heatmap = Heatmap::from_values(vec![vec![10.0]]).with_values(true);
666 heatmap.bounds = Rect::new(0.0, 0.0, 20.0, 10.0);
667 let mut canvas = MockCanvas::new();
668 heatmap.paint(&mut canvas);
669 assert!(!canvas.texts.is_empty());
671 }
672
673 #[test]
674 fn test_heatmap_low_value_contrast() {
675 let mut heatmap = Heatmap::from_values(vec![vec![1.0, 10.0]]).with_values(true);
677 heatmap.bounds = Rect::new(0.0, 0.0, 20.0, 10.0);
678 let mut canvas = MockCanvas::new();
679 heatmap.paint(&mut canvas);
680 assert!(!canvas.texts.is_empty());
682 }
683
684 #[test]
685 fn test_palette_blue_red_low() {
686 let palette = HeatmapPalette::BlueRed;
687 let low = palette.color(0.0);
688 assert!(low.b > low.r);
690 }
691
692 #[test]
693 fn test_palette_blue_red_high() {
694 let palette = HeatmapPalette::BlueRed;
695 let high = palette.color(1.0);
696 assert!(high.r > high.b);
698 }
699
700 #[test]
701 fn test_heatmap_measure_with_labels() {
702 let heatmap = Heatmap::from_values(vec![vec![1.0, 2.0]])
703 .with_row_labels(vec!["LongRowLabel".to_string()])
704 .with_col_labels(vec!["A".to_string(), "B".to_string()]);
705 let size = heatmap.measure(Constraints::loose(Size::new(100.0, 100.0)));
706 assert!(size.width > 0.0);
708 assert!(size.height >= 2.0);
710 }
711
712 #[test]
713 fn test_heatmap_with_range_min_equals_max() {
714 let heatmap = Heatmap::default().with_range(5.0, 5.0);
715 assert!(heatmap.max > heatmap.min);
717 }
718
719 #[test]
720 fn test_heatmap_cell_size_min() {
721 let heatmap = Heatmap::default().with_cell_size(0, 0);
722 assert_eq!(heatmap.cell_width, 1);
724 assert_eq!(heatmap.cell_height, 1);
725 }
726}