use rich::measure::Measurement;
use rich::{Console, ConsoleOptions, Renderable, Segment, Style};
use super::{
cell_units, cells, entries_width, has_colour, lines_to_segments, theme_style, truncate,
wrap_entries, Charset, Line, Scale, ValueFormat,
};
const SHADES: [char; 5] = [' ', '░', '▒', '▓', '█'];
const ASCII_SHADES: [char; 10] = [' ', '.', ':', '-', '=', '+', '*', '#', '%', '@'];
const HEAT_STYLES: usize = 5;
#[derive(Clone, Debug, PartialEq)]
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) {
if 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 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()
}
}