use rich::measure::Measurement;
use rich::{Console, ConsoleOptions, Renderable, Segment};
use super::{
BarChart, Charset, Chart, Line, LineChart, Scale, Sparkline, ValueFormat, has_colour,
lines_to_segments, theme_style, truncate, wrap_entries,
};
const DEFAULT_MIN_WIDTH: usize = 24;
const DEFAULT_GAP: usize = 2;
const MAX_GAP: usize = u16::MAX as usize;
#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
#[must_use = "builder methods return a new value; it does nothing until it is rendered or used"]
pub enum Facet {
Sparkline(Sparkline),
Bars(BarChart),
Line(LineChart),
Chart(Chart),
}
impl From<Sparkline> for Facet {
fn from(chart: Sparkline) -> Self {
Facet::Sparkline(chart)
}
}
impl From<BarChart> for Facet {
fn from(chart: BarChart) -> Self {
Facet::Bars(chart)
}
}
impl From<LineChart> for Facet {
fn from(chart: LineChart) -> Self {
Facet::Line(chart)
}
}
impl From<Chart> for Facet {
fn from(chart: Chart) -> Self {
Facet::Chart(chart)
}
}
impl Facet {
pub fn value_range(&self) -> Option<(f64, f64)> {
match self {
Facet::Sparkline(c) => c.value_extent(),
Facet::Bars(c) => c.value_extent(),
Facet::Line(c) => c.y_extent(),
Facet::Chart(c) => c.y_extent(),
}
}
pub fn with_range(self, min: f64, max: f64) -> Facet {
match self {
Facet::Sparkline(c) => Facet::Sparkline(c.range(min, max)),
Facet::Bars(c) => Facet::Bars(c.range(min, max)),
Facet::Line(c) => Facet::Line(c.y_range(min, max)),
Facet::Chart(c) => Facet::Chart(c.with_y_bounds(min, max)),
}
}
fn has_axis(&self) -> bool {
matches!(self, Facet::Line(_) | Facet::Chart(_))
}
fn renderable(&self) -> &dyn Renderable {
match self {
Facet::Sparkline(c) => c,
Facet::Bars(c) => c,
Facet::Line(c) => c,
Facet::Chart(c) => c,
}
}
}
impl Renderable for Facet {
fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
self.renderable().rich_render(console, options)
}
fn measure(&self, console: &Console, options: &ConsoleOptions) -> Measurement {
self.renderable().measure(console, options)
}
}
impl crate::a11y::AccessibleText for Facet {
fn accessible_text(&self, width: usize) -> String {
match self {
Facet::Sparkline(c) => c.accessible_text(width),
Facet::Bars(c) => c.accessible_text(width),
Facet::Line(c) => c.accessible_text(width),
Facet::Chart(c) => c.accessible_text(width),
}
}
}
#[derive(Clone, Debug, PartialEq)]
#[must_use = "builder methods return a new value; it does nothing until it is rendered or used"]
pub struct SmallMultiples {
items: Vec<(String, Facet)>,
columns: Option<usize>,
min_width: usize,
gap: usize,
shared: bool,
range: Option<(f64, f64)>,
height: Option<usize>,
note: bool,
charset: Charset,
}
impl Default for SmallMultiples {
fn default() -> Self {
Self::new(Vec::<(String, Facet)>::new())
}
}
impl SmallMultiples {
pub fn new<T: Into<String>, F: Into<Facet>>(items: impl IntoIterator<Item = (T, F)>) -> Self {
SmallMultiples {
items: items
.into_iter()
.map(|(t, f)| (t.into(), f.into()))
.collect(),
columns: None,
min_width: DEFAULT_MIN_WIDTH,
gap: DEFAULT_GAP,
shared: true,
range: None,
height: None,
note: true,
charset: Charset::Auto,
}
}
pub fn push(mut self, title: impl Into<String>, chart: impl Into<Facet>) -> Self {
self.items.push((title.into(), chart.into()));
self
}
pub fn columns(mut self, n: usize) -> Self {
self.columns = Some(n.max(1));
self
}
pub fn min_cell_width(mut self, cells: usize) -> Self {
self.min_width = cells.max(1);
self
}
pub fn gap(mut self, cells: usize) -> Self {
self.gap = cells.min(MAX_GAP);
self
}
pub fn shared_scale(mut self, shared: bool) -> Self {
self.shared = shared;
self
}
pub fn range(mut self, min: f64, max: f64) -> Self {
self.range = Some((min, max));
self
}
pub fn height(mut self, rows: usize) -> Self {
self.height = Some(rows.max(1));
self
}
pub fn note(mut self, show: bool) -> Self {
self.note = show;
self
}
pub fn charset(mut self, charset: Charset) -> Self {
self.charset = charset;
self
}
pub fn items(&self) -> &[(String, Facet)] {
&self.items
}
pub fn columns_at(&self, width: usize) -> usize {
let n = self.items.len().max(1);
let gap = self.gap;
let wanted = self.columns.unwrap_or_else(|| {
(width.saturating_add(gap) / self.min_width.saturating_add(gap)).max(1)
});
let fits = (width.saturating_add(gap) / gap.saturating_add(1)).max(1);
wanted.min(n).min(fits)
}
pub fn scale(&self) -> Option<(f64, f64)> {
if !self.shared {
return None;
}
if let Some((a, b)) = self.range {
let s = Scale::new(a, b);
return Some((s.min(), s.max()));
}
let ends = self
.items
.iter()
.filter_map(|(_, f)| f.value_range())
.flat_map(|(lo, hi)| [lo, hi]);
let (lo, hi) = super::describe::extent(ends)?;
let scale = Scale::new(lo, hi);
let scale = if self.items.iter().any(|(_, f)| f.has_axis()) {
scale.nice(5)
} else {
scale
};
Some((scale.min(), scale.max()))
}
fn facets(&self) -> Vec<Facet> {
let scale = self.scale();
self.items
.iter()
.map(|(_, f)| {
let f = match scale {
Some((lo, hi)) => f.clone().with_range(lo, hi),
None => f.clone(),
};
match f {
Facet::Line(c) if self.note => Facet::Line(c.legend(false)),
f => f,
}
})
.collect()
}
fn legend(
&self,
console: &Console,
options: &ConsoleOptions,
ascii: bool,
) -> Vec<Vec<(String, Option<rich::Style>)>> {
let label_style = has_colour(console).then(|| theme_style(console, "chart.label"));
let mut names: Vec<String> = Vec::new();
let mut entries = Vec::new();
for (_, facet) in &self.items {
let Facet::Line(chart) = facet else {
continue;
};
if !chart.has_legend() {
continue;
}
for (index, series) in chart.all_series().iter().enumerate() {
if index < names.len() || series.name().is_empty() {
continue;
}
while names.len() < index {
names.push(String::new());
}
names.push(series.name().to_string());
let (marker, style) = chart.legend_entry(console, options, index);
entries.push(vec![
(marker.to_string(), style),
(" ".to_string(), None),
(truncate(series.name(), 40, ascii), label_style.clone()),
]);
}
}
entries
}
fn note_lines(&self, console: &Console, options: &ConsoleOptions, ascii: bool) -> Vec<Line> {
if !self.note {
return Vec::new();
}
let mut entries = Vec::new();
if let Some((lo, hi)) = self.scale() {
let format = ValueFormat::Compact;
let style = has_colour(console).then(|| theme_style(console, "chart.axis"));
entries.push(vec![(
format!(
"scale {} to {} in every chart",
format.format(lo),
format.format(hi)
),
style,
)]);
}
entries.extend(self.legend(console, options, ascii));
wrap_entries(entries, options.max_width)
}
fn chart_height(&self, given: Option<usize>, rows: usize, note: usize) -> Option<usize> {
if self.height.is_some() {
return self.height;
}
let given = given?;
let chrome = rows + rows.saturating_sub(1) + note;
Some((given.saturating_sub(chrome) / rows.max(1)).max(1))
}
}
fn to_lines(lines: Vec<Vec<Segment>>) -> Vec<Line> {
lines
.into_iter()
.map(|segments| {
let mut line = Line::new();
for s in segments.into_iter().filter(|s| !s.control) {
line.push(&s.text, s.style);
}
line
})
.collect()
}
impl Renderable for SmallMultiples {
fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
let width = options.max_width;
let ascii = self.charset.resolve(console, options, Charset::Blocks) == Charset::Ascii;
if self.items.is_empty() {
let mut line = Line::new();
line.push(&truncate("no data", width, ascii), None);
return lines_to_segments(vec![line], width);
}
let columns = self.columns_at(width);
let gaps = self.gap.saturating_mul(columns - 1);
let cell = (width.saturating_sub(gaps) / columns).max(1);
let rows = self.items.len().div_ceil(columns);
let note = self.note_lines(console, options, ascii);
let height = self.chart_height(options.height, rows, note.len());
let title_style = has_colour(console).then(|| theme_style(console, "chart.title"));
let facets = self.facets();
let mut out: Vec<Line> = Vec::new();
for (row, chunk) in facets.chunks(columns).enumerate() {
if row > 0 {
out.push(Line::new());
}
let first = row * columns;
let mut blocks: Vec<Vec<Line>> = Vec::new();
for (k, facet) in chunk.iter().enumerate() {
let mut opts = options.update_width(cell);
opts.height = height;
let facet = match (facet, height) {
(Facet::Line(c), Some(h)) => Facet::Line(c.clone().height(h.saturating_sub(2))),
(f, _) => f.clone(),
};
let mut block = vec![{
let mut title = Line::new();
let text = truncate(&self.items[first + k].0, cell, ascii);
title.push(&text, title_style.clone());
title
}];
block.extend(to_lines(console.render_lines(&facet, &opts, false)));
blocks.push(block);
}
let tall = blocks.iter().map(Vec::len).max().unwrap_or(0);
for i in 0..tall {
let mut line = Line::new();
for (k, block) in blocks.iter_mut().enumerate() {
if k > 0 {
line.pad(self.gap);
}
let mut part = block.get_mut(i).map(std::mem::take).unwrap_or_default();
part.crop(cell);
let fill = cell - part.width();
line.append(part);
line.pad(fill);
}
out.push(line);
}
}
out.extend(note);
lines_to_segments(out, width)
}
fn measure(&self, _console: &Console, options: &ConsoleOptions) -> Measurement {
let min = self.min_width.min(options.max_width);
Measurement::new(min, options.max_width)
.with_maximum(options.max_width)
.normalize()
}
}
impl crate::a11y::AccessibleText for SmallMultiples {
fn accessible_text(&self, width: usize) -> String {
if self.items.is_empty() {
return "Small multiples, no charts".to_string();
}
let mut head = format!(
"Small multiples, {}",
super::describe::counted(self.items.len(), "chart", "charts")
);
if let Some((lo, hi)) = self.scale() {
head.push_str(&format!(
" on a shared scale {}",
super::describe::range(lo, hi, ValueFormat::Compact)
));
}
let mut out = vec![head];
for ((title, _), facet) in self.items.iter().zip(self.facets()) {
let text = facet.accessible_text(width).replace('\n', "; ");
if title.is_empty() {
out.push(text);
} else {
out.push(format!("{title}: {text}"));
}
}
out.join("\n")
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::a11y::AccessibleText;
use crate::chart::{Series, SeriesKind};
fn rows(width: usize, r: &dyn Renderable) -> Vec<String> {
Console::builder()
.width(width)
.color_system(None)
.build()
.render_export(r)
.lines()
.map(str::to_string)
.collect()
}
#[test]
fn a_huge_gap_or_width_does_not_overflow() {
let grid = sparks().gap(usize::MAX);
assert_eq!(grid.columns_at(usize::MAX), grid.items().len());
assert_eq!(grid.columns_at(80), 1);
assert_eq!(
sparks().min_cell_width(usize::MAX).columns_at(usize::MAX),
1
);
assert!(!rows(40, &sparks().gap(usize::MAX)).is_empty());
}
fn sparks() -> SmallMultiples {
SmallMultiples::new([
("api", Sparkline::new([10.0, 20.0, 30.0, 40.0])),
("web", Sparkline::new([5.0, 5.0, 10.0, 15.0])),
("worker", Sparkline::new([40.0, 30.0, 20.0, 50.0])),
("cron", Sparkline::new([0.0, 1.0, 0.0, 2.0])),
])
}
#[test]
fn columns_follow_the_width() {
let grid = sparks();
assert_eq!(grid.columns_at(40), 1);
assert_eq!(grid.columns_at(80), 3);
assert_eq!(grid.columns_at(120), 4);
assert_eq!(grid.clone().columns(2).columns_at(120), 2);
assert_eq!(grid.clone().columns(9).columns_at(120), 4);
assert_eq!(grid.clone().min_cell_width(10).columns_at(40), 3);
}
#[test]
fn a_grid_at_40_80_and_120() {
let grid = sparks();
assert_eq!(
rows(40, &grid),
vec![
"api ",
"▂▄▅▇ ",
" ",
"web ",
"▂▂▂▃ ",
" ",
"worker ",
"▇▅▄█ ",
" ",
"cron ",
"▁▁▁▁ ",
"scale 0 to 50 in every chart ",
]
);
assert_eq!(rows(80, &grid).len(), 6);
assert_eq!(
rows(120, &grid),
vec![
format!("{:<30}{:<30}{:<30}{:<28}", "api", "web", "worker", "cron"),
format!("{:<30}{:<30}{:<30}{:<28}", "▂▄▅▇", "▂▂▂▃", "▇▅▄█", "▁▁▁▁"),
format!("{:<118}", "scale 0 to 50 in every chart"),
]
);
}
#[test]
fn the_scale_is_shared_unless_asked_not_to() {
let grid = sparks();
assert_eq!(grid.scale(), Some((0.0, 50.0)));
let own = grid.clone().shared_scale(false);
assert_eq!(own.scale(), None);
assert_eq!(rows(120, &own)[1].split_whitespace().last(), Some("▁▅▁█"));
assert_eq!(grid.clone().range(0.0, 100.0).scale(), Some((0.0, 100.0)));
let mixed = SmallMultiples::new([
("bars", Facet::from(BarChart::new().bar("a", 12.0))),
(
"line",
Facet::from(LineChart::new().series(Series::from_values("p50", [13.0, 47.0]))),
),
]);
assert_eq!(mixed.scale(), Some((0.0, 50.0)));
}
#[test]
fn line_charts_share_one_legend() {
let chart = |ys: [f64; 3]| {
LineChart::new()
.series(Series::from_values("p50", ys))
.series(Series::from_values("p99", ys.map(|y| y * 2.0)).kind(SeriesKind::Scatter))
.charset(Charset::Ascii)
.height(3)
};
let grid =
SmallMultiples::new([("a", chart([1.0, 2.0, 3.0])), ("b", chart([2.0, 2.0, 2.0]))])
.min_cell_width(16);
let out = rows(48, &grid);
assert_eq!(
out.last().map(|l| l.trim_end()),
Some("scale 1 to 6 in every chart * p50 + p99")
);
assert!(
!out[..out.len() - 1].iter().any(|l| l.contains("p50")),
"{out:#?}"
);
}
#[test]
fn a_height_is_shared_among_the_rows() {
let grid = sparks().columns(2).note(false);
let console = Console::builder().width(40).color_system(None).build();
let lines = console.render_lines(&grid, &console.options().update_height(9), false);
assert_eq!(lines.len(), 9);
let fixed = grid.height(2);
assert_eq!(rows(40, &fixed).len(), 2 * 3 + 1);
}
#[test]
fn accessible_text_reads_each_chart() {
let grid = SmallMultiples::new([
("api", Sparkline::new([10.0, 20.0, 30.0, 40.0])),
("cron", Sparkline::new([0.0, 1.0, 0.0, 2.0])),
]);
assert_eq!(
grid.accessible_text(80),
"Small multiples, 2 charts on a shared scale from 0 to 40\n\
api: Sparkline, 4 values: min 10, max 40, last 40, trend up\n\
cron: Sparkline, 4 values: min 0, max 2, last 2, trend up"
);
assert_eq!(
SmallMultiples::default().accessible_text(80),
"Small multiples, no charts"
);
}
}