1use rich::measure::Measurement;
4use rich::{Console, ConsoleOptions, Renderable, Segment, Style};
5
6use super::{
7 Charset, Line, Scale, ValueFormat, cell_units, cells, entries_width, has_colour,
8 lines_to_segments, theme_style, truncate, wrap_entries,
9};
10
11const SHADES: [char; 5] = [' ', '░', '▒', '▓', '█'];
13const ASCII_SHADES: [char; 10] = [' ', '.', ':', '-', '=', '+', '*', '#', '%', '@'];
15const HEAT_STYLES: usize = 5;
17
18#[derive(Clone, Debug, PartialEq)]
60#[must_use = "builder methods return a new value; it does nothing until it is rendered or used"]
61pub struct Heatmap {
62 columns: Vec<String>,
63 rows: Vec<(String, Vec<f64>)>,
64 min: Option<f64>,
65 max: Option<f64>,
66 charset: Charset,
67 cell_width: usize,
68 legend: bool,
69 format: ValueFormat,
70}
71
72impl Default for Heatmap {
73 fn default() -> Self {
74 Self::new()
75 }
76}
77
78struct Grid {
80 label: usize,
81 cell: usize,
82 buckets: Vec<(usize, usize)>,
84}
85
86impl Heatmap {
87 pub fn new() -> Self {
89 Heatmap {
90 columns: Vec::new(),
91 rows: Vec::new(),
92 min: None,
93 max: None,
94 charset: Charset::Auto,
95 cell_width: 2,
96 legend: true,
97 format: ValueFormat::Compact,
98 }
99 }
100
101 pub fn columns<S: Into<String>>(mut self, headers: impl IntoIterator<Item = S>) -> Self {
103 self.columns = headers.into_iter().map(Into::into).collect();
104 self
105 }
106
107 pub fn row(mut self, label: impl Into<String>, values: impl IntoIterator<Item = f64>) -> Self {
109 self.rows.push((label.into(), values.into_iter().collect()));
110 self
111 }
112
113 pub fn range(mut self, min: f64, max: f64) -> Self {
115 self.min = Some(min);
116 self.max = Some(max);
117 self
118 }
119
120 pub fn charset(mut self, charset: Charset) -> Self {
123 self.charset = charset;
124 self
125 }
126
127 pub fn cell_width(mut self, width: usize) -> Self {
129 self.cell_width = width.max(1);
130 self
131 }
132
133 pub fn legend(mut self, show: bool) -> Self {
135 self.legend = show;
136 self
137 }
138
139 pub fn format(mut self, format: ValueFormat) -> Self {
141 self.format = format;
142 self
143 }
144
145 pub fn scale(&self) -> Scale {
147 let values = self.rows.iter().flat_map(|(_, v)| v.iter().copied());
148 Scale::from_values(values).bounds(self.min, self.max)
149 }
150
151 fn column_count(&self) -> usize {
152 self.rows
153 .iter()
154 .map(|(_, v)| v.len())
155 .max()
156 .unwrap_or(0)
157 .max(self.columns.len())
158 }
159
160 fn label_width(&self) -> usize {
161 self.rows.iter().map(|(l, _)| cells(l)).max().unwrap_or(0)
162 }
163
164 fn has_missing(&self) -> bool {
165 let n = self.column_count();
166 self.rows
167 .iter()
168 .any(|(_, v)| v.len() < n || v.iter().any(|x| !x.is_finite()))
169 }
170
171 fn layout(&self, width: usize) -> Grid {
172 let n = self.column_count();
173 let l = self.label_width();
174 let part = |l: usize| l + usize::from(l > 0);
175 let each = |cell: usize| Grid {
176 label: 0,
177 cell,
178 buckets: (0..n).map(|i| (i, i + 1)).collect(),
179 };
180 let room = width.saturating_sub(part(l));
181 if room >= n {
182 let cell = self.cell_width.min(room / n.max(1)).max(1);
183 return Grid {
184 label: l,
185 ..each(cell)
186 };
187 }
188 let keep = l.min(3);
189 if let Some(label) = width.checked_sub(n + 1)
190 && label >= keep
191 && label > 0
192 {
193 return Grid {
194 label: label.min(l),
195 ..each(1)
196 };
197 }
198 let label = if width > part(keep) + 1 { keep } else { 0 };
200 let slots = width.saturating_sub(part(label)).max(1);
201 let buckets = (0..slots)
202 .map(|i| {
203 let start = i * n / slots;
204 (start, ((i + 1) * n / slots).max(start + 1))
205 })
206 .collect();
207 Grid {
208 label,
209 cell: 1,
210 buckets,
211 }
212 }
213
214 fn level(scale: &Scale, value: f64, levels: usize) -> Option<usize> {
216 let n = scale.normalize(value)?;
217 Some(((n * levels as f64).floor() as usize).min(levels - 1))
218 }
219
220 fn level_style(console: &Console, level: usize, levels: usize) -> Style {
221 let key = format!("chart.heat.{}", level * HEAT_STYLES / levels + 1);
222 theme_style(console, &key)
223 }
224
225 fn lines(&self, console: &Console, options: &ConsoleOptions) -> Vec<Line> {
226 let width = options.max_width;
227 let ascii = self.charset.resolve(console, options, Charset::Blocks) == Charset::Ascii;
228 let colour = has_colour(console);
229 if self.column_count() == 0 {
230 let mut line = Line::new();
231 line.push(&truncate("no data", width, ascii), None);
232 return vec![line];
233 }
234 let shades: &[char] = if ascii { &ASCII_SHADES } else { &SHADES };
235 let missing = if ascii { '?' } else { '·' };
236 let levels = shades.len();
237 let scale = self.scale();
238 let grid = self.layout(width);
239 let label_style = colour.then(|| theme_style(console, "chart.label"));
240 let lead = if grid.label > 0 { grid.label + 1 } else { 0 };
241 let mut out = Vec::new();
242
243 if self.columns.iter().any(|c| !c.is_empty()) {
245 let plot = grid.buckets.len() * grid.cell;
246 let mut row = vec![" ".to_string(); plot];
247 let mut free = 0;
248 for (i, (start, _)) in grid.buckets.iter().enumerate() {
249 let Some(header) = self.columns.get(*start) else {
250 continue;
251 };
252 let header = truncate(header, usize::MAX, ascii);
253 let at = i * grid.cell;
254 let len = cells(&header);
255 if at < free || at + len > plot || len == 0 {
256 continue;
257 }
258 for (k, unit) in cell_units(&header).into_iter().enumerate() {
259 row[at + k] = unit;
260 }
261 free = at + len + 1;
262 }
263 if row.iter().any(|c| c != " ") {
264 let mut line = Line::new();
265 line.pad(lead);
266 line.push(&row.concat(), label_style.clone());
267 out.push(line);
268 }
269 }
270
271 for (label, values) in &self.rows {
272 let mut line = Line::new();
273 if grid.label > 0 {
274 let text = truncate(label, grid.label, ascii);
275 let pad = grid.label - cells(&text);
276 line.push(&text, label_style.clone());
277 line.pad(pad + 1);
278 }
279 for &(start, end) in &grid.buckets {
280 let bucket: Vec<f64> = (start..end)
281 .filter_map(|i| values.get(i).copied())
282 .filter(|v| v.is_finite())
283 .collect();
284 let value = if bucket.is_empty() {
285 f64::NAN
286 } else {
287 bucket.iter().sum::<f64>() / bucket.len() as f64
288 };
289 let (glyph, style) = match Self::level(&scale, value, levels) {
290 Some(level) => (
291 shades[level],
292 colour.then(|| Self::level_style(console, level, levels)),
293 ),
294 None => (missing, None),
295 };
296 line.push(&glyph.to_string().repeat(grid.cell), style);
297 }
298 out.push(line);
299 }
300 if self.legend {
301 out.extend(wrap_entries(
302 self.legend_entries(console, ascii, colour),
303 width,
304 ));
305 }
306 out
307 }
308
309 fn legend_entries(
310 &self,
311 console: &Console,
312 ascii: bool,
313 colour: bool,
314 ) -> Vec<Vec<(String, Option<Style>)>> {
315 let shades: &[char] = if ascii { &ASCII_SHADES } else { &SHADES };
316 let scale = self.scale();
317 let label_style = colour.then(|| theme_style(console, "chart.label"));
318 let mut ramp = vec![(
319 format!("{} [", self.format.format(scale.min())),
320 label_style.clone(),
321 )];
322 for (level, c) in shades.iter().enumerate() {
323 let style = colour.then(|| Self::level_style(console, level, shades.len()));
324 ramp.push((c.to_string(), style));
325 }
326 ramp.push((
327 format!("] {}", self.format.format(scale.max())),
328 label_style.clone(),
329 ));
330 let mut entries = vec![ramp];
331 if self.has_missing() {
332 let missing = if ascii { "?" } else { "·" };
333 entries.push(vec![(format!("{missing} no data"), label_style)]);
334 }
335 entries
336 }
337}
338
339impl crate::surface::Draw for Heatmap {
340 fn draw(
341 &self,
342 console: &Console,
343 options: &ConsoleOptions,
344 screen: &mut crate::surface::Screen,
345 rect: crate::surface::Rect,
346 ) {
347 super::draw_lines(
348 self.lines(console, options),
349 options.max_width,
350 options,
351 screen,
352 rect,
353 );
354 }
355}
356
357impl Renderable for Heatmap {
358 fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
359 lines_to_segments(self.lines(console, options), options.max_width)
360 }
361
362 fn measure(&self, console: &Console, options: &ConsoleOptions) -> Measurement {
363 let n = self.column_count();
364 if n == 0 {
365 return Measurement::new(7, 7).with_maximum(options.max_width);
366 }
367 let ascii = self.charset.resolve(console, options, Charset::Blocks) == Charset::Ascii;
368 let l = self.label_width();
369 let grid = (l + usize::from(l > 0)).saturating_add(n.saturating_mul(self.cell_width));
370 let legend = if self.legend {
371 entries_width(&self.legend_entries(console, ascii, false))
372 } else {
373 0
374 };
375 let max = grid.max(legend);
376 Measurement::new(n.min(max), max)
377 .with_maximum(options.max_width)
378 .normalize()
379 }
380}
381
382impl crate::a11y::AccessibleText for Heatmap {
386 fn accessible_text(&self, _width: usize) -> String {
387 use super::describe::{count, counted, range};
388 let columns = self.column_count();
389 if self.rows.is_empty() || columns == 0 {
390 return "Heatmap, no values".to_string();
391 }
392 let mut head = format!(
393 "Heatmap, {} by {}",
394 counted(self.rows.len(), "row", "rows"),
395 counted(columns, "column", "columns")
396 );
397 let cells: Vec<(&str, usize, f64)> = self
399 .rows
400 .iter()
401 .flat_map(|(label, values)| {
402 (0..columns).map(move |c| {
403 let value = values.get(c).copied().unwrap_or(f64::NAN);
404 (label.as_str(), c, value)
405 })
406 })
407 .collect();
408 let finite: Vec<&(&str, usize, f64)> = cells.iter().filter(|c| c.2.is_finite()).collect();
409 let missing = cells.len() - finite.len();
410 if let Some((lo, hi)) = super::describe::extent(finite.iter().map(|c| c.2)) {
411 head.push_str(&format!(", values {}", range(lo, hi, self.format)));
412 }
413 if missing > 0 {
414 head.push_str(&format!(", {} missing", count(missing)));
415 }
416 let header = |c: usize| {
417 self.columns
418 .get(c)
419 .filter(|h| !h.is_empty())
420 .cloned()
421 .unwrap_or_else(|| format!("column {}", c + 1))
422 };
423 let at = |cell: &(&str, usize, f64)| {
424 let row = if cell.0.is_empty() {
425 String::new()
426 } else {
427 format!("{}, ", cell.0)
428 };
429 format!("{} at {row}{}", self.format.format(cell.2), header(cell.1))
430 };
431 let mut highest: Option<&(&str, usize, f64)> = None;
433 let mut lowest: Option<&(&str, usize, f64)> = None;
434 for &cell in &finite {
435 if highest.is_none_or(|h| cell.2 > h.2) {
436 highest = Some(cell);
437 }
438 if lowest.is_none_or(|l| cell.2 < l.2) {
439 lowest = Some(cell);
440 }
441 }
442 let mut out = vec![head];
443 if let (Some(hi), Some(lo)) = (highest, lowest) {
444 out.push(format!("Highest {}; lowest {}", at(hi), at(lo)));
445 }
446 out.join("\n")
447 }
448}
449
450#[cfg(test)]
451mod tests {
452 use super::*;
453 use crate::a11y::AccessibleText;
454
455 #[test]
456 fn accessible_text_finds_the_extremes() {
457 let map = Heatmap::new()
458 .columns(["mon", "tue", "wed", "thu"])
459 .row("api", [1.0, 4.0, 9.0, 2.0])
460 .row("web", [0.0, 6.0, 8.0, f64::NAN]);
461 assert_eq!(
462 map.accessible_text(80),
463 "Heatmap, 2 rows by 4 columns, values from 0 to 9, 1 missing\n\
464 Highest 9 at api, wed; lowest 0 at web, mon"
465 );
466 let short = Heatmap::new().row("", [3.0, 3.0]);
467 assert_eq!(
468 short.accessible_text(80),
469 "Heatmap, 1 row by 2 columns, values at 3\n\
470 Highest 3 at column 1; lowest 3 at column 1"
471 );
472 assert_eq!(Heatmap::new().accessible_text(80), "Heatmap, no values");
473 }
474}