use crate::color::Color;
use crate::console::{Console, ConsoleOptions, Renderable};
use crate::gradient::interpolate_color;
use crate::measure::Measurement;
use crate::segment::Segment;
use crate::style::Style;
const DEFAULT_CELL_WIDTH: usize = 2;
#[derive(Debug, Clone)]
pub struct Heatmap {
rows: Vec<Vec<f64>>,
min_value: Option<f64>,
max_value: Option<f64>,
gradient: Vec<Color>,
cell_width: usize,
}
impl Heatmap {
pub fn new(rows: impl Into<Vec<Vec<f64>>>) -> Self {
Self {
rows: rows.into(),
min_value: None,
max_value: None,
gradient: default_gradient(),
cell_width: DEFAULT_CELL_WIDTH,
}
}
#[must_use]
pub fn with_min(mut self, min: f64) -> Self {
self.min_value = Some(min);
self
}
#[must_use]
pub fn with_max(mut self, max: f64) -> Self {
self.max_value = Some(max);
self
}
#[must_use]
pub fn with_gradient(mut self, gradient: Vec<Color>) -> Self {
self.gradient = gradient;
self
}
#[must_use]
pub fn with_cell_width(mut self, cell_width: usize) -> Self {
self.cell_width = cell_width;
self
}
fn cols(&self) -> usize {
self.rows.iter().map(Vec::len).max().unwrap_or(0)
}
fn bounds(&self) -> (f64, f64) {
let auto_min = || {
self.rows
.iter()
.flatten()
.copied()
.fold(f64::INFINITY, f64::min)
};
let auto_max = || {
self.rows
.iter()
.flatten()
.copied()
.fold(f64::NEG_INFINITY, f64::max)
};
let min = self.min_value.unwrap_or_else(auto_min);
let max = self.max_value.unwrap_or_else(auto_max);
if min.is_finite() && max.is_finite() {
(min, max)
} else {
(0.0, 1.0)
}
}
fn normalize(v: f64, min: f64, max: f64) -> f64 {
let range = max - min;
if !range.is_finite() || range.abs() < f64::EPSILON {
return 0.0;
}
((v - min) / range).clamp(0.0, 1.0)
}
fn gradient_color(&self, t: f64) -> Color {
match self.gradient.len() {
0 => Color::default_color(),
1 => self.gradient[0],
n => {
let t = t.clamp(0.0, 1.0);
let segments = n - 1;
let scaled = t * segments as f64;
let seg = (scaled.floor() as usize).min(segments - 1);
let local = scaled - seg as f64;
interpolate_color(&self.gradient[seg], &self.gradient[seg + 1], local)
}
}
}
}
fn default_gradient() -> Vec<Color> {
vec![
Color::from_rgb(0, 0, 128), Color::from_rgb(0, 255, 255), Color::from_rgb(255, 255, 0), Color::from_rgb(255, 0, 0), ]
}
impl Renderable for Heatmap {
fn gilt_console(&self, _console: &Console, _options: &ConsoleOptions) -> Vec<Segment> {
let cols = self.cols();
if cols == 0 {
return Vec::new();
}
let (min, max) = self.bounds();
let cell = " ".repeat(self.cell_width);
let mut segments = Vec::with_capacity(self.rows.len() * (cols + 1));
for row in &self.rows {
for c in 0..cols {
let v = row.get(c).copied().unwrap_or(min);
let t = Self::normalize(v, min, max);
let color = self.gradient_color(t);
segments.push(Segment::new(&cell, Some(Style::null().bg(color)), None));
}
segments.push(Segment::line());
}
segments
}
fn gilt_measure(&self, console: &Console, options: &ConsoleOptions) -> Measurement {
self.measure(console, options)
}
}
impl Heatmap {
pub fn measure(&self, _console: &Console, _options: &ConsoleOptions) -> Measurement {
let w = self.cols() * self.cell_width;
Measurement::new(w, w)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::color_triplet::ColorTriplet;
use crate::console::Console;
fn console() -> Console {
Console::builder()
.width(80)
.force_terminal(true)
.no_color(false)
.build()
}
fn line_widths(segments: &[Segment]) -> Vec<usize> {
let mut widths = vec![0usize];
for seg in segments {
if seg.text.as_str() == "\n" {
widths.push(0);
} else {
*widths.last_mut().unwrap() += seg.text.chars().count();
}
}
if widths.last() == Some(&0) {
widths.pop();
}
widths
}
fn cell_bg(segments: &[Segment], idx: usize) -> ColorTriplet {
let cell = segments
.iter()
.filter(|s| s.text.as_str() != "\n")
.nth(idx)
.expect("cell exists");
cell.style
.as_ref()
.expect("cell has style")
.bgcolor()
.expect("cell has bg colour")
.get_truecolor(None, false)
}
#[test]
fn heatmap_gilt_measure_delegates_to_measure() {
let h = Heatmap::new(vec![vec![1.0, 2.0, 3.0]]);
let c = console();
let opts = c.options();
assert_eq!(h.gilt_measure(&c, &opts), h.measure(&c, &opts));
}
#[test]
fn test_grid_dimensions() {
let h = Heatmap::new(vec![vec![1.0, 2.0, 3.0], vec![4.0, 5.0, 6.0]]);
let c = console();
let segs = h.gilt_console(&c, &c.options());
let widths = line_widths(&segs);
assert_eq!(widths, vec![6, 6]);
}
#[test]
fn test_cell_width() {
let h = Heatmap::new(vec![vec![1.0, 2.0]]).with_cell_width(4);
let c = console();
let segs = h.gilt_console(&c, &c.options());
assert_eq!(line_widths(&segs), vec![8]); }
#[test]
fn test_color_at_t0_first_stop() {
let h = Heatmap::new(vec![vec![0.0, 1.0]]).with_gradient(vec![
Color::from_rgb(0, 0, 0),
Color::from_rgb(255, 255, 255),
]);
let c = console();
let segs = h.gilt_console(&c, &c.options());
assert_eq!(cell_bg(&segs, 0), ColorTriplet::new(0, 0, 0));
}
#[test]
fn test_color_at_t1_last_stop() {
let h = Heatmap::new(vec![vec![0.0, 1.0]]).with_gradient(vec![
Color::from_rgb(0, 0, 0),
Color::from_rgb(255, 255, 255),
]);
let c = console();
let segs = h.gilt_console(&c, &c.options());
assert_eq!(cell_bg(&segs, 1), ColorTriplet::new(255, 255, 255));
}
#[test]
fn test_color_at_midpoint() {
let h = Heatmap::new(vec![vec![0.0, 0.5, 1.0]]).with_gradient(vec![
Color::from_rgb(0, 0, 0),
Color::from_rgb(255, 255, 255),
]);
let c = console();
let segs = h.gilt_console(&c, &c.options());
assert_eq!(cell_bg(&segs, 1), ColorTriplet::new(128, 128, 128));
}
#[test]
fn test_default_gradient_endpoints() {
let h = Heatmap::new(vec![vec![0.0, 1.0]]);
let c = console();
let segs = h.gilt_console(&c, &c.options());
assert_eq!(cell_bg(&segs, 0), ColorTriplet::new(0, 0, 128)); assert_eq!(cell_bg(&segs, 1), ColorTriplet::new(255, 0, 0)); }
#[test]
fn test_with_min_max_override() {
let h = Heatmap::new(vec![vec![0.0, 10.0]])
.with_min(0.0)
.with_max(20.0)
.with_gradient(vec![
Color::from_rgb(0, 0, 0),
Color::from_rgb(200, 200, 200),
]);
let c = console();
let segs = h.gilt_console(&c, &c.options());
assert_eq!(cell_bg(&segs, 0), ColorTriplet::new(0, 0, 0));
assert_eq!(cell_bg(&segs, 1), ColorTriplet::new(100, 100, 100));
}
#[test]
fn test_max_equals_min_guard() {
let h = Heatmap::new(vec![vec![5.0, 5.0, 5.0]]).with_gradient(vec![
Color::from_rgb(10, 20, 30),
Color::from_rgb(200, 100, 50),
]);
let c = console();
let segs = h.gilt_console(&c, &c.options());
for i in 0..3 {
assert_eq!(cell_bg(&segs, i), ColorTriplet::new(10, 20, 30));
}
}
#[test]
fn test_empty_grid() {
let h = Heatmap::new(Vec::<Vec<f64>>::new());
let c = console();
let segs = h.gilt_console(&c, &c.options());
assert!(segs.is_empty());
}
#[test]
fn test_ragged_rows() {
let h = Heatmap::new(vec![vec![1.0, 2.0, 3.0], vec![4.0]]);
let c = console();
let segs = h.gilt_console(&c, &c.options());
assert_eq!(line_widths(&segs), vec![6, 6]);
}
#[test]
fn test_ends_in_newline() {
let h = Heatmap::new(vec![vec![1.0, 2.0], vec![3.0, 4.0]]);
let c = console();
let segs = h.gilt_console(&c, &c.options());
assert_eq!(segs.last().unwrap().text.as_str(), "\n");
}
#[test]
fn test_measure_matches_rendered_width() {
let h = Heatmap::new(vec![vec![1.0, 2.0, 3.0], vec![4.0, 5.0, 6.0]]).with_cell_width(3);
let c = console();
let opts = c.options();
let m = h.measure(&c, &opts);
let widths = line_widths(&h.gilt_console(&c, &opts));
assert_eq!(m, Measurement::new(9, 9)); assert!(widths.iter().all(|&w| w == 9));
}
#[test]
fn test_slice_constructor() {
let slice: &[Vec<f64>] = &[vec![1.0, 2.0], vec![3.0, 4.0]];
let h = Heatmap::new(slice);
let c = console();
let widths = line_widths(&h.gilt_console(&c, &c.options()));
assert_eq!(widths, vec![4, 4]);
}
#[test]
fn test_cells_use_background() {
let h = Heatmap::new(vec![vec![0.0, 1.0]]);
let c = console();
let segs = h.gilt_console(&c, &c.options());
let first = segs.iter().find(|s| s.text.as_str() != "\n").unwrap();
let style = first.style.as_ref().unwrap();
assert!(style.bgcolor().is_some(), "cell must set a bg colour");
assert!(style.color().is_none(), "cell must not set a fg colour");
assert!(first.text.chars().all(|ch| ch == ' '));
}
#[test]
fn test_builder_fields() {
let h = Heatmap::new(vec![vec![1.0]])
.with_min(-1.0)
.with_max(1.0)
.with_cell_width(5)
.with_gradient(vec![Color::from_rgb(1, 2, 3), Color::from_rgb(4, 5, 6)]);
assert_eq!(h.min_value, Some(-1.0));
assert_eq!(h.max_value, Some(1.0));
assert_eq!(h.cell_width, 5);
assert_eq!(h.gradient.len(), 2);
}
#[test]
fn test_all_empty_rows() {
let h = Heatmap::new(vec![Vec::<f64>::new(), Vec::<f64>::new()]);
let c = console();
let segs = h.gilt_console(&c, &c.options());
assert!(segs.is_empty());
}
}