use rich::measure::Measurement;
use rich::{Console, ConsoleOptions, Renderable, Segment, Style};
use super::{
Charset, Line, Scale, ValueFormat, cell_units, cells, entries_width, has_colour,
lines_to_segments, theme_style, truncate, wrap_entries,
};
const SHADES: [char; 5] = [' ', '░', '▒', '▓', '█'];
const ASCII_SHADES: [char; 10] = [' ', '.', ':', '-', '=', '+', '*', '#', '%', '@'];
const HEAT_STYLES: usize = 5;
#[derive(Clone, Debug, PartialEq)]
#[must_use = "builder methods return a new value; it does nothing until it is rendered or used"]
pub struct Heatmap {
columns: Vec<String>,
rows: Vec<(String, Vec<f64>)>,
min: Option<f64>,
max: Option<f64>,
charset: Charset,
cell_width: usize,
legend: bool,
format: ValueFormat,
}
impl Default for Heatmap {
fn default() -> Self {
Self::new()
}
}
struct Grid {
label: usize,
cell: usize,
buckets: Vec<(usize, usize)>,
}
impl Heatmap {
pub fn new() -> Self {
Heatmap {
columns: Vec::new(),
rows: Vec::new(),
min: None,
max: None,
charset: Charset::Auto,
cell_width: 2,
legend: true,
format: ValueFormat::Compact,
}
}
pub fn columns<S: Into<String>>(mut self, headers: impl IntoIterator<Item = S>) -> Self {
self.columns = headers.into_iter().map(Into::into).collect();
self
}
pub fn row(mut self, label: impl Into<String>, values: impl IntoIterator<Item = f64>) -> Self {
self.rows.push((label.into(), values.into_iter().collect()));
self
}
pub fn range(mut self, min: f64, max: f64) -> Self {
self.min = Some(min);
self.max = Some(max);
self
}
pub fn charset(mut self, charset: Charset) -> Self {
self.charset = charset;
self
}
pub fn cell_width(mut self, width: usize) -> Self {
self.cell_width = width.max(1);
self
}
pub fn legend(mut self, show: bool) -> Self {
self.legend = show;
self
}
pub fn format(mut self, format: ValueFormat) -> Self {
self.format = format;
self
}
pub fn scale(&self) -> Scale {
let values = self.rows.iter().flat_map(|(_, v)| v.iter().copied());
Scale::from_values(values).bounds(self.min, self.max)
}
fn column_count(&self) -> usize {
self.rows
.iter()
.map(|(_, v)| v.len())
.max()
.unwrap_or(0)
.max(self.columns.len())
}
fn label_width(&self) -> usize {
self.rows.iter().map(|(l, _)| cells(l)).max().unwrap_or(0)
}
fn has_missing(&self) -> bool {
let n = self.column_count();
self.rows
.iter()
.any(|(_, v)| v.len() < n || v.iter().any(|x| !x.is_finite()))
}
fn layout(&self, width: usize) -> Grid {
let n = self.column_count();
let l = self.label_width();
let part = |l: usize| l + usize::from(l > 0);
let each = |cell: usize| Grid {
label: 0,
cell,
buckets: (0..n).map(|i| (i, i + 1)).collect(),
};
let room = width.saturating_sub(part(l));
if room >= n {
let cell = self.cell_width.min(room / n.max(1)).max(1);
return Grid {
label: l,
..each(cell)
};
}
let keep = l.min(3);
if let Some(label) = width.checked_sub(n + 1)
&& label >= keep
&& label > 0
{
return Grid {
label: label.min(l),
..each(1)
};
}
let label = if width > part(keep) + 1 { keep } else { 0 };
let slots = width.saturating_sub(part(label)).max(1);
let buckets = (0..slots)
.map(|i| {
let start = i * n / slots;
(start, ((i + 1) * n / slots).max(start + 1))
})
.collect();
Grid {
label,
cell: 1,
buckets,
}
}
fn level(scale: &Scale, value: f64, levels: usize) -> Option<usize> {
let n = scale.normalize(value)?;
Some(((n * levels as f64).floor() as usize).min(levels - 1))
}
fn level_style(console: &Console, level: usize, levels: usize) -> Style {
let key = format!("chart.heat.{}", level * HEAT_STYLES / levels + 1);
theme_style(console, &key)
}
fn lines(&self, console: &Console, options: &ConsoleOptions) -> Vec<Line> {
let width = options.max_width;
let ascii = self.charset.resolve(console, options, Charset::Blocks) == Charset::Ascii;
let colour = has_colour(console);
if self.column_count() == 0 {
let mut line = Line::new();
line.push(&truncate("no data", width, ascii), None);
return vec![line];
}
let shades: &[char] = if ascii { &ASCII_SHADES } else { &SHADES };
let missing = if ascii { '?' } else { '·' };
let levels = shades.len();
let scale = self.scale();
let grid = self.layout(width);
let label_style = colour.then(|| theme_style(console, "chart.label"));
let lead = if grid.label > 0 { grid.label + 1 } else { 0 };
let mut out = Vec::new();
if self.columns.iter().any(|c| !c.is_empty()) {
let plot = grid.buckets.len() * grid.cell;
let mut row = vec![" ".to_string(); plot];
let mut free = 0;
for (i, (start, _)) in grid.buckets.iter().enumerate() {
let Some(header) = self.columns.get(*start) else {
continue;
};
let header = truncate(header, usize::MAX, ascii);
let at = i * grid.cell;
let len = cells(&header);
if at < free || at + len > plot || len == 0 {
continue;
}
for (k, unit) in cell_units(&header).into_iter().enumerate() {
row[at + k] = unit;
}
free = at + len + 1;
}
if row.iter().any(|c| c != " ") {
let mut line = Line::new();
line.pad(lead);
line.push(&row.concat(), label_style.clone());
out.push(line);
}
}
for (label, values) in &self.rows {
let mut line = Line::new();
if grid.label > 0 {
let text = truncate(label, grid.label, ascii);
let pad = grid.label - cells(&text);
line.push(&text, label_style.clone());
line.pad(pad + 1);
}
for &(start, end) in &grid.buckets {
let bucket: Vec<f64> = (start..end)
.filter_map(|i| values.get(i).copied())
.filter(|v| v.is_finite())
.collect();
let value = if bucket.is_empty() {
f64::NAN
} else {
bucket.iter().sum::<f64>() / bucket.len() as f64
};
let (glyph, style) = match Self::level(&scale, value, levels) {
Some(level) => (
shades[level],
colour.then(|| Self::level_style(console, level, levels)),
),
None => (missing, None),
};
line.push(&glyph.to_string().repeat(grid.cell), style);
}
out.push(line);
}
if self.legend {
out.extend(wrap_entries(
self.legend_entries(console, ascii, colour),
width,
));
}
out
}
fn legend_entries(
&self,
console: &Console,
ascii: bool,
colour: bool,
) -> Vec<Vec<(String, Option<Style>)>> {
let shades: &[char] = if ascii { &ASCII_SHADES } else { &SHADES };
let scale = self.scale();
let label_style = colour.then(|| theme_style(console, "chart.label"));
let mut ramp = vec![(
format!("{} [", self.format.format(scale.min())),
label_style.clone(),
)];
for (level, c) in shades.iter().enumerate() {
let style = colour.then(|| Self::level_style(console, level, shades.len()));
ramp.push((c.to_string(), style));
}
ramp.push((
format!("] {}", self.format.format(scale.max())),
label_style.clone(),
));
let mut entries = vec![ramp];
if self.has_missing() {
let missing = if ascii { "?" } else { "·" };
entries.push(vec![(format!("{missing} no data"), label_style)]);
}
entries
}
}
impl crate::surface::Draw for Heatmap {
fn draw(
&self,
console: &Console,
options: &ConsoleOptions,
screen: &mut crate::surface::Screen,
rect: crate::surface::Rect,
) {
super::draw_lines(
self.lines(console, options),
options.max_width,
options,
screen,
rect,
);
}
}
impl Renderable for Heatmap {
fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
lines_to_segments(self.lines(console, options), options.max_width)
}
fn measure(&self, console: &Console, options: &ConsoleOptions) -> Measurement {
let n = self.column_count();
if n == 0 {
return Measurement::new(7, 7).with_maximum(options.max_width);
}
let ascii = self.charset.resolve(console, options, Charset::Blocks) == Charset::Ascii;
let l = self.label_width();
let grid = (l + usize::from(l > 0)).saturating_add(n.saturating_mul(self.cell_width));
let legend = if self.legend {
entries_width(&self.legend_entries(console, ascii, false))
} else {
0
};
let max = grid.max(legend);
Measurement::new(n.min(max), max)
.with_maximum(options.max_width)
.normalize()
}
}
impl crate::a11y::AccessibleText for Heatmap {
fn accessible_text(&self, _width: usize) -> String {
use super::describe::{count, counted, range};
let columns = self.column_count();
if self.rows.is_empty() || columns == 0 {
return "Heatmap, no values".to_string();
}
let mut head = format!(
"Heatmap, {} by {}",
counted(self.rows.len(), "row", "rows"),
counted(columns, "column", "columns")
);
let cells: Vec<(&str, usize, f64)> = self
.rows
.iter()
.flat_map(|(label, values)| {
(0..columns).map(move |c| {
let value = values.get(c).copied().unwrap_or(f64::NAN);
(label.as_str(), c, value)
})
})
.collect();
let finite: Vec<&(&str, usize, f64)> = cells.iter().filter(|c| c.2.is_finite()).collect();
let missing = cells.len() - finite.len();
if let Some((lo, hi)) = super::describe::extent(finite.iter().map(|c| c.2)) {
head.push_str(&format!(", values {}", range(lo, hi, self.format)));
}
if missing > 0 {
head.push_str(&format!(", {} missing", count(missing)));
}
let header = |c: usize| {
self.columns
.get(c)
.filter(|h| !h.is_empty())
.cloned()
.unwrap_or_else(|| format!("column {}", c + 1))
};
let at = |cell: &(&str, usize, f64)| {
let row = if cell.0.is_empty() {
String::new()
} else {
format!("{}, ", cell.0)
};
format!("{} at {row}{}", self.format.format(cell.2), header(cell.1))
};
let mut highest: Option<&(&str, usize, f64)> = None;
let mut lowest: Option<&(&str, usize, f64)> = None;
for &cell in &finite {
if highest.is_none_or(|h| cell.2 > h.2) {
highest = Some(cell);
}
if lowest.is_none_or(|l| cell.2 < l.2) {
lowest = Some(cell);
}
}
let mut out = vec![head];
if let (Some(hi), Some(lo)) = (highest, lowest) {
out.push(format!("Highest {}; lowest {}", at(hi), at(lo)));
}
out.join("\n")
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::a11y::AccessibleText;
#[test]
fn accessible_text_finds_the_extremes() {
let map = Heatmap::new()
.columns(["mon", "tue", "wed", "thu"])
.row("api", [1.0, 4.0, 9.0, 2.0])
.row("web", [0.0, 6.0, 8.0, f64::NAN]);
assert_eq!(
map.accessible_text(80),
"Heatmap, 2 rows by 4 columns, values from 0 to 9, 1 missing\n\
Highest 9 at api, wed; lowest 0 at web, mon"
);
let short = Heatmap::new().row("", [3.0, 3.0]);
assert_eq!(
short.accessible_text(80),
"Heatmap, 1 row by 2 columns, values at 3\n\
Highest 3 at column 1; lowest 3 at column 1"
);
assert_eq!(Heatmap::new().accessible_text(80), "Heatmap, no values");
}
}