Browse Source

Initial version

Documentation is pending
pull/2/head
JuanLeon Lahoz 5 years ago
parent
commit
38ebc48456
  1. 1
      .gitignore
  2. 15
      Cargo.toml
  3. 139
      src/histogram.rs
  4. 145
      src/main.rs
  5. 109
      src/plot.rs
  6. 102
      src/reader.rs
  7. 83
      src/stats.rs

1
.gitignore vendored

@ -0,0 +1 @@
/target

15
Cargo.toml

@ -0,0 +1,15 @@
[package]
name = "lowcharts"
version = "0.1.0"
authors = ["JuanLeon Lahoz <juanleon.lahoz@gmail.com>"]
edition = "2018"
[dependencies]
clap = "3.0.0-beta.2"
yansi = "0.5.0"
isatty = "0.1"
derive_builder = "0.10.0"
regex = "1.4.5"
[dev-dependencies]
float_eq = "0.5.0"

139
src/histogram.rs

@ -0,0 +1,139 @@
use std::fmt;
use std::ops::Range;
use yansi::Color::{Red, Blue, Green};
use crate::stats::Stats;
#[derive(Debug)]
pub struct Bucket {
range: Range<f64>,
count: usize,
}
impl Bucket {
fn new(range: Range<f64>) -> Bucket {
Bucket {
range,
count: 0,
}
}
fn inc(&mut self) {
self.count += 1;
}
}
#[derive(Debug)]
pub struct Histogram {
vec: Vec<Bucket>,
max: f64,
step: f64,
top: usize,
last: usize,
stats: Stats,
}
impl Histogram {
pub fn new(size: usize, step: f64, stats: Stats) -> Histogram {
let mut b = Histogram {
vec: Vec::with_capacity(size),
max: stats.min + (step * size as f64),
step,
top: 0,
last: size - 1,
stats,
};
let mut lower = b.stats.min;
for _ in 0..size {
b.vec.push(Bucket::new(lower..lower + step));
lower += step;
}
b
}
pub fn load(&mut self, vec: &[f64]) {
for x in vec {
self.add(*x);
}
}
pub fn add(&mut self, n: f64) {
if let Some(slot) = self.find_slot(n) {
self.vec[slot].inc();
self.top = self.top.max(self.vec[slot].count);
}
}
fn find_slot(&self, n: f64) -> Option<usize> {
if n < self.stats.min || n > self.max {
None
} else {
Some((((n - self.stats.min) / self.step) as usize).min(self.last))
}
}
}
impl fmt::Display for Histogram {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{}", self.stats)?;
let writer = HistWriter {width: f.width().unwrap_or(110)};
writer.write(f, &self)
}
}
struct HistWriter {
width: usize,
}
impl HistWriter {
pub fn write(&self, f: &mut fmt::Formatter, hist: &Histogram) -> fmt::Result {
let width_range = Self::get_width(hist);
let width_count = ((hist.top as f64).log10().ceil() as usize).max(1);
let divisor = 1.max(hist.top / self.get_max_bar_len(width_range + width_count));
writeln!(
f,
"each {} represents a count of {}",
Red.paint("∎"),
Blue.paint(divisor.to_string()),
)?;
for x in hist.vec.iter() {
self.write_bucket(f, x, divisor, width_range, width_count)?;
}
Ok(())
}
fn write_bucket(&self, f: &mut fmt::Formatter, bucket: &Bucket, divisor: usize, width: usize, width_count: usize) -> fmt::Result {
let bar = Red.paint(format!("{:∎<width$}", "", width=bucket.count / divisor));
writeln!(
f,
"[{range}] [{count}] {bar}",
range=Blue.paint(
format!(
"{:width$.3} .. {:width$.3}",
bucket.range.start,
bucket.range.end,
width = width,
)
),
count=Green.paint(format!("{:width$}", bucket.count, width=width_count)),
bar=bar
)
}
fn get_width(hist: &Histogram) -> usize {
format!("{:.3}", hist.stats.min).len().max(format!("{:.3}", hist.max).len())
}
fn get_max_bar_len(&self, fixed_width: usize) -> usize {
const EXTRA_CHARS: usize = 10;
if self.width < fixed_width + EXTRA_CHARS {
75
} else {
self.width - fixed_width - EXTRA_CHARS
}
}
}

