use rich::measure::Measurement;
use rich::{Console, ConsoleOptions, HorizontalAlign, Renderable, Segment, Style};
use super::plot_canvas::{CellGrid, Marker, Op, View, is_ascii, raster_into, spec_style};
use super::{Scale, ValueFormat, cell_units, cells, lines_to_segments, series_key};
use crate::fidelity::ascii_text;
mod stacked;
const DEFAULT_HEIGHT: usize = 15;
const MAX_HEIGHT: usize = u16::MAX as usize;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum AxisScale {
#[default]
Linear,
Log,
}
#[derive(Clone, Debug, Default, PartialEq)]
pub enum Labels {
#[default]
None,
Auto(usize),
Values(Vec<f64>),
Text(Vec<String>),
Markup(Vec<String>),
}
#[derive(Clone, Debug, Default, PartialEq)]
#[must_use = "builder methods return a new value; it does nothing until it is rendered or used"]
pub struct Axis {
bounds: Option<[f64; 2]>,
title: Option<String>,
title_style: Option<String>,
labels: Labels,
labels_align: Option<HorizontalAlign>,
scale: AxisScale,
style: Option<String>,
label_format: ValueFormat,
grid: Option<Box<GridLines>>,
}
#[derive(Clone, Debug, Default, PartialEq)]
struct GridLines {
values: Vec<f64>,
style: Option<String>,
marker: Option<Marker>,
}
impl Axis {
pub fn bounds(mut self, min: f64, max: f64) -> Self {
self.bounds = Some([min, max]);
self
}
pub fn title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
pub fn title_style(mut self, style: impl Into<String>) -> Self {
self.title_style = Some(style.into());
self
}
pub fn labels(mut self, labels: Labels) -> Self {
self.labels = labels;
self
}
pub fn labels_align(mut self, align: HorizontalAlign) -> Self {
self.labels_align = Some(align);
self
}
pub fn scale(mut self, scale: AxisScale) -> Self {
self.scale = scale;
self
}
pub fn style(mut self, style: impl Into<String>) -> Self {
self.style = Some(style.into());
self
}
pub fn label_format(mut self, format: ValueFormat) -> Self {
self.label_format = format;
self
}
pub fn grid(mut self, values: impl IntoIterator<Item = f64>) -> Self {
self.grid_lines().values = values.into_iter().collect();
self
}
pub fn grid_style(mut self, style: impl Into<String>) -> Self {
self.grid_lines().style = Some(style.into());
self
}
pub fn grid_marker(mut self, marker: Marker) -> Self {
self.grid_lines().marker = Some(marker);
self
}
fn grid_lines(&mut self) -> &mut GridLines {
self.grid.get_or_insert_with(Default::default)
}
pub fn label_values(&self) -> Vec<f64> {
let scale = self.span(std::iter::empty());
self.values(&scale)
}
fn map(&self, value: f64) -> Option<f64> {
match self.scale {
_ if !value.is_finite() => None,
AxisScale::Linear => Some(value),
AxisScale::Log if value > 0.0 => Some(value.log10()),
AxisScale::Log => None,
}
}
fn span(&self, data: impl Iterator<Item = f64>) -> Scale {
let given = self.bounds.and_then(|[a, b]| {
let (a, b) = (self.map(a)?, self.map(b)?);
Some(Scale::new(a, b))
});
match given {
Some(scale) => scale,
None => Scale::from_values(data.filter_map(|v| self.map(v))),
}
}
fn values(&self, scale: &Scale) -> Vec<f64> {
let inside = |m: f64| {
let slack = scale.span() * 1e-9;
m >= scale.min() - slack && m <= scale.max() + slack
};
match (&self.labels, self.scale) {
(Labels::Values(values), _) => values
.iter()
.copied()
.filter(|v| self.map(*v).is_some_and(inside))
.collect(),
(Labels::Auto(0), _) => Vec::new(),
(Labels::Auto(n), AxisScale::Linear) => {
scale.ticks(*n).into_iter().filter(|v| inside(*v)).collect()
}
(Labels::Auto(n), AxisScale::Log) => log_ticks(scale, *n),
_ => Vec::new(),
}
}
fn resolve(&self, data: impl Iterator<Item = f64>, console: &Console, ascii: bool) -> Resolved {
let scale = self.span(data);
let text = |s: &str| {
if ascii && !s.is_ascii() {
ascii_text(s)
} else {
s.to_string()
}
};
let plain = |s: &str| Label::plain(text(s));
let labels = match &self.labels {
Labels::None => Placed::None,
Labels::Text(labels) => Placed::Even(labels.iter().map(|l| plain(l)).collect()),
Labels::Markup(labels) => Placed::Even(
labels
.iter()
.map(|l| Label::markup(console, l, ascii))
.collect(),
),
_ => Placed::At(
self.values(&scale)
.into_iter()
.filter_map(|v| Some((self.map(v)?, plain(&self.label_format.format(v)))))
.collect(),
),
};
Resolved {
scale,
labels,
title: self.title.as_deref().map(text).filter(|t| !t.is_empty()),
}
}
}
fn log_ticks(scale: &Scale, count: usize) -> Vec<f64> {
let (lo, hi) = (scale.min(), scale.max());
let slack = (hi - lo) * 1e-9;
let inside = |v: f64| (lo - slack..=hi + slack).contains(&v.log10());
let (first, last) = (lo.floor() as i32, hi.ceil() as i32);
let mut values: Vec<f64> = (first..=last)
.map(|k| 10f64.powi(k))
.filter(|v| inside(*v))
.collect();
if values.len() < 2 {
values = (first..=last)
.flat_map(|k| [1.0, 2.0, 5.0].map(|m| m * 10f64.powi(k)))
.filter(|v| inside(*v))
.collect();
}
let count = count.max(2);
if values.len() > count {
let step = values.len().div_ceil(count);
values = values.into_iter().step_by(step).collect();
}
values
}
struct Resolved {
scale: Scale,
labels: Placed,
title: Option<String>,
}
enum Placed {
None,
At(Vec<(f64, Label)>),
Even(Vec<Label>),
}
#[derive(Clone, Debug, Default)]
struct Label {
runs: Vec<(String, Option<Style>)>,
}
impl Label {
fn plain(text: String) -> Label {
Label {
runs: vec![(text, None)],
}
}
fn markup(console: &Console, markup: &str, ascii: bool) -> Label {
let text = rich::Text::from_markup(markup).unwrap_or_else(|_| rich::Text::new(markup));
let mut options = console.options().update_width(cells(text.plain()).max(1));
options.no_wrap = Some(true);
let mut runs = Vec::new();
for segment in console.render(&text, Some(&options)) {
if segment.control || segment.text.is_empty() {
continue;
}
if segment.text.contains('\n') {
break;
}
let run = if ascii && !segment.text.is_ascii() {
ascii_text(&segment.text)
} else {
segment.text.into_string()
};
runs.push((run, segment.style));
}
Label { runs }
}
fn width(&self) -> usize {
self.runs.iter().map(|(text, _)| cells(text)).sum()
}
}
fn over(base: &Option<Style>, own: &Option<Style>) -> Option<Style> {
match (base, own) {
(_, None) => base.clone(),
(None, Some(own)) => Some(own.clone()),
(Some(base), Some(own)) => Some(base.combine(own)),
}
}
impl Resolved {
fn grid(&self, axis: &Axis) -> Vec<f64> {
let (lo, hi) = (self.scale.min(), self.scale.max());
let slack = (hi - lo).abs() * 1e-9;
axis.grid
.iter()
.flat_map(|lines| lines.values.iter())
.filter_map(|v| axis.map(*v))
.filter(|m| lo.min(hi) - slack <= *m && *m <= lo.max(hi) + slack)
.collect()
}
fn has_labels(&self) -> bool {
!matches!(self.labels, Placed::None)
}
fn texts(&self) -> Vec<&Label> {
match &self.labels {
Placed::None => Vec::new(),
Placed::At(labels) => labels.iter().map(|(_, l)| l).collect(),
Placed::Even(labels) => labels.iter().collect(),
}
}
fn cell(&self, mapped: f64, cells: usize, per: usize) -> usize {
let t = self.scale.normalize(mapped).unwrap_or(0.0);
let dots = (cells * per).saturating_sub(1);
((t * dots as f64) as usize / per).min(cells.saturating_sub(1))
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum GraphType {
#[default]
Scatter,
Line,
Bar,
Area,
}
#[derive(Clone, Debug, PartialEq)]
#[must_use = "builder methods return a new value; it does nothing until it is rendered or used"]
pub struct Dataset {
name: String,
points: Vec<(f64, f64)>,
graph_type: GraphType,
marker: Marker,
style: Option<String>,
fill_to: f64,
}
impl Dataset {
pub fn new(points: impl IntoIterator<Item = (f64, f64)>) -> Self {
Dataset {
name: String::new(),
points: points.into_iter().collect(),
graph_type: GraphType::Scatter,
marker: Marker::Dot,
style: None,
fill_to: 0.0,
}
}
pub fn name(mut self, name: impl Into<String>) -> Self {
self.name = name.into();
self
}
pub fn graph_type(mut self, graph_type: GraphType) -> Self {
self.graph_type = graph_type;
self
}
pub fn marker(mut self, marker: Marker) -> Self {
self.marker = marker;
self
}
pub fn style(mut self, style: impl Into<String>) -> Self {
self.style = Some(style.into());
self
}
pub fn fill_to(mut self, y: f64) -> Self {
self.fill_to = y;
self
}
pub fn points(&self) -> &[(f64, f64)] {
&self.points
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum LegendPosition {
#[default]
TopRight,
Top,
TopLeft,
Left,
Right,
BottomLeft,
Bottom,
BottomRight,
None,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum OutOfBounds {
#[default]
Clip,
Drop,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum Layering {
#[default]
Merge,
PerDataset,
}
#[derive(Clone, Debug, PartialEq)]
#[must_use = "builder methods return a new value; it does nothing until it is rendered or used"]
pub struct Chart {
datasets: Vec<Dataset>,
x_axis: Axis,
y_axis: Axis,
legend: LegendPosition,
hidden_legend: (f64, f64),
legend_style: Option<Box<str>>,
style: Option<Box<str>>,
legend_clear: bool,
out_of_bounds: OutOfBounds,
layering: Layering,
marker: Option<Marker>,
width: Option<usize>,
height: Option<usize>,
stacked: bool,
}
impl Default for Chart {
fn default() -> Self {
Self::new(Vec::new())
}
}
impl Chart {
pub fn new(datasets: impl IntoIterator<Item = Dataset>) -> Self {
Chart {
datasets: datasets.into_iter().collect(),
x_axis: Axis::default(),
y_axis: Axis::default(),
legend: LegendPosition::TopRight,
hidden_legend: (0.25, 0.25),
legend_style: None,
style: None,
legend_clear: true,
out_of_bounds: OutOfBounds::Clip,
layering: Layering::Merge,
marker: None,
width: None,
height: None,
stacked: false,
}
}
pub fn dataset(mut self, dataset: Dataset) -> Self {
self.datasets.push(dataset);
self
}
pub fn datasets(&self) -> &[Dataset] {
&self.datasets
}
pub fn x_axis(mut self, axis: Axis) -> Self {
self.x_axis = axis;
self
}
pub fn y_axis(mut self, axis: Axis) -> Self {
self.y_axis = axis;
self
}
pub fn legend(mut self, position: LegendPosition) -> Self {
self.legend = position;
self
}
pub fn hidden_legend_fraction(mut self, width: f64, height: f64) -> Self {
let share = |v: f64| if v.is_nan() { 0.25 } else { v.clamp(0.0, 1.0) };
self.hidden_legend = (share(width), share(height));
self
}
pub fn style(mut self, style: impl Into<String>) -> Self {
self.style = Some(style.into().into_boxed_str());
self
}
pub fn legend_style(mut self, style: impl Into<String>) -> Self {
self.legend_style = Some(style.into().into_boxed_str());
self
}
pub fn legend_unstyled(self) -> Self {
self.legend_style("none")
}
pub fn legend_clear(mut self, clear: bool) -> Self {
self.legend_clear = clear;
self
}
pub fn out_of_bounds(mut self, out_of_bounds: OutOfBounds) -> Self {
self.out_of_bounds = out_of_bounds;
self
}
pub fn layering(mut self, layering: Layering) -> Self {
self.layering = layering;
self
}
pub fn marker(mut self, marker: Marker) -> Self {
self.marker = Some(marker);
self
}
pub fn width(mut self, width: usize) -> Self {
self.width = Some(width);
self
}
pub fn height(mut self, rows: usize) -> Self {
self.height = Some(rows.min(MAX_HEIGHT));
self
}
fn grid(&self, console: &Console, options: &ConsoleOptions) -> CellGrid {
let w = self
.width
.map_or(options.max_width, |v| v.min(options.max_width))
.min(MAX_HEIGHT);
let h = self
.height
.or(options.height)
.unwrap_or(DEFAULT_HEIGHT)
.min(MAX_HEIGHT);
let mut grid = CellGrid::new(w, h);
if w == 0 || h == 0 {
return grid;
}
let ascii = is_ascii(console, options);
let points = || self.datasets.iter().flat_map(|d| d.points.iter());
let x = self.x_axis.resolve(points().map(|p| p.0), console, ascii);
let y = self.y_axis.resolve(points().map(|p| p.1), console, ascii);
let style_of = |spec: &Option<String>, default: &str| {
spec_style(console, spec.as_deref().unwrap_or(default))
};
let x_style = style_of(&self.x_axis.style, "chart.axis");
let y_style = style_of(&self.y_axis.style, "chart.axis");
let (h_line, v_line, corner) = if ascii {
('-', '|', '+')
} else {
('─', '│', '└')
};
let mut bottom = h - 1;
let label_x = (!x.texts().is_empty() && bottom > 0).then(|| {
bottom -= 1;
bottom + 1
});
let x_align = self.x_axis.labels_align;
let mut left = left_width(w, &x, &y, x_align);
let axis_x = (x.has_labels() && bottom > 0).then(|| {
bottom -= 1;
bottom + 1
});
let axis_y = (y.has_labels() && left + 1 < w).then(|| {
left += 1;
left - 1
});
let (gx, gw, gh) = (left, w - left, bottom + 1);
let marker = self
.datasets
.first()
.map_or(Marker::Dot, |d| d.marker)
.resolve(ascii);
let (per_x, per_y) = marker.per_cell();
if let Some(row) = label_x {
match &x.labels {
Placed::At(labels) => {
let mut free = 0;
for (value, label) in labels {
let len = label.width();
if len > w {
continue;
}
let tick = gx + x.cell(*value, gw, per_x);
let start = match x_align {
Some(HorizontalAlign::Left) => tick,
Some(HorizontalAlign::Right) => (tick + 1).saturating_sub(len),
_ => tick.saturating_sub(len / 2),
}
.min(w - len);
if start < free {
continue;
}
put_runs(&mut grid, row, start, w, label, &x_style);
free = start + len + 1;
}
}
Placed::Even(labels) if labels.len() >= 2 => {
let n = labels.len();
let between = gw / n;
let put = |grid: &mut CellGrid, col, width, label: &Label, align| {
put_aligned(grid, row, col, width, label, &x_style, align);
};
let (col, width, align) = match x_align {
Some(HorizontalAlign::Center) => {
(0, gx + between.min(labels[0].width()), Align::Centre)
}
Some(HorizontalAlign::Right) => {
(gx.saturating_sub(1), between + 1, Align::Left)
}
_ => (0, gx, Align::Right),
};
put(&mut grid, col, width, &labels[0], align);
for (i, label) in labels[1..n - 1].iter().enumerate() {
let col = gx + (i + 1) * between + 1;
put(
&mut grid,
col,
between.saturating_sub(1),
label,
Align::Centre,
);
}
put(
&mut grid,
w - between,
between,
&labels[n - 1],
Align::Right,
);
}
_ => {}
}
}
if y.has_labels() {
let width = gx.saturating_sub(1);
let align = self.y_axis.labels_align.map(Align::from);
match &y.labels {
Placed::At(labels) => {
let mut used = vec![false; gh];
for (value, label) in labels {
let row = gh - 1 - y.cell(*value, gh, per_y);
if !std::mem::replace(&mut used[row], true) {
let align = align.unwrap_or(Align::Right);
put_aligned(&mut grid, row, 0, width, label, &y_style, align);
}
}
}
Placed::Even(labels) => {
let n = labels.len();
for (i, label) in labels.iter().enumerate() {
let dy = if n > 1 { i * (gh - 1) / (n - 1) } else { 0 };
if dy < gh {
let row = gh - 1 - dy;
let align = align.unwrap_or(Align::Left);
put_aligned(&mut grid, row, 0, width, label, &y_style, align);
}
}
}
Placed::None => {}
}
}
let (x_id, y_id) = (grid.intern(x_style.as_ref()), grid.intern(y_style.as_ref()));
if let Some(row) = axis_x {
for col in gx..w {
grid.set_char(row, col, h_line, x_id);
}
}
if let Some(col) = axis_y {
for row in 0..gh {
grid.set_char(row, col, v_line, y_id);
}
if let Some(row) = axis_x {
grid.set_char(row, col, corner, x_id);
}
}
let view = View {
x: [x.scale.min(), x.scale.max()],
y: [y.scale.min(), y.scale.max()],
width: gw,
height: gh,
marker,
ascii,
};
raster_into(&self.ops(&x, &y), &view, console, &mut grid, gx);
let x_title = x.title.as_ref().map(|t| (t, cells(t)));
let x_title = x_title
.filter(|(_, tw)| *tw < gw && gh > 2)
.map(|(title, tw)| {
let col = gx + gw - tw;
let style = style_of(&self.x_axis.title_style, "chart.label");
grid.put_text(gh - 1, col, title, style, w);
(gh - 1, col, col + tw)
});
let y_title = y.title.as_ref().map(|t| (t, cells(t)));
let y_title = y_title
.filter(|(_, tw)| tw + 1 < gw && gh > 2)
.map(|(title, tw)| {
let style = style_of(&self.y_axis.title_style, "chart.label");
grid.put_text(0, gx, title, style, w);
(0, gx, gx + tw)
});
let named: Vec<(String, Option<Style>)> = self
.datasets
.iter()
.enumerate()
.filter(|(_, d)| !d.name.is_empty())
.map(|(i, d)| {
let name = if ascii && !d.name.is_ascii() {
ascii_text(&d.name)
} else {
d.name.clone()
};
(name, self.dataset_style(console, i))
})
.collect();
let inner = named.iter().map(|(n, _)| cells(n)).max().unwrap_or(0);
let (lw, lh) = (inner + 2, named.len() + 2);
let max_w = (gw as f64 * self.hidden_legend.0).round() as usize;
let max_h = (gh as f64 * self.hidden_legend.1).round() as usize;
if inner > 0 && lw <= max_w && lh <= max_h {
let area = Area { gx, gw, gh };
if let Some((top, col)) = area.legend(self.legend, lw, lh, x_title, y_title) {
let border = if ascii {
['+', '+', '+', '+', '-', '|']
} else {
['┌', '┐', '└', '┘', '─', '│']
};
let style = match (self.legend_style.as_deref(), self.style.as_deref()) {
(Some(spec), _) | (None, Some(spec)) => spec_style(console, spec),
(None, None) => x_style.clone(),
};
let box_ = LegendBox {
at: (top, col),
inner,
border,
style,
clear: self.legend_clear,
};
box_.draw(&mut grid, &named);
}
}
if let Some(style) = self.style.as_deref().and_then(|s| spec_style(console, s)) {
grid.underlay(&style);
}
grid
}
fn dataset_style(&self, console: &Console, index: usize) -> Option<Style> {
spec_style(console, &self.dataset_spec(index))
}
fn dataset_spec(&self, index: usize) -> String {
self.datasets[index]
.style
.clone()
.unwrap_or_else(|| series_key(index))
}
fn ops(&self, x: &Resolved, y: &Resolved) -> Vec<Op> {
let within = |v: f64, s: &Scale| {
let (a, b) = (s.min(), s.max());
a.min(b) <= v && v <= a.max(b)
};
let inside = |(px, py): (f64, f64)| within(px, &x.scale) && within(py, &y.scale);
let keep = |a: (f64, f64), b: (f64, f64)| {
self.out_of_bounds == OutOfBounds::Clip || (inside(a) && inside(b))
};
let mut ops = Vec::new();
let fallback = self
.marker
.or(self.datasets.first().map(|d| d.marker))
.unwrap_or(Marker::Braille);
let lines = |axis: &Axis| {
let grid = axis.grid.as_deref();
let spec = grid
.and_then(|g| g.style.clone())
.or_else(|| axis.style.clone())
.unwrap_or_else(|| "chart.axis".into());
let marker = grid.and_then(|g| g.marker).unwrap_or(fallback);
(spec, marker)
};
let (left, right) = (x.scale.min(), x.scale.max());
let (bottom, top) = (y.scale.min(), y.scale.max());
let (y_spec, y_marker) = lines(&self.y_axis);
let y_spec: super::plot_canvas::Spec = y_spec.into();
let (x_spec, x_marker) = lines(&self.x_axis);
let x_spec: super::plot_canvas::Spec = x_spec.into();
let across = y.grid(&self.y_axis).into_iter().map(|at| {
let line = Op::Line((left, at), (right, at), y_spec.clone());
(line, y_marker)
});
let up = x.grid(&self.x_axis).into_iter().map(|at| {
let line = Op::Line((at, bottom), (at, top), x_spec.clone());
(line, x_marker)
});
let mut marker = None;
for (line, line_marker) in across.chain(up) {
if marker != Some(line_marker) {
ops.push(Op::Marker(line_marker));
marker = Some(line_marker);
}
ops.push(line);
if self.layering == Layering::PerDataset {
ops.push(Op::Layer);
}
}
for (nth, index) in self.draw_order().into_iter().enumerate() {
let dataset = &self.datasets[index];
if nth > 0 && self.layering == Layering::PerDataset {
ops.push(Op::Layer);
}
let style: super::plot_canvas::Spec = self.dataset_spec(index).into();
let mapped: Vec<Option<(f64, f64)>> = dataset
.points
.iter()
.map(|&(px, py)| Some((self.x_axis.map(px)?, self.y_axis.map(py)?)))
.collect();
let shown = || mapped.iter().flatten().copied();
ops.push(Op::Marker(dataset.marker));
ops.push(Op::Points(shown().collect(), style.clone()));
match dataset.graph_type {
GraphType::Scatter => {}
GraphType::Line => {
for pair in mapped.windows(2) {
if let [Some(a), Some(b)] = pair
&& keep(*a, *b)
{
ops.push(Op::Line(*a, *b, style.clone()));
}
}
}
GraphType::Bar => {
let base = match self.y_axis.scale {
AxisScale::Linear => 0.0,
AxisScale::Log => y.scale.min(),
};
for (px, py) in shown().filter(|&p| keep((p.0, base), p)) {
ops.push(Op::Line((px, base), (px, py), style.clone()));
}
}
GraphType::Area => {
let level = self.y_axis.map(dataset.fill_to).unwrap_or(y.scale.min());
for pair in mapped.windows(2) {
if let [Some(a), Some(b)] = pair
&& keep(*a, *b)
{
ops.push(Op::Area(*a, *b, level, style.clone()));
}
}
}
}
}
ops
}
}
fn left_width(width: usize, x: &Resolved, y: &Resolved, x_align: Option<HorizontalAlign>) -> usize {
let mut widest = y.texts().iter().map(|l| l.width()).max().unwrap_or(0);
if let Some(first) = x.texts().first() {
let first = first.width();
let hangs = match (&x.labels, x_align) {
(Placed::Even(_), None | Some(HorizontalAlign::Left)) => {
first.saturating_sub(usize::from(y.has_labels()))
}
(Placed::Even(_), Some(HorizontalAlign::Right)) => 0,
(_, Some(HorizontalAlign::Left)) => 0,
(_, Some(HorizontalAlign::Right)) => first.saturating_sub(1),
_ => first / 2,
};
widest = widest.max(hangs);
}
widest.min(width / 3)
}
#[derive(Clone, Copy)]
enum Align {
Left,
Centre,
Right,
}
impl From<HorizontalAlign> for Align {
fn from(align: HorizontalAlign) -> Align {
match align {
HorizontalAlign::Left => Align::Left,
HorizontalAlign::Center => Align::Centre,
HorizontalAlign::Right => Align::Right,
}
}
}
fn put_runs(
grid: &mut CellGrid,
row: usize,
col: usize,
end: usize,
label: &Label,
base: &Option<Style>,
) {
let mut at = col;
for (text, style) in &label.runs {
if at >= end {
break;
}
grid.put_text(row, at, text, over(base, style), end);
at += cells(text);
}
}
fn put_aligned(
grid: &mut CellGrid,
row: usize,
col: usize,
width: usize,
label: &Label,
base: &Option<Style>,
align: Align,
) {
if width == 0 {
return;
}
let len = label.width();
let end = col + width;
if len <= width {
let offset = match align {
Align::Left => 0,
Align::Centre => (width - len) / 2,
Align::Right => width - len,
};
put_runs(grid, row, col + offset, end, label, base);
} else if let Align::Right = align {
let units: Vec<(String, Option<Style>)> = label
.runs
.iter()
.flat_map(|(text, style)| {
let style = over(base, style);
cell_units(text)
.into_iter()
.map(move |u| (u, style.clone()))
})
.collect();
for (i, (unit, style)) in units[len - width..].iter().enumerate() {
let unit = if i == 0 && unit.is_empty() { " " } else { unit };
grid.set(row, col + i, unit.to_string(), style.clone());
}
} else {
put_runs(grid, row, col, end, label, base);
}
}
struct Area {
gx: usize,
gw: usize,
gh: usize,
}
type TitleSpan = (usize, usize, usize);
impl Area {
fn legend(
&self,
position: LegendPosition,
lw: usize,
lh: usize,
x_title: Option<TitleSpan>,
y_title: Option<TitleSpan>,
) -> Option<(usize, usize)> {
self.gh
.checked_sub(lh + usize::from(x_title.is_some()) + usize::from(y_title.is_some()))?;
if lw > self.gw {
return None;
}
let (right, centre) = (self.gx + self.gw - lw, self.gx + (self.gw - lw) / 2);
let (middle, bottom) = ((self.gh - lh) / 2, self.gh - lh);
let (mut row, col) = match position {
LegendPosition::TopLeft => (0, self.gx),
LegendPosition::Top => (0, centre),
LegendPosition::TopRight => (0, right),
LegendPosition::Left => (middle, self.gx),
LegendPosition::Right => (middle, right),
LegendPosition::BottomLeft => (bottom, self.gx),
LegendPosition::Bottom => (bottom, centre),
LegendPosition::BottomRight => (bottom, right),
LegendPosition::None => return None,
};
let covers = |row: usize, (r, c0, c1): TitleSpan| {
row <= r && r < row + lh && col < c1 && c0 < col + lw
};
if y_title.is_some_and(|t| covers(row, t)) {
row += 1;
}
if x_title.is_some_and(|t| covers(row, t)) {
row = row.saturating_sub(1);
}
Some((row, col))
}
}
struct LegendBox {
at: (usize, usize),
inner: usize,
border: [char; 6],
style: Option<Style>,
clear: bool,
}
impl LegendBox {
fn draw(&self, grid: &mut CellGrid, named: &[(String, Option<Style>)]) {
let ((top, left), inner, border) = (self.at, self.inner, &self.style);
let [tl, tr, bl, br, h, v] = self.border;
let edge = |a: char, b: char| format!("{a}{}{b}", h.to_string().repeat(inner));
let right = left + inner + 2;
grid.put_text(top, left, &edge(tl, tr), border.clone(), right);
for (i, (name, style)) in named.iter().enumerate() {
let row = top + 1 + i;
grid.set(row, left, v.to_string(), border.clone());
for col in left + 1..right - 1 {
if self.clear {
grid.set(row, col, " ".to_string(), None);
} else {
grid.restyle(row, col, None);
}
}
grid.put_text(row, left + 1, name, style.clone(), right - 1);
grid.set(row, right - 1, v.to_string(), border.clone());
}
let bottom = top + named.len() + 1;
grid.put_text(bottom, left, &edge(bl, br), border.clone(), right);
}
}
impl Axis {
fn describe(&self, name: &str, data: impl Iterator<Item = f64>) -> String {
let mut out = name.to_string();
if let Some(title) = self.title.as_deref().filter(|t| !t.is_empty()) {
out.push_str(&format!(" ({title})"));
}
let shown = data.filter(|v| self.map(*v).is_some());
let span = match self.bounds {
Some([a, b]) => Some((a.min(b), a.max(b))),
None => super::describe::extent(shown),
};
if let Some((lo, hi)) = span {
out.push(' ');
out.push_str(&super::describe::range(lo, hi, self.label_format));
}
if self.scale == AxisScale::Log {
out.push_str(", log scale");
}
out
}
}
impl crate::a11y::AccessibleText for Chart {
fn accessible_text(&self, _width: usize) -> String {
use super::describe::{counted, series};
if self.datasets.is_empty() {
return "Chart, no datasets".to_string();
}
let points = || self.datasets.iter().flat_map(|d| d.points.iter());
let view = self.stacked_view();
let stacked_points = || view.datasets.iter().flat_map(|d| d.points.iter());
let mut out = vec![format!(
"Chart, {}, {}, {}{}",
counted(self.datasets.len(), "dataset", "datasets"),
self.x_axis.describe("x", points().map(|p| p.0)),
view.y_axis.describe("y", stacked_points().map(|p| p.1)),
if self.stacks() { ", areas stacked" } else { "" },
)];
for (index, d) in self.datasets.iter().enumerate() {
let name = if d.name.is_empty() {
format!("dataset {}", index + 1)
} else {
d.name.clone()
};
let kind = match d.graph_type {
GraphType::Scatter => "points",
GraphType::Line => "line",
GraphType::Bar => "bars",
GraphType::Area => "area",
};
let facts = series(&d.points, self.y_axis.label_format, ("point", "points"));
out.push(format!("{name}, {kind}: {facts}"));
}
out.join("\n")
}
}
impl Chart {
pub(crate) fn y_extent(&self) -> Option<(f64, f64)> {
if let Some([a, b]) = self.y_axis.bounds {
return Some((a.min(b), a.max(b)));
}
let view = self.stacked_view();
let ys = view
.datasets
.iter()
.flat_map(|d| d.points.iter().map(|p| p.1));
super::describe::extent(ys.filter(|v| self.y_axis.map(*v).is_some()))
}
pub(crate) fn with_y_bounds(mut self, min: f64, max: f64) -> Self {
self.y_axis.bounds = Some([min, max]);
self
}
}
impl crate::surface::Draw for Chart {
fn draw(
&self,
console: &Console,
options: &ConsoleOptions,
screen: &mut crate::surface::Screen,
rect: crate::surface::Rect,
) {
let view = self.stacked_view();
super::draw_grid(view.grid(console, options), options, screen, rect);
}
}
impl Renderable for Chart {
fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
let view = self.stacked_view();
lines_to_segments(view.grid(console, options).into_lines(), options.max_width)
}
fn measure(&self, _console: &Console, options: &ConsoleOptions) -> Measurement {
let max = self.width.unwrap_or(options.max_width);
Measurement::new(max.min(12), max)
.with_maximum(options.max_width)
.normalize()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn accessible_text_names_the_axes_and_each_dataset() {
use crate::a11y::AccessibleText;
let cpu = Dataset::new([(0.0, 0.0), (5.0, 4.0), (10.0, 1.0)])
.name("cpu")
.graph_type(GraphType::Line);
let chart = Chart::new(vec![cpu, Dataset::new([(1.0, 2.0)])])
.x_axis(Axis::default().bounds(0.0, 10.0).title("t"))
.y_axis(Axis::default().bounds(0.0, 4.0).title("%"));
assert_eq!(
chart.accessible_text(80),
"Chart, 2 datasets, x (t) from 0 to 10, y (%) from 0 to 4\n\
cpu, line: 3 points, min 0, max 4, last 1, trend up\n\
dataset 2, points: 1 point, min 2, max 2, last 2"
);
let log = Chart::new(vec![
Dataset::new([(1.0, 1.0), (2.0, 100.0)])
.name("hz")
.graph_type(GraphType::Area),
])
.y_axis(Axis::default().scale(AxisScale::Log));
assert_eq!(
log.accessible_text(80),
"Chart, 1 dataset, x from 1 to 2, y from 1 to 100, log scale\n\
hz, area: 2 points, min 1, max 100, last 100, trend up"
);
assert_eq!(Chart::default().accessible_text(80), "Chart, no datasets");
}
fn rows(chart: &Chart, width: usize) -> Vec<String> {
let console = Console::builder().width(width).color_system(None).build();
console
.render_export(chart)
.lines()
.map(str::to_string)
.collect()
}
fn dot(points: &[(f64, f64)]) -> Dataset {
Dataset::new(points.to_vec()).marker(Marker::Ascii('o'))
}
#[test]
fn log_axes_map_by_powers_of_ten() {
let axis = Axis::default().bounds(1.0, 1000.0).scale(AxisScale::Log);
assert_eq!(axis.map(100.0), Some(2.0));
assert_eq!(axis.map(0.0), None);
assert_eq!(axis.map(-5.0), None);
assert_eq!(axis.map(f64::NAN), None);
let labels = |axis: Axis| axis.label_values();
assert_eq!(
labels(axis.clone().labels(Labels::Auto(4))),
vec![1.0, 10.0, 100.0, 1000.0]
);
assert_eq!(
labels(axis.clone().labels(Labels::Auto(2))),
vec![1.0, 100.0]
);
let narrow = Axis::default()
.bounds(2.0, 60.0)
.scale(AxisScale::Log)
.labels(Labels::Auto(6));
assert_eq!(labels(narrow), vec![2.0, 5.0, 10.0, 20.0, 50.0]);
let given = axis.labels(Labels::Values(vec![-1.0, 0.0, 5.0, 5000.0]));
assert_eq!(labels(given), vec![5.0]);
}
#[test]
fn a_log_axis_places_points_by_decade() {
let chart = Chart::new(vec![dot(&[
(1.0, 0.0),
(10.0, 0.0),
(100.0, 0.0),
(0.0, 0.0),
])])
.x_axis(
Axis::default()
.bounds(1.0, 100.0)
.scale(AxisScale::Log)
.labels(Labels::Auto(3)),
)
.y_axis(Axis::default().bounds(0.0, 1.0))
.height(3);
assert_eq!(
rows(&chart, 11),
vec![" ", "o o o", "───────────", "1 10 100"][1..].to_vec()
);
}
#[test]
fn text_labels_are_spread_as_ratatui_spreads_them() {
let chart = Chart::new(Vec::new())
.x_axis(Axis::default().labels(Labels::Text(vec!["a".into(), "b".into(), "c".into()])))
.y_axis(Axis::default().labels(Labels::Text(vec!["lo".into(), "hi".into()])))
.height(5);
assert_eq!(
rows(&chart, 14),
vec![
"hi│ ",
" │ ",
"lo│ ",
" └───────────",
" a b c",
]
);
}
#[test]
fn bars_rise_from_zero() {
let chart = Chart::new(vec![
dot(&[(0.0, 2.0), (2.0, 4.0), (4.0, -1.0)]).graph_type(GraphType::Bar),
])
.x_axis(Axis::default().bounds(0.0, 4.0))
.y_axis(Axis::default().bounds(-1.0, 4.0))
.height(6)
.width(5);
assert_eq!(
rows(&chart, 5),
vec![" o ", " o ", "o o ", "o o ", "o o o", " o"]
);
}
#[test]
fn bars_on_a_log_axis_rise_from_the_bottom() {
let chart = Chart::new(vec![dot(&[(1.0, 100.0)]).graph_type(GraphType::Bar)])
.x_axis(Axis::default().bounds(0.0, 2.0))
.y_axis(Axis::default().bounds(1.0, 1000.0).scale(AxisScale::Log))
.height(4)
.width(3);
assert_eq!(rows(&chart, 3), vec![" ", " o ", " o ", " o "]);
}
#[test]
fn lines_break_at_points_the_axes_cannot_show() {
let chart = Chart::new(vec![
dot(&[
(0.0, 0.0),
(1.0, 0.0),
(f64::NAN, 0.0),
(3.0, f64::INFINITY),
(4.0, 0.0),
(5.0, 0.0),
])
.graph_type(GraphType::Line),
])
.x_axis(Axis::default().bounds(0.0, 5.0))
.y_axis(Axis::default().bounds(0.0, 1.0))
.height(1)
.width(6);
assert_eq!(rows(&chart, 6), vec!["oo oo"]);
}
fn legend_chart(position: LegendPosition) -> Chart {
Chart::new(vec![
dot(&[]).name("one").style("red"),
dot(&[]).name("two").style("blue"),
dot(&[]),
])
.legend(position)
.hidden_legend_fraction(1.0, 1.0)
.width(9)
.height(6)
}
#[test]
fn legends_go_in_the_corner_asked_for() {
assert_eq!(
rows(&legend_chart(LegendPosition::TopRight), 9),
vec![
" ┌───┐",
" │one│",
" │two│",
" └───┘",
" ",
" ",
]
);
assert_eq!(
rows(&legend_chart(LegendPosition::BottomLeft), 9)[2..],
["┌───┐ ", "│one│ ", "│two│ ", "└───┘ "]
);
assert_eq!(
rows(&legend_chart(LegendPosition::Right), 9)[1],
" ┌───┐"
);
assert!(
rows(&legend_chart(LegendPosition::None), 9)
.iter()
.all(|r| r.trim().is_empty())
);
}
#[test]
fn legends_keep_clear_of_titles() {
let chart = legend_chart(LegendPosition::TopLeft)
.y_axis(Axis::default().title("y"))
.x_axis(Axis::default().title("x"));
assert_eq!(
rows(&chart, 9),
vec![
"y ",
"┌───┐ ",
"│one│ ",
"│two│ ",
"└───┘ ",
" x",
]
);
assert!(rows(&chart.height(5), 9).iter().all(|r| !r.contains('┌')));
}
#[test]
fn legends_hide_when_they_would_take_too_much() {
let shown = |w: f64, h: f64| {
let chart = legend_chart(LegendPosition::TopRight).hidden_legend_fraction(w, h);
rows(&chart, 9).concat().contains("one")
};
assert!(shown(1.0, 1.0));
assert!(shown(0.56, 0.67));
assert!(!shown(0.4, 1.0));
assert!(!shown(1.0, 0.5));
let default = legend_chart(LegendPosition::TopRight);
let default = Chart {
hidden_legend: (0.25, 0.25),
..default
};
assert!(!rows(&default, 9).concat().contains("one"));
}
#[test]
fn the_legend_names_take_their_dataset_styles() {
let console = Console::builder().width(9).build();
let options = console.options();
let grid = legend_chart(LegendPosition::TopRight).grid(&console, &options);
assert_eq!(grid.style_at(1, 5), Style::parse("red").ok());
assert_eq!(grid.style_at(2, 5), Style::parse("blue").ok());
}
#[test]
fn later_datasets_win_a_cell() {
let chart = Chart::new(vec![
Dataset::new([(0.0, 0.0)]).style("red"),
Dataset::new([(0.0, 0.0)]).style("blue"),
])
.x_axis(Axis::default().bounds(0.0, 1.0))
.y_axis(Axis::default().bounds(0.0, 1.0))
.height(2)
.width(2);
let console = Console::builder().width(2).build();
let grid = chart.grid(&console, &console.options());
assert_eq!(grid.row_string(1), "• ");
assert_eq!(grid.style_at(1, 0), Style::parse("blue").ok());
}
fn unit_chart(datasets: Vec<Dataset>) -> Chart {
Chart::new(datasets)
.x_axis(Axis::default().bounds(0.0, 4.0))
.y_axis(Axis::default().bounds(0.0, 2.0))
.width(5)
.height(3)
}
#[test]
fn segments_leaving_the_bounds_are_clipped_by_default() {
let line = dot(&[(0.0, 0.0), (8.0, 2.0)]).graph_type(GraphType::Line);
let chart = unit_chart(vec![line]);
assert_eq!(chart.out_of_bounds, OutOfBounds::Clip);
assert_eq!(rows(&chart, 5), vec![" ", " ooo", "oo "]);
let explicit = chart.clone().out_of_bounds(OutOfBounds::Clip);
assert_eq!(rows(&explicit, 5), rows(&chart, 5));
}
#[test]
fn dropped_segments_leave_out_any_with_an_end_outside() {
let drop = |d: Dataset| rows(&unit_chart(vec![d]).out_of_bounds(OutOfBounds::Drop), 5);
let line = dot(&[(0.0, 0.0), (8.0, 2.0)]).graph_type(GraphType::Line);
assert_eq!(drop(line), vec![" ", " ", "o "]);
let line = dot(&[(0.0, 0.0), (4.0, 2.0), (4.0, 9.0)]).graph_type(GraphType::Line);
assert_eq!(drop(line), vec![" oo", " oo ", "o "]);
let across = dot(&[(-1.0, 1.0), (5.0, 1.0)]).graph_type(GraphType::Line);
assert_eq!(drop(across.clone()), vec![" "; 3]);
assert_eq!(
rows(&unit_chart(vec![across]), 5),
vec![" ", "ooooo", " "]
);
let bar = |d: Dataset| {
let chart = unit_chart(vec![d.graph_type(GraphType::Bar)])
.y_axis(Axis::default().bounds(1.0, 3.0));
(
rows(&chart.clone(), 5),
rows(&chart.out_of_bounds(OutOfBounds::Drop), 5),
)
};
let (clipped, dropped) = bar(dot(&[(2.0, 3.0)]));
assert_eq!(clipped, vec![" o "; 3]);
assert_eq!(dropped, vec![" o ", " ", " "]);
let area = dot(&[(0.0, 2.0), (8.0, 2.0)]).graph_type(GraphType::Area);
assert_eq!(drop(area), vec!["o ", " ", " "]);
}
fn crossing() -> Chart {
Chart::new(vec![
Dataset::new([(0.0, 1.0), (4.0, 1.0)])
.graph_type(GraphType::Line)
.marker(Marker::Braille)
.style("red"),
Dataset::new([(2.0, 0.0), (2.0, 2.0)])
.graph_type(GraphType::Line)
.marker(Marker::Braille)
.style("blue"),
])
.x_axis(Axis::default().bounds(0.0, 4.0))
.y_axis(Axis::default().bounds(0.0, 2.0))
.width(3)
.height(1)
}
#[test]
fn datasets_merge_their_dots_by_default() {
let console = Console::builder().width(3).build();
let chart = crossing();
assert_eq!(chart.layering, Layering::Merge);
let grid = chart.grid(&console, &console.options());
assert_eq!(grid.row_string(0), "⠒⡗⠒");
assert_eq!(grid.style_at(0, 1), Style::parse("blue").ok());
assert_eq!(grid.style_at(0, 0), Style::parse("red").ok());
}
#[test]
fn per_dataset_layering_draws_each_over_the_ones_before() {
let console = Console::builder().width(3).build();
let chart = crossing().layering(Layering::PerDataset);
let grid = chart.grid(&console, &console.options());
assert_eq!(grid.row_string(0), "⠒⡇⠒");
assert_eq!(grid.style_at(0, 1), Style::parse("blue").ok());
assert_eq!(grid.style_at(0, 2), Style::parse("red").ok());
let merged = crossing().grid(&console, &console.options());
assert_eq!(grid.row_string(0)[..3], merged.row_string(0)[..3]);
}
#[test]
fn the_legend_box_takes_the_x_axis_style_unless_given_its_own() {
let console = Console::builder().width(9).build();
let options = console.options();
let chart = legend_chart(LegendPosition::TopRight).x_axis(Axis::default().style("magenta"));
let grid = chart.grid(&console, &options);
assert_eq!(grid.style_at(0, 4), Style::parse("magenta").ok());
assert_eq!(grid.style_at(1, 4), Style::parse("magenta").ok());
let own = chart.legend_style("green").grid(&console, &options);
for (row, col) in [(0, 4), (0, 8), (1, 4), (1, 8), (3, 6)] {
assert_eq!(own.style_at(row, col), Style::parse("green").ok());
}
assert_eq!(own.style_at(1, 5), Style::parse("red").ok());
assert_eq!(own.style_at(2, 5), Style::parse("blue").ok());
let text = |g: &CellGrid| (0..6).map(|r| g.row_string(r)).collect::<Vec<_>>();
assert_eq!(text(&own), text(&grid));
}
fn walled() -> Chart {
let wall = (0..8).flat_map(|x| (0..4).map(move |y| (f64::from(x), f64::from(y))));
let dots = Dataset::new(wall)
.name("a")
.marker(Marker::Ascii('.'))
.style("red");
Chart::new(vec![dots, Dataset::new([]).name("bb").style("blue")])
.x_axis(Axis::default().bounds(0.0, 7.0))
.y_axis(Axis::default().bounds(0.0, 3.0))
.hidden_legend_fraction(1.0, 1.0)
.width(8)
.height(4)
}
#[test]
fn the_legend_clears_the_plot_under_it_by_default() {
let chart = walled();
assert!(chart.legend_clear);
assert_eq!(
rows(&chart, 8),
vec!["....┌──┐", "....│a │", "....│bb│", "....└──┘"]
);
assert_eq!(rows(&chart.clone().legend_clear(true), 8), rows(&chart, 8));
}
#[test]
fn an_uncleared_legend_leaves_the_plot_unstyled_around_the_names() {
let chart = walled().legend_clear(false);
assert_eq!(
rows(&chart, 8),
vec!["....┌──┐", "....│a.│", "....│bb│", "....└──┘"]
);
let console = Console::builder().width(8).build();
let grid = chart.grid(&console, &console.options());
assert_eq!(grid.style_at(1, 6), None);
assert_eq!(grid.style_at(1, 5), Style::parse("red").ok());
assert_eq!(grid.style_at(1, 2), Style::parse("red").ok());
}
#[test]
fn bounds_default_to_the_data() {
let chart = Chart::new(vec![dot(&[(10.0, 5.0), (20.0, 7.0)])])
.height(2)
.width(3);
assert_eq!(rows(&chart, 3), vec![" o", "o "]);
}
#[test]
fn tiny_charts_drop_labels_then_titles() {
let chart = Chart::new(vec![
dot(&[(0.0, 0.0), (1.0, 1.0)])
.name("series")
.graph_type(GraphType::Line),
])
.x_axis(
Axis::default()
.bounds(0.0, 1.0)
.title("time")
.labels(Labels::Auto(5)),
)
.y_axis(
Axis::default()
.bounds(0.0, 1.0)
.title("value")
.labels(Labels::Auto(5)),
);
for width in 0..12 {
for height in 0..6 {
let rendered = rows(&chart.clone().width(width).height(height), 12);
assert!(rendered.len() <= height);
assert!(rendered.iter().all(|r| rich::cells::cell_len(r) <= width));
}
}
assert_eq!(
rows(&chart.clone().width(8).height(2), 8),
vec![" 0│ooooo", " 0 0.8"]
);
assert_eq!(
rows(&chart.clone().width(2).height(3), 2),
vec!["│o", "└─", " 0"]
);
assert_eq!(
rows(&chart.clone().width(12).height(5), 12),
vec![
" 1│value oo",
"0.6│ oooo ",
" 0│oo time",
" └────────",
" 0 0.6 1",
]
);
let short = rows(&chart.width(12).height(4), 12).concat();
assert!(!short.contains("time") && !short.contains("value"));
}
#[test]
fn ascii_consoles_draw_ascii_axes_and_legends() {
let console = Console::builder()
.width(10)
.color_system(None)
.ascii_only(true)
.build();
let chart = Chart::new(vec![
Dataset::new([(0.0, 0.0), (1.0, 1.0)])
.name("é")
.graph_type(GraphType::Line),
])
.x_axis(
Axis::default()
.bounds(0.0, 1.0)
.labels(Labels::Values(vec![])),
)
.y_axis(
Axis::default()
.bounds(0.0, 1.0)
.labels(Labels::Values(vec![])),
)
.hidden_legend_fraction(1.0, 1.0)
.height(6);
assert_eq!(
console.render_export(&chart),
concat!(
"| +-+\n",
"| *|?|\n",
"| ** +-+\n",
"| ** \n",
"|* \n",
"+---------\n",
)
);
}
#[test]
fn area_charts_fill_to_their_level() {
let points = [(0.0, 1.0), (2.0, 3.0), (4.0, 2.0), (6.0, 4.0)];
let area = |fill: f64| {
Chart::new(vec![
Dataset::new(points)
.graph_type(GraphType::Area)
.marker(Marker::Ascii('#'))
.fill_to(fill),
])
.x_axis(Axis::default().bounds(0.0, 6.0))
.y_axis(Axis::default().bounds(0.0, 4.0))
.height(5)
.width(7)
};
assert_eq!(
rows(&area(0.0), 7),
vec![" #", " # ##", " ######", "#######", "#######"]
);
assert_eq!(
rows(&area(2.0), 7),
vec![" #", " # ##", "#######", "# ", " "]
);
let log = Chart::new(vec![
Dataset::new([(0.0, 10.0), (2.0, 10.0)])
.graph_type(GraphType::Area)
.marker(Marker::Ascii('#')),
])
.x_axis(Axis::default().bounds(0.0, 2.0))
.y_axis(Axis::default().bounds(1.0, 100.0).scale(AxisScale::Log))
.height(3)
.width(3);
assert_eq!(rows(&log, 3), vec![" ", "###", "###"]);
}
#[test]
fn markup_labels_keep_their_styles() {
let chart = Chart::new(Vec::new())
.x_axis(Axis::default().style("blue").labels(Labels::Markup(vec![
"[bold]0[/]".into(),
"5".into(),
"[bold]10[/]".into(),
])))
.height(3)
.width(12);
assert_eq!(
rows(&chart, 12),
vec![" ", " ───────────", "0 5 10"]
);
let console = Console::builder().width(12).build();
let grid = chart.grid(&console, &console.options());
let bold_blue = Style::parse("bold blue").ok();
assert_eq!(grid.style_at(2, 0), bold_blue);
assert_eq!(grid.style_at(2, 5), Style::parse("blue").ok());
assert_eq!(grid.style_at(2, 10), bold_blue);
assert_eq!(grid.style_at(2, 11), bold_blue);
let broken = Chart::new(Vec::new())
.y_axis(Axis::default().labels(Labels::Markup(vec!["[/x]".into(), "b".into()])))
.height(2)
.width(8);
assert_eq!(rows(&broken, 8), vec!["b │ ", "[/│ "]);
}
#[test]
fn labels_align_as_asked() {
let chart = |align: Option<HorizontalAlign>| {
let mut y = Axis::default()
.bounds(0.0, 100.0)
.labels(Labels::Values(vec![0.0, 50.0, 100.0]));
if let Some(align) = align {
y = y.labels_align(align);
}
Chart::new(Vec::new())
.y_axis(y.labels(Labels::Text(vec!["0".into(), "fifty".into(), "100".into()])))
.height(3)
.width(15)
};
assert_eq!(
rows(&chart(None), 15),
vec!["100 │ ", "fifty│ ", "0 │ "]
);
assert_eq!(
rows(&chart(Some(HorizontalAlign::Right)), 15),
vec![" 100│ ", "fifty│ ", " 0│ "]
);
assert_eq!(
rows(&chart(Some(HorizontalAlign::Center)), 15),
vec![" 100 │ ", "fifty│ ", " 0 │ "]
);
let values = |align: Option<HorizontalAlign>| {
let mut y = Axis::default()
.bounds(0.0, 100.0)
.labels(Labels::Values(vec![0.0, 100.0]));
if let Some(align) = align {
y = y.labels_align(align);
}
rows(&Chart::new(Vec::new()).y_axis(y).height(2).width(12), 12)
};
assert_eq!(values(None), vec!["100│ ", " 0│ "]);
assert_eq!(
values(Some(HorizontalAlign::Left)),
vec!["100│ ", "0 │ "]
);
}
#[test]
fn the_first_x_label_sits_against_the_y_axis_as_asked() {
let chart = |align: Option<HorizontalAlign>| {
let mut x = Axis::default().labels(Labels::Text(vec!["start".into(), "end".into()]));
if let Some(align) = align {
x = x.labels_align(align);
}
let y = Axis::default().labels(Labels::Text(vec!["abc".into(), "d".into()]));
rows(
&Chart::new(Vec::new())
.x_axis(x)
.y_axis(y)
.height(4)
.width(16),
16,
)
};
assert_eq!(chart(None)[2], " └───────────");
assert_eq!(chart(None)[3], "start end");
assert_eq!(chart(Some(HorizontalAlign::Center))[3], " start end");
assert_eq!(chart(Some(HorizontalAlign::Right))[2], " └────────────");
assert_eq!(chart(Some(HorizontalAlign::Right))[3], " start end");
}
#[test]
fn a_chart_fills_the_height_it_is_given() {
let chart = Chart::new(vec![
dot(&[(0.0, 0.0), (1.0, 1.0)]).graph_type(GraphType::Line),
])
.x_axis(Axis::default().bounds(0.0, 1.0).labels(Labels::Auto(2)))
.y_axis(Axis::default().bounds(0.0, 1.0).labels(Labels::Auto(2)));
let console = Console::builder().width(20).color_system(None).build();
let lines = console.render_lines(&chart, &console.options().update_height(7), false);
assert_eq!(lines.len(), 7);
let text: Vec<String> = lines
.iter()
.map(|l| l.iter().map(|s| s.text.as_str()).collect())
.collect();
assert!(
text[5].starts_with(" └") || text[5].contains('└'),
"{text:?}"
);
assert!(text[0].contains('1') && text[4].contains('0'), "{text:?}");
let fixed = chart.clone().height(4);
let lines = console.render_lines(&fixed, &console.options().update_height(7), false);
assert_eq!(lines.iter().filter(|l| !l.is_empty()).count(), 4);
assert_eq!(console.render_export(&chart).lines().count(), 15);
}
#[test]
fn axes_without_labels_reserve_no_room_for_them() {
let chart = Chart::new(vec![dot(&[(0.0, 0.0)])])
.x_axis(
Axis::default()
.bounds(0.0, 1.0)
.labels(Labels::Values(vec![])),
)
.y_axis(
Axis::default()
.bounds(0.0, 1.0)
.labels(Labels::Text(vec![])),
)
.width(10)
.height(4);
assert_eq!(
rows(&chart, 10),
vec!["│ ", "│ ", "│o ", "└─────────"]
);
}
#[test]
fn grid_lines_go_under_the_datasets_and_within_the_bounds() {
let trace = Dataset::new([(0.0, 1.0), (4.0, 1.0)])
.graph_type(GraphType::Line)
.marker(Marker::Ascii('o'));
let chart = Chart::new(vec![trace])
.x_axis(Axis::default().bounds(0.0, 4.0).grid([2.0, 9.0]))
.y_axis(Axis::default().bounds(0.0, 2.0).grid([-1.0]))
.layering(Layering::PerDataset)
.width(5)
.height(3);
assert_eq!(rows(&chart, 5), [" o ", "ooooo", " o "]);
let log = Chart::new(vec![Dataset::new([]).marker(Marker::Ascii('+'))])
.x_axis(
Axis::default()
.bounds(1.0, 100.0)
.scale(AxisScale::Log)
.grid([0.0, 10.0]),
)
.y_axis(Axis::default().bounds(0.0, 1.0))
.width(5)
.height(2);
assert_eq!(rows(&log, 5), [" + ", " + "]);
}
#[test]
fn grid_lines_take_the_axis_style() {
let console = Console::builder().width(3).build();
let chart = Chart::new(vec![Dataset::new([]).marker(Marker::Ascii('.'))])
.x_axis(Axis::default().bounds(0.0, 2.0).grid([1.0]).style("red"))
.y_axis(Axis::default().bounds(0.0, 1.0))
.width(3)
.height(1);
let grid = chart.grid(&console, &console.options());
assert_eq!(grid.row_string(0), " . ");
assert_eq!(grid.style_at(0, 1), Style::parse("red").ok());
}
#[test]
fn the_chart_style_is_under_everything_and_the_legends() {
let console = Console::builder().width(8).build();
let chart = Chart::new(vec![Dataset::new([]).name("a")])
.x_axis(Axis::default().bounds(0.0, 1.0).style("red"))
.y_axis(Axis::default().bounds(0.0, 1.0))
.hidden_legend_fraction(1.0, 1.0)
.width(8)
.height(3);
let grid = chart.grid(&console, &console.options());
assert_eq!(grid.style_at(0, 5), Style::parse("red").ok());
assert_eq!(grid.style_at(2, 0), None);
let styled = chart.clone().style("on blue");
let grid = styled.grid(&console, &console.options());
assert_eq!(grid.style_at(0, 5), Style::parse("on blue").ok());
assert_eq!(grid.style_at(2, 0), Style::parse("on blue").ok());
let grid = styled
.legend_style("green")
.grid(&console, &console.options());
assert_eq!(grid.style_at(0, 5), Style::parse("on blue green").ok());
}
#[test]
fn crossing_grid_lines_are_layers_of_their_own_the_y_axis_first() {
let cross = |layering| {
Chart::new(Vec::<Dataset>::new())
.x_axis(Axis::default().bounds(-1.0, 1.0).grid([0.0]))
.y_axis(Axis::default().bounds(-1.0, 1.0).grid([0.0]))
.layering(layering)
.width(3)
.height(1)
};
let line = |points: [(f64, f64); 2]| {
Dataset::new(points)
.graph_type(GraphType::Line)
.marker(Marker::Braille)
};
let datasets = Chart::new(vec![
line([(-1.0, 0.0), (1.0, 0.0)]),
line([(0.0, -1.0), (0.0, 1.0)]),
])
.x_axis(Axis::default().bounds(-1.0, 1.0))
.y_axis(Axis::default().bounds(-1.0, 1.0))
.layering(Layering::PerDataset)
.width(3)
.height(1);
let layered = rows(&cross(Layering::PerDataset), 3);
assert_eq!(layered, rows(&datasets, 3));
assert_eq!(layered, ["⠒⡇⠒"]);
assert_eq!(rows(&cross(Layering::Merge), 3), ["⠒⡗⠒"]);
}
#[test]
fn a_grid_with_no_datasets_is_braille_unless_told() {
let empty = Chart::new(Vec::<Dataset>::new())
.x_axis(Axis::default().bounds(0.0, 1.0))
.y_axis(Axis::default().bounds(-1.0, 1.0).grid([0.0]))
.width(3)
.height(1);
assert_eq!(rows(&empty, 3), ["⠒⠒⠒"]);
let dotted = Chart::new(vec![Dataset::new([]).marker(Marker::Dot)])
.y_axis(Axis::default().bounds(-1.0, 1.0).grid([0.0]))
.x_axis(Axis::default().bounds(0.0, 1.0))
.width(3)
.height(1);
assert_eq!(rows(&dotted, 3), ["•••"]);
assert_eq!(rows(&dotted.clone().marker(Marker::Ascii('=')), 3), ["==="]);
let own = dotted.marker(Marker::Ascii('=')).y_axis(
Axis::default()
.bounds(-1.0, 1.0)
.grid([0.0])
.grid_marker(Marker::Ascii('~')),
);
assert_eq!(rows(&own, 3), ["~~~"]);
}
#[test]
fn grid_lines_take_their_own_style_over_the_axis_style() {
let console = Console::builder().width(3).build();
let chart = Chart::new(vec![Dataset::new([]).marker(Marker::Ascii('.'))])
.x_axis(
Axis::default()
.bounds(0.0, 2.0)
.grid([1.0])
.style("red")
.grid_style("blue"),
)
.y_axis(Axis::default().bounds(0.0, 1.0))
.width(3)
.height(1);
let grid = chart.grid(&console, &console.options());
assert_eq!(grid.row_string(0), " . ");
assert_eq!(grid.style_at(0, 1), Style::parse("blue").ok());
let none = Chart::new(Vec::<Dataset>::new())
.x_axis(Axis::default().bounds(0.0, 2.0).grid_style("blue"))
.y_axis(Axis::default().bounds(0.0, 1.0))
.width(3)
.height(1);
assert_eq!(rows(&none, 3), [" "]);
}
#[test]
fn an_unstyled_legend_is_the_none_style_by_name() {
let console = Console::builder().width(9).build();
let options = console.options();
let chart = legend_chart(LegendPosition::TopRight).x_axis(Axis::default().style("magenta"));
let plain = chart.clone().legend_unstyled().grid(&console, &options);
let none = chart.style("none").grid(&console, &options);
for (row, col) in [(0, 4), (1, 4), (3, 6)] {
assert_eq!(
plain.style_at(row, col).unwrap_or_default(),
Style::default()
);
assert_eq!(plain.style_at(row, col), none.style_at(row, col));
}
}
}