mod features;
use features::zero_line::render_zero_line;
use features::threshold::render_thresholds;
use features::x_axis::add_x_axis;
use features::stat_annotations::render_stat_annotations;
use crate::legend::add_legends;
use crate::options::{CharSet, Config, DEFAULT_CHAR_SET};
use crate::utils::{calculate_height, interpolate_array, min_max_float64_slice};
use crate::{utils, AnsiColor};
#[derive(Clone)]
pub(crate) struct Cell {
text: String,
color: AnsiColor,
}
impl Default for Cell {
fn default() -> Self {
Cell {
text: " ".to_string(),
color: AnsiColor::DEFAULT,
}
}
}
pub fn plot(series: &[f64], config: Config) -> String {
plot_many(&[series], config)
}
pub fn plot_many(data: &[&[f64]], config: Config) -> String {
let config = normalize_config(config);
let (data, len_max) = prepare_data(data, &config);
let bounds = calculate_bounds(&data, &config);
let width = len_max + config.offset;
let mut plot = init_grid(bounds.rows, width);
let precision = calculate_precision(bounds.maximum, bounds.minimum, &config);
let (magnitudes, max_width) = calculate_y_axis_magnitudes(&bounds, precision, &config);
let left_pad = config.offset + max_width;
render_y_axis(&mut plot, &magnitudes, max_width, precision, &config);
if let Some(zl) = config.zero_line {
render_zero_line(&mut plot, &bounds, config.offset, zl);
}
if !config.thresholds.is_empty() {
render_thresholds(
&mut plot,
&data,
&bounds,
config.offset,
&config.thresholds,
&config.series_colors,
);
}
if let Some(ref sa) = config.stat_annotations {
render_stat_annotations(&mut plot, &data, &bounds, config.offset, sa);
}
render_series(&mut plot, &data, &bounds, len_max, &config);
let mut lines = join_rows(&plot, &config);
if let Some(ref label) = config.y_axis_label {
let mut result = format!("{}{}", label, config.line_ending);
result.push_str(&lines);
lines = result;
}
if config.x_axis_range.is_some() {
add_x_axis(&mut lines, &config, len_max, left_pad);
}
if !config.caption.is_empty() {
render_caption(&mut lines, &config, len_max, left_pad);
}
if !config.series_legends.is_empty() {
add_legends(&mut lines, &config, len_max, left_pad);
}
lines
}
pub(crate) fn get_char_set(config: &Config, series_index: usize) -> CharSet {
if series_index < config.series_chars.len() {
return config.series_chars[series_index].clone();
}
DEFAULT_CHAR_SET
}
fn normalize_config(mut config: Config) -> Config {
if config.offset == 0 {
config.offset = 3;
}
if config.line_ending.is_empty() {
config.line_ending = "\n".to_string();
}
config
}
fn prepare_data(data: &[&[f64]], config: &Config) -> (Vec<Vec<f64>>, usize) {
let mut data: Vec<Vec<f64>> = data.iter().map(|s| s.to_vec()).collect();
let mut len_max = data.iter().map(Vec::len).max().unwrap_or(0);
if config.width > 0 {
for series in data.iter_mut() {
series.resize(len_max, f64::NAN);
*series = interpolate_array(series, config.width as u32);
}
len_max = config.width;
}
if let Some(window) = config.moving_average_window {
if !data.is_empty() {
let ma = utils::moving_average(&data[0], window);
data.push(ma);
}
}
(data, len_max)
}
pub(crate) struct Bounds {
minimum: f64,
maximum: f64,
interval: f64,
ratio: f64,
rows: usize,
intmin2: isize,
intmax2: isize,
min2: f64,
}
fn calculate_bounds(data: &[Vec<f64>], config: &Config) -> Bounds {
let mut minimum = f64::INFINITY;
let mut maximum = f64::NEG_INFINITY;
for series in data.iter() {
match min_max_float64_slice(series) {
Some((min_v, max_v)) => {
if min_v < minimum { minimum = min_v; }
if max_v > maximum { maximum = max_v; }
}
None => eprintln!("warning: series contained no finite values"),
}
}
if let Some(lb) = config.lower_bound {
if lb < minimum { minimum = lb; }
}
if let Some(ub) = config.upper_bound {
if ub > maximum { maximum = ub; }
}
debug_assert!(maximum >= minimum, "maximum must be >= minimum");
let interval = maximum - minimum;
let height = if config.height > 0 { config.height } else { calculate_height(interval) };
let ratio = if interval != 0.0 { height as f64 / interval } else { 1.0 };
let min2 = utils::round(minimum * ratio);
let max2 = utils::round(maximum * ratio);
let intmin2 = min2.round() as isize;
let intmax2 = max2.round() as isize;
let rows = (intmax2 - intmin2).unsigned_abs();
Bounds { minimum, maximum, interval, ratio, rows, intmin2, intmax2, min2 }
}
fn init_grid(rows: usize, width: usize) -> Vec<Vec<Cell>> {
vec![vec![Cell::default(); width]; rows + 1]
}
fn calculate_precision(maximum: f64, minimum: f64, config: &Config) -> usize {
let mut precision = config.precision.unwrap_or(2);
let mut log_maximum = maximum.abs().max(minimum.abs()).log10();
if minimum == 0.0 && maximum == 0.0 {
log_maximum = -1.0;
}
if log_maximum < 0.0 {
if log_maximum.fract() != 0.0 {
precision += log_maximum.abs() as usize;
} else {
precision += (log_maximum.abs() - 1.0) as usize;
}
} else if log_maximum > 2.0 && config.precision.is_none() {
precision = 0;
}
precision
}
fn calculate_y_axis_magnitudes(
bounds: &Bounds,
precision: usize,
config: &Config,
) -> (Vec<f64>, usize) {
let mut max_num_length = format!("{:.prec$}", bounds.maximum, prec = precision)
.chars()
.count();
let min_num_length = format!("{:.prec$}", bounds.minimum, prec = precision)
.chars()
.count();
if config.y_axis_value_formatter.is_some() {
max_num_length = 0;
}
let mut magnitudes = Vec::with_capacity(bounds.rows + 1);
for y in bounds.intmin2..=bounds.intmax2 {
let magnitude = if bounds.rows > 0 && bounds.interval > 0.0 {
bounds.maximum
- (((y - bounds.intmin2) as f64 * bounds.interval) / bounds.rows as f64)
} else if bounds.interval == 0.0 {
bounds.minimum
} else {
y as f64
};
magnitudes.push(magnitude);
if let Some(formatter) = &config.y_axis_value_formatter {
let l = formatter(magnitude).chars().count();
if l > max_num_length {
max_num_length = l;
}
}
}
let max_width = if config.y_axis_value_formatter.is_some() {
max_num_length
} else {
max_num_length.max(min_num_length)
};
(magnitudes, max_width)
}
fn render_y_axis(
plot: &mut Vec<Vec<Cell>>,
magnitudes: &[f64],
max_width: usize,
precision: usize,
config: &Config,
) {
for (w, &magnitude) in magnitudes.iter().enumerate() {
let label = if let Some(formatter) = &config.y_axis_value_formatter {
format!("{:>width$}", formatter(magnitude), width = max_width + 1)
} else {
format!("{:>width$.prec$}", magnitude, width = max_width + 1, prec = precision)
};
let h = ((config.offset as f64) - (label.chars().count() as f64))
.max(0.0) as usize;
plot[w][h].text = label;
plot[w][h].color = config.label_color;
plot[w][config.offset - 1].text = "┤".to_string();
plot[w][config.offset - 1].color = config.axis_color;
}
}
fn render_series(
plot: &mut Vec<Vec<Cell>>,
data: &[Vec<f64>],
bounds: &Bounds,
len_max: usize,
config: &Config,
) {
let _ = len_max;
for (i, series) in data.iter().enumerate() {
let color = config.series_colors.get(i).copied().unwrap_or(AnsiColor::DEFAULT);
let char_set = get_char_set(config, i);
let (mut y0, mut y1): (usize, usize);
if !series[0].is_nan() {
y0 = ((series[0] * bounds.ratio).round() - bounds.min2) as usize;
plot[bounds.rows - y0][config.offset - 1].text = "┼".to_string();
plot[bounds.rows - y0][config.offset - 1].color = config.axis_color;
}
for (x, window) in series.windows(2).enumerate() {
let (d0, d1) = (window[0], window[1]);
if d0.is_nan() && d1.is_nan() { continue; }
if !d0.is_nan() && d1.is_nan() {
y0 = ((d0 * bounds.ratio).round() - bounds.intmin2 as f64) as usize;
plot[bounds.rows - y0][x + config.offset].text = char_set.end_cap.to_string();
plot[bounds.rows - y0][x + config.offset].color = color;
continue;
}
if d0.is_nan() && !d1.is_nan() {
y1 = ((d1 * bounds.ratio).round() - bounds.intmin2 as f64) as usize;
plot[bounds.rows - y1][x + config.offset].text = char_set.start_cap.to_string();
plot[bounds.rows - y1][x + config.offset].color = color;
continue;
}
y0 = ((d0 * bounds.ratio).round() - bounds.intmin2 as f64) as usize;
y1 = ((d1 * bounds.ratio).round() - bounds.intmin2 as f64) as usize;
if y0 == y1 {
plot[bounds.rows - y0][x + config.offset].text = char_set.horizontal.to_string();
} else {
if y0 > y1 {
plot[bounds.rows - y1][x + config.offset].text = char_set.arc_up_right.to_string();
plot[bounds.rows - y0][x + config.offset].text = char_set.arc_down_left.to_string();
} else {
plot[bounds.rows - y1][x + config.offset].text = char_set.arc_down_right.to_string();
plot[bounds.rows - y0][x + config.offset].text = char_set.arc_up_left.to_string();
}
let lo = y0.min(y1) + 1;
let hi = y0.max(y1);
for y in lo..hi {
plot[bounds.rows - y][x + config.offset].text = char_set.vertical_line.to_string();
}
}
let lo = y0.min(y1);
let hi = y0.max(y1);
for y in lo..=hi {
plot[bounds.rows - y][x + config.offset].color = color;
}
}
}
}
fn join_rows(plot: &Vec<Vec<Cell>>, config: &Config) -> String {
let mut lines = String::new();
for (h, row) in plot.iter().enumerate() {
if h != 0 {
lines.push_str(&config.line_ending);
}
let mut row_str = String::new();
let mut current_color = AnsiColor::DEFAULT;
for cell in row.iter() {
if cell.color != current_color {
current_color = cell.color;
row_str.push_str(¤t_color.to_string());
}
row_str.push_str(&cell.text);
}
if current_color != AnsiColor::DEFAULT {
row_str.push_str(&AnsiColor::DEFAULT.to_string());
}
lines.push_str(row_str.trim_end_matches(' '));
}
lines
}
fn render_caption(lines: &mut String, config: &Config, len_max: usize, left_pad: usize) {
lines.push_str(&config.line_ending);
lines.push_str(&" ".repeat(left_pad));
if config.caption.len() < len_max {
lines.push_str(&" ".repeat((len_max - config.caption.len()) / 2));
}
if config.caption_color != AnsiColor::DEFAULT {
lines.push_str(&config.caption_color.to_string());
}
lines.push_str(&config.caption);
if config.caption_color != AnsiColor::DEFAULT {
lines.push_str(&AnsiColor::DEFAULT.to_string());
}
}