145
src/main.rs

@ -0,0 +1,145 @@
use std::env;
use yansi::Paint;
use yansi::Color::Red;
use yansi::Color::Yellow;
use clap::{AppSettings, Clap};
use isatty::stdout_isatty;
use regex::Regex;
#[macro_use]
extern crate derive_builder;
mod stats;
mod histogram;
mod reader;
mod plot;
fn disable_color_if_needed(option: &str) {
match option {
"no" => Paint::disable(),
"auto" => {
match env::var("TERM") {
Ok(value) if value == "dumb" => Paint::disable(),
_ => {
if !stdout_isatty() {
Paint::disable();
}
}
}
},
_ => ()
}
}
/// Tool to draw low-resolution graphs in terminal
#[derive(Clap)]
#[clap(setting = AppSettings::ColoredHelp)]
struct Opts {
/// Input file. If not present or a single dash, standard input will be used.
#[clap(default_value = "-")]
input: String,
/// Filter out values bigger than this
#[clap(long)]
max: Option<f64>,
/// Filter out values smaller than this
#[clap(long)]
min: Option<f64>,
/// Use colors in the output. Auto means "yes if tty with TERM != dumb and
/// no redirects".
#[clap(short, long, default_value = "auto", possible_values = &["auto", "no", "yes"])]
color: String,
/// Use this many characters as terminal width
#[clap(long, default_value = "110")]
width: usize,
/// Use a regex to capture input values. By default this will use a capture
/// group named "value". If not present, it will use first capture group.
/// If not present, a number per line is expected. Examples of regex are '
/// 200 \d+ ([0-9.]+)' (1 anonymous capture group) or 'a(a)?
/// (?P<value>[0-9.]+)' (a named capture group).
#[clap(long)]
regex: Option<String>,
#[clap(long)]
/// Be more verbose
verbose: bool,
#[clap(subcommand)]
subcmd: SubCommand,
}
#[derive(Clap)]
enum SubCommand {
/// Plot an histogram from input values
Hist(Hist),
/// Plot an 2d plot where y-values are averages of input values (as many
/// averages as wide is the plot)
Plot(Plot),
}
#[derive(Clap)]
#[clap(setting = AppSettings::ColoredHelp)]
struct Hist {
/// Use that many intervals
#[clap(long, default_value = "20")]
intervals: usize,
}
#[derive(Clap)]
#[clap(setting = AppSettings::ColoredHelp)]
struct Plot {
/// Use that many rows for the plot
#[clap(long, default_value = "40")]
height: usize,
}
fn main() {
let opts: Opts = Opts::parse();
disable_color_if_needed(&opts.color);
let mut builder = reader::DataReaderBuilder::default();
builder.verbose(opts.verbose);
if opts.min.is_some() || opts.max.is_some() {
builder.range(
opts.min.unwrap_or(f64::NEG_INFINITY)..opts.max.unwrap_or(f64::INFINITY)
);
}
if let Some(string) = opts.regex {
match Regex::new(&string) {
Ok(re) => {
builder.regex(re);
},
_ => eprintln!("[{}]: Failed to parse regex {}", Red.paint("ERROR"), string)
};
}
let reader = builder.build().unwrap();
let vec = reader.read(opts.input);
if vec.is_empty() {
eprintln!("[{}]: No data", Yellow.paint("WARN"));
std::process::exit(0);
}
let stats = stats::Stats::new(&vec);
match opts.subcmd {
SubCommand::Hist(o) => {
let mut histogram = histogram::Histogram::new(
o.intervals,
(stats.max - stats.min) / o.intervals as f64,
stats
);
histogram.load(&vec);
println!("{:width$}", histogram, width=opts.width);
},
SubCommand::Plot(o) => {
let mut plot = plot::Plot::new(
opts.width,
o.height,
stats
);
plot.load(&vec);
print!("{}", plot);
}
}
}

109
src/plot.rs

@ -0,0 +1,109 @@
use std::fmt;
use std::ops::Range;
use yansi::Color::{Red, Blue};
use crate::stats::Stats;
#[derive(Debug)]
pub struct Plot {
x_axis: Vec<f64>,
y_axis: Vec<f64>,
width: usize,
height: usize,
stats: Stats,
}
impl Plot {
pub fn new(width: usize, height: usize, stats: Stats) -> Plot {
Plot {
x_axis: Vec::with_capacity(width),
y_axis: Vec::with_capacity(height),
width,
height,
stats,
}
}
pub fn load(&mut self, vec: &[f64]) {
self.width = self.width.min(vec.len());
let num_chunks = vec.len() / self.width;
let iter = vec.chunks(num_chunks);
for x in iter {
let sum: f64 = x.iter().sum();
self.x_axis.push(sum / x.len() as f64);
}
let step = (self.stats.max - self.stats.min) / self.height as f64;
for y in 0..self.height {
self.y_axis.push(self.stats.min + step * y as f64);
}
}
}
impl fmt::Display for Plot {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{}", self.stats)?;
let _step = (self.stats.max - self.stats.min) / self.height as f64;
let y_width = format!("{:.3}", self.stats.max).len();
let mut newvec = self.y_axis.to_vec();
newvec.reverse();
print_line(f, &self.x_axis, newvec[0]..f64::INFINITY, y_width)?;
for y in newvec.windows(2) {
print_line(f, &self.x_axis, y[1]..y[0], y_width)?;
}
Ok(())
}
}
fn print_line(f: &mut fmt::Formatter, x_axis: &[f64], range: Range<f64>, y_width: usize) -> fmt::Result {
let mut row = format!("{: <width$}", "", width=x_axis.len());
// The reverse in the enumeration is to avoid breaking char boundaries
// because of unicode char ● having more bytes than ascii chars.
for (x, value) in x_axis.iter().enumerate().rev() {
if range.contains(value) {
row.replace_range(x..x+1, "●".as_ref());
}
}
writeln!(
f,
"[{}] {}",
Blue.paint(format!("{y:.*}", y_width, y=range.start.to_string())),
Red.paint(row),
)
}
#[cfg(test)]
mod tests {
use super::*;
use float_eq::assert_float_eq;
use yansi::Paint;
#[test]
fn basic_test() {
let stats = Stats::new(&[-1.0, 4.0]);
let mut plot = Plot::new(3, 5, stats);
plot.load(&[-1.0, 0.0, 1.0, 2.0, 3.0, 4.0, -1.0]);
assert_float_eq!(plot.x_axis[0], -0.5, rmax <= f64::EPSILON);
assert_float_eq!(plot.x_axis[1], 1.5, rmax <= f64::EPSILON);
assert_float_eq!(plot.x_axis[2], 3.5, rmax <= f64::EPSILON);
assert_float_eq!(plot.x_axis[3], -1.0, rmax <= f64::EPSILON);
assert_float_eq!(plot.y_axis[0], -1.0, rmax <= f64::EPSILON);
assert_float_eq!(plot.y_axis[4], 3.0, rmax <= f64::EPSILON);
}
#[test]
fn display_test() {
let stats = Stats::new(&[-1.0, 4.0]);
let mut plot = Plot::new(3, 5, stats);
plot.load(&[-1.0, 0.0, 1.0, 2.0, 3.0, 4.0, -1.0]);
Paint::disable();
let display = format!("{}", plot);
assert!(display.find("[3] ● ").is_some());
assert!(display.find("[2] ").is_some());
assert!(display.find("[1] ● ").is_some());
assert!(display.find("[-1] ● ●").is_some());
}
}

102
src/reader.rs

@ -0,0 +1,102 @@
use std::io::{self, BufRead};
use std::fs::File;
use std::ops::Range;
use regex::Regex;
use yansi::Color::{Red, Magenta};
#[derive(Debug, Default, Builder)]
pub struct DataReader {
#[builder(setter(strip_option), default)]
range: Option<Range<f64>>,
#[builder(setter(strip_option), default)]
regex: Option<Regex>,
#[builder(default)]
verbose: bool,
}
impl DataReader {
pub fn read(&self, path: String) -> Vec<f64> {
let mut vec: Vec<f64> = vec![];
match path.as_str() {
"-" => {
vec = self.read_data(io::stdin().lock().lines());
}
_ => {
let file = File::open(path);
match file {
Ok(fd) => {
vec = self.read_data(io::BufReader::new(fd).lines());
}
Err(error) => eprintln!("[{}]: {}", Red.paint("ERROR"), error),
}
}
}
vec
}
fn read_data<T: BufRead>(&self, lines: std::io::Lines<T>) -> Vec<f64> {
let mut vec: Vec<f64> = Vec::new();
let line_parser = match self.regex {
Some(_) => Self::parse_regex,
None => Self::parse_float
};
for line in lines {
match line {
Ok(as_string) => if let Some(n) = line_parser(&self, &as_string) {
match &self.range {
Some(range) => if range.contains(&n) {
vec.push(n);
},
_ => vec.push(n)
}
},
Err(error) => eprintln!("[{}]: {}", Red.paint("ERROR"), error),
}
}
vec
}
fn parse_float(&self, line: &str) -> Option<f64> {
match line.parse::<f64>() {
Ok(n) => Some(n),
Err(parse_error) => {
eprintln!(
"[{}] Cannot parse float ({}) at '{}'",
Red.paint("ERROR"),
parse_error,
line
);
None
}
}
}
fn parse_regex(&self, line: &str) -> Option<f64> {
match self.regex.as_ref().unwrap().captures(line) {
Some(cap) => {
if let Some(name) = cap.name("value") {
self.parse_float(&name.as_str())
} else if let Some(capture) = cap.get(1) {
self.parse_float(&capture.as_str())
} else {
None
}
},
None => {
if self.verbose {
eprintln!(
"[{}] Regex does not match '{}'",
Magenta.paint("DEBUG"),
line
);
}
None
}
}
}
}

83
src/stats.rs

@ -0,0 +1,83 @@
use std::fmt;
use yansi::Color::Blue;
#[derive(Debug)]
pub struct Stats {
pub min: f64,
pub max: f64,
pub avg: f64,
pub std: f64,
pub var: f64,
pub sum: f64,
pub samples: usize,
}
impl Stats {
pub fn new(vec: &[f64]) -> Stats {
let mut max = vec[0];
let mut min = max;
let mut temp: f64 = 0.0;
let sum = vec.iter().sum::<f64>();
let avg = sum / vec.len() as f64;
for val in vec.iter() {
max = max.max(*val);
min = min.min(*val);
temp += (avg - *val).powi(2)
}
let var = temp / vec.len() as f64;
let std = var.sqrt();
Stats { min, max, avg, std, var, sum, samples: vec.len() }
}
}
impl fmt::Display for Stats {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
writeln!(
f,
"Samples = {len:.5}; Min = {min:.5}; Max = {max:.5}",
len=Blue.paint(self.samples.to_string()),
min=Blue.paint(self.min.to_string()),
max=Blue.paint(self.max.to_string()),
)?;
writeln!(
f,
"Average = {avg:.5}; Variance = {var:.5}; STD = {std:.5}",
avg=Blue.paint(self.avg.to_string()),
var=Blue.paint(self.var.to_string()),
std=Blue.paint(self.std.to_string())
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use float_eq::assert_float_eq;
use yansi::Paint;
#[test]
fn basic_test() {
let stats = Stats::new(&[1.1, 3.3, 2.2]);
assert_eq!(3_usize, stats.samples);
assert_float_eq!(stats.sum, 6.6, rmax <= f64::EPSILON);
assert_float_eq!(stats.avg, 2.2, rmax <= f64::EPSILON);
assert_float_eq!(stats.min, 1.1, rmax <= f64::EPSILON);
assert_float_eq!(stats.max, 3.3, rmax <= f64::EPSILON);
assert_float_eq!(stats.var, 0.8066, abs <= 0.0001);
assert_float_eq!(stats.std, 0.8981, abs <= 0.0001);
}
#[test]
fn test_display() {
let stats = Stats::new(&[1.1, 3.3, 2.2]);
Paint::disable();
let display = format!("{}", stats);
assert!(display.find("Samples = 3").is_some());
assert!(display.find("Min = 1.1").is_some());
assert!(display.find("Max = 3.3").is_some());
assert!(display.find("Average = 2.2").is_some());
}
}
Loading…
Cancel
Save