From 2cbdba8497cf460d60fbcbee9b110b71a5f7a0b0 Mon Sep 17 00:00:00 2001 From: JuanLeon Lahoz Date: Fri, 6 May 2022 17:35:32 +0200 Subject: [PATCH] Allow using lowcharts as a library --- Cargo.toml | 9 +++++++ Makefile | 3 +++ README.md | 28 +++++++++++++++++++++ src/lib.rs | 3 +++ src/main.rs | 21 ++++------------ src/plot/histogram.rs | 52 ++++++++++++++++++++++++++++----------- src/plot/matchbar.rs | 5 ++++ src/plot/splittimehist.rs | 20 ++++++++++++--- src/plot/terms.rs | 9 +++++++ src/plot/timehist.rs | 25 +++++++++++++------ src/plot/xy.rs | 41 +++++++++++++++++++++++++----- src/stats/mod.rs | 18 +++++++++++--- 12 files changed, 183 insertions(+), 51 deletions(-) create mode 100644 src/lib.rs diff --git a/Cargo.toml b/Cargo.toml index c0362ee..396fd9e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,15 @@ keywords = ["regex", "grep", "data", "troubleshooting", "graph", "text", "consol categories = ["command-line-utilities", "text-processing"] license = "MIT" +[lib] +name = "lowcharts" +path = "src/lib.rs" + +[[bin]] +name = "lowcharts" +path = "src/main.rs" + + [dependencies] clap = { version = "^3", features = ["cargo"] } yansi = "^0" diff --git a/Makefile b/Makefile index 84aad07..2b5c62e 100644 --- a/Makefile +++ b/Makefile @@ -13,3 +13,6 @@ test: # assert_cmd, as it does not follow forks coverage: cargo tarpaulin -o Html --ignore-tests -- --test-threads 1 + +doc: + cargo doc -p lowcharts diff --git a/README.md b/README.md index b1ba7db..0794653 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,34 @@ of a metric over time, but not the speed of that evolution. There is regex support for this type of plots. +### Using it as a library + +`lowcharts` can be used as a library by any code that needs to display text +based charts. + +```toml +[dependencies] +lowcharts = "*" +``` + +Example: + +```rust +// use lowcharts::plot; +let vec = &[-1.0, -1.1, 2.0, 2.0, 2.1, -0.9, 11.0, 11.2, 1.9, 1.99, 1.98, 1.97, 1.96]; +// Plot a histogram of the above vector, with 4 buckets and a precision +// choosen by library +let histogram = plot::Histogram::new(vec, 4, None); +print!("{}", histogram); +``` + +You can disable coloring by doing: + +```rust +// use yansi::Paint; +Paint::disable(); +``` + ### Installing #### Via release diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..428bed0 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,3 @@ +mod format; +pub mod plot; +pub mod stats; diff --git a/src/main.rs b/src/main.rs index 962f2d9..1a01f59 100644 --- a/src/main.rs +++ b/src/main.rs @@ -115,18 +115,9 @@ fn histogram(matches: &ArgMatches) -> i32 { } else { Some(precision_arg as usize) }; - let stats = stats::Stats::new(&vec, precision); let width = matches.value_of_t("width").unwrap(); - let mut intervals: usize = matches.value_of_t("intervals").unwrap(); - - intervals = intervals.min(vec.len()); - let mut histogram = plot::Histogram::new( - intervals, - (stats.max - stats.min) / intervals as f64, - stats, - precision, - ); - histogram.load(&vec); + let intervals: usize = matches.value_of_t("intervals").unwrap(); + let histogram = plot::Histogram::new(&vec, intervals, precision); print!("{:width$}", histogram, width = width); 0 } @@ -147,13 +138,12 @@ fn plot(matches: &ArgMatches) -> i32 { } else { Some(precision_arg as usize) }; - let mut plot = plot::XyPlot::new( + let plot = plot::XyPlot::new( + &vec, matches.value_of_t("width").unwrap(), matches.value_of_t("height").unwrap(), - stats::Stats::new(&vec, precision), precision, ); - plot.load(&vec); print!("{}", plot); 0 } @@ -235,8 +225,7 @@ fn timehist(matches: &ArgMatches) -> i32 { let reader = builder.build().unwrap(); let vec = reader.read(matches.value_of("input").unwrap()); if assert_data(&vec, 2) { - let mut timehist = plot::TimeHistogram::new(matches.value_of_t("intervals").unwrap(), &vec); - timehist.load(&vec); + let timehist = plot::TimeHistogram::new(matches.value_of_t("intervals").unwrap(), &vec); print!("{:width$}", timehist, width = width); }; 0 diff --git a/src/plot/histogram.rs b/src/plot/histogram.rs index 1b61870..2f556e2 100644 --- a/src/plot/histogram.rs +++ b/src/plot/histogram.rs @@ -7,6 +7,7 @@ use crate::format::F64Formatter; use crate::stats::Stats; #[derive(Debug)] +/// A struct that represents a bucket of an histogram. struct Bucket { range: Range, count: usize, @@ -23,6 +24,7 @@ impl Bucket { } #[derive(Debug)] +/// A struct holding data to plot a Histogram of numerical data. pub struct Histogram { vec: Vec, max: f64, @@ -34,7 +36,35 @@ pub struct Histogram { } impl Histogram { - pub fn new(size: usize, step: f64, stats: Stats, precision: Option) -> Histogram { + /// Creates a Histogram from a vector of numerical data. + /// + /// `intervals` is the number of histogram buckets to display (capped to the + /// length of input data). + /// + /// `precision` is an Option with the number of decimals to display. If + /// "None" is used, human units will be used, with an heuristic based on the + /// input data for deciding the units and the decimal places. + pub fn new(vec: &[f64], intervals: usize, precision: Option) -> Histogram { + let stats = Stats::new(vec, precision); + let size = intervals.min(vec.len()); + let step = (stats.max - stats.min) / size as f64; + let mut histogram = Histogram::new_with_stats(size, step, stats, precision); + histogram.load(vec); + histogram + } + + /// Creates a Histogram with no input data. + /// + /// + /// Parameters are similar to those on the `new` method, but a parameter + /// named `stats` is needed to decide how future data (to be injected with + /// the load method) will be accommodated. + pub fn new_with_stats( + size: usize, + step: f64, + stats: Stats, + precision: Option, + ) -> Histogram { let mut vec = Vec::::with_capacity(size); let mut lower = stats.min; for _ in 0..size { @@ -52,12 +82,14 @@ impl Histogram { } } + /// Add to the `Histogram` data the values of a slice of numerical data. pub fn load(&mut self, vec: &[f64]) { for x in vec { self.add(*x); } } + /// Add to the `Histogram` a single piece of numerical data. pub fn add(&mut self, n: f64) { if let Some(slot) = self.find_slot(n) { self.vec[slot].inc(); @@ -158,7 +190,7 @@ mod tests { #[test] fn test_buckets() { let stats = Stats::new(&[-2.0, 14.0], None); - let mut hist = Histogram::new(8, 2.5, stats, None); + let mut hist = Histogram::new_with_stats(8, 2.5, stats, None); hist.load(&[ -1.0, -1.1, 2.0, 2.0, 2.1, -0.9, 11.0, 11.2, 1.9, 1.99, 1.98, 1.97, 1.96, ]); @@ -174,7 +206,7 @@ mod tests { #[test] fn test_buckets_bad_stats() { - let mut hist = Histogram::new(6, 1.0, Stats::new(&[-2.0, 4.0], None), None); + let mut hist = Histogram::new_with_stats(6, 1.0, Stats::new(&[-2.0, 4.0], None), None); hist.load(&[-1.0, 2.0, -1.0, 2.0, 10.0, 10.0, 10.0, -10.0]); assert_eq!(hist.top, 2); } @@ -182,7 +214,7 @@ mod tests { #[test] fn display_test() { let stats = Stats::new(&[-2.0, 14.0], None); - let mut hist = Histogram::new(8, 2.5, stats, Some(3)); + let mut hist = Histogram::new_with_stats(8, 2.5, stats, Some(3)); hist.load(&[ -1.0, -1.1, 2.0, 2.0, 2.1, -0.9, 11.0, 11.2, 1.9, 1.99, 1.98, 1.97, 1.96, ]); @@ -195,7 +227,7 @@ mod tests { #[test] fn display_test_bad_width() { - let mut hist = Histogram::new(8, 2.5, Stats::new(&[-2.0, 14.0], None), Some(3)); + let mut hist = Histogram::new_with_stats(8, 2.5, Stats::new(&[-2.0, 14.0], None), Some(3)); hist.load(&[ -1.0, -1.1, 2.0, 2.0, 2.1, -0.9, 11.0, 11.2, 1.9, 1.99, 1.98, 1.97, 1.96, ]); @@ -216,15 +248,7 @@ mod tests { 500000.0, 500000.0, ]; - let intervals = vector.len(); - let stats = Stats::new(vector, None); - let mut hist = Histogram::new( - intervals, - (stats.max - stats.min) / intervals as f64, - stats, - None, - ); - hist.load(vector); + let hist = Histogram::new(vector, vector.len(), None); Paint::disable(); let display = format!("{}", hist); assert!(display.contains("[-12.0 M .. -10.4 M] [4] ∎∎∎∎\n")); diff --git a/src/plot/matchbar.rs b/src/plot/matchbar.rs index a464357..90dcac5 100644 --- a/src/plot/matchbar.rs +++ b/src/plot/matchbar.rs @@ -3,6 +3,8 @@ use std::fmt; use yansi::Color::{Blue, Green, Red}; #[derive(Debug)] +/// A struct that represents a single match bar of a match bar histogram (a +/// bucket for a term/label). pub struct MatchBarRow { pub label: String, pub count: usize, @@ -24,6 +26,8 @@ impl MatchBarRow { } #[derive(Debug)] +/// A struct holding data to plot a MatchBar: a histogram of the number of +/// occurrences of a set of strings in some input dara. pub struct MatchBar { pub vec: Vec, top_values: usize, @@ -31,6 +35,7 @@ pub struct MatchBar { } impl MatchBar { + /// Creates a Histogram from a vector of MatchBarRow elements. pub fn new(vec: Vec) -> MatchBar { let mut top_lenght: usize = 0; let mut top_values: usize = 0; diff --git a/src/plot/splittimehist.rs b/src/plot/splittimehist.rs index 88f7dc4..449544b 100644 --- a/src/plot/splittimehist.rs +++ b/src/plot/splittimehist.rs @@ -31,6 +31,8 @@ impl TimeBucket { } #[derive(Debug)] +/// A struct holding data to plot a split time histogram, where the display +/// shows the frequency of selected terms over time. pub struct SplitTimeHistogram { vec: Vec, strings: Vec, @@ -42,6 +44,13 @@ pub struct SplitTimeHistogram { } impl SplitTimeHistogram { + /// Creates a SplitTimeHistogram from a vector of `strings` (the terms whose + /// frequency we want to display) and a vector of timestamps where the terms + /// appear. + /// + /// `size` is the number of time slots in the histogram. Parameter 'ts' is + /// a slice of tuples of DateTime (the timestamp of a term occurrence) and + /// the index of the term in the `strings` parameter. pub fn new( size: usize, strings: Vec, @@ -68,13 +77,18 @@ impl SplitTimeHistogram { sth } - fn load(&mut self, vec: &[(DateTime, usize)]) { + /// Add to the `SplitTimeHistogram` data the values of a slice of tuples of + /// DateTime (the timestamp of a term occurrence) and the index of the term + /// in the in the list of common terms. + pub fn load(&mut self, vec: &[(DateTime, usize)]) { for x in vec { self.add(x.0, x.1); } } - fn add(&mut self, ts: DateTime, index: usize) { + /// Add to the `SplitTimeHistogram` data another data point (a timestamp and + /// index of the term in the list of common terms). + pub fn add(&mut self, ts: DateTime, index: usize) { if let Some(slot) = self.find_slot(ts) { self.vec[slot].inc(index); } @@ -89,7 +103,7 @@ impl SplitTimeHistogram { } } - // Clippy gets badly confused necause self.strings and COLORS may have + // Clippy gets badly confused because self.strings and COLORS may have // different lengths #[allow(clippy::needless_range_loop)] fn fmt_row( diff --git a/src/plot/terms.rs b/src/plot/terms.rs index 2032cc4..85be93c 100644 --- a/src/plot/terms.rs +++ b/src/plot/terms.rs @@ -4,12 +4,20 @@ use std::fmt; use yansi::Color::{Blue, Green, Red}; #[derive(Debug)] +/// A struct holding data to plot a Histogram of the most frequent terms in an +/// arbitrary input. +/// +/// The struct is create empty and it will fill its data by calling its +/// `observe` method. pub struct CommonTerms { pub terms: HashMap, lines: usize, } impl CommonTerms { + /// Create and empty `CommonTerms`. + /// + /// `lines` is the number of lines to be displayed. pub fn new(lines: usize) -> CommonTerms { CommonTerms { terms: HashMap::new(), @@ -17,6 +25,7 @@ impl CommonTerms { } } + /// Observe a new "term". pub fn observe(&mut self, term: String) { *self.terms.entry(term).or_insert(0) += 1 } diff --git a/src/plot/timehist.rs b/src/plot/timehist.rs index 94e517b..cc4f8f0 100644 --- a/src/plot/timehist.rs +++ b/src/plot/timehist.rs @@ -22,6 +22,7 @@ impl TimeBucket { } #[derive(Debug)] +/// A struct holding data to plot a TimeHistogram of timestamp data. pub struct TimeHistogram { vec: Vec, min: DateTime, @@ -33,6 +34,9 @@ pub struct TimeHistogram { } impl TimeHistogram { + /// Creates a Histogram from a vector of DateTime elements. + /// + /// `size` is the number of histogram buckets to display. pub fn new(size: usize, ts: &[DateTime]) -> TimeHistogram { let mut vec = Vec::::with_capacity(size); let min = *ts.iter().min().unwrap(); @@ -42,7 +46,7 @@ impl TimeHistogram { for i in 0..size { vec.push(TimeBucket::new(min + (inc * i as i32))); } - TimeHistogram { + let mut timehist = TimeHistogram { vec, min, max, @@ -50,15 +54,23 @@ impl TimeHistogram { top: 0, last: size - 1, nanos: (max - min).num_microseconds().unwrap() as u64, - } + }; + timehist.load(ts); + timehist } + /// Add to the `TimeHistogram` data the values of a slice of DateTime + /// elements. Elements not in the initial range (the one passed to `new`) + /// will be silently discarded. pub fn load(&mut self, vec: &[DateTime]) { for x in vec { self.add(*x); } } + /// Add to the `TimeHistogram` another DateTime element. If element is not + /// in the initial range (the one passed to `new`), it will be silently + /// discarded. pub fn add(&mut self, ts: DateTime) { if let Some(slot) = self.find_slot(ts) { self.vec[slot].inc(); @@ -129,8 +141,7 @@ mod tests { vec.push(DateTime::parse_from_rfc3339("2022-04-15T04:25:00+00:00").unwrap()); vec.push(DateTime::parse_from_rfc3339("2022-04-15T04:25:00+00:00").unwrap()); vec.push(DateTime::parse_from_rfc3339("2023-04-15T04:25:00+00:00").unwrap()); - let mut th = TimeHistogram::new(3, &vec); - th.load(&vec); + let th = TimeHistogram::new(3, &vec); let display = format!("{}", th); assert!(display.contains("Matches: 5")); assert!(display.contains("represents a count of 1")); @@ -146,8 +157,7 @@ mod tests { vec.push(DateTime::parse_from_rfc3339("2022-04-15T04:25:00.001+00:00").unwrap()); vec.push(DateTime::parse_from_rfc3339("2022-04-15T04:25:00.002+00:00").unwrap()); vec.push(DateTime::parse_from_rfc3339("2022-04-15T04:25:00.006+00:00").unwrap()); - let mut th = TimeHistogram::new(4, &vec); - th.load(&vec); + let th = TimeHistogram::new(4, &vec); let display = format!("{}", th); assert!(display.contains("Matches: 3")); assert!(display.contains("represents a count of 1")); @@ -163,8 +173,7 @@ mod tests { let mut vec = Vec::>::new(); vec.push(DateTime::parse_from_rfc3339("2022-04-15T04:25:00.001+00:00").unwrap()); vec.push(DateTime::parse_from_rfc3339("2022-04-15T04:25:00.001+00:00").unwrap()); - let mut th = TimeHistogram::new(4, &vec); - th.load(&vec); + let th = TimeHistogram::new(4, &vec); let display = format!("{}", th); assert!(display.contains("Matches: 2")); assert!(display.contains("represents a count of 1")); diff --git a/src/plot/xy.rs b/src/plot/xy.rs index a58ccec..9149674 100644 --- a/src/plot/xy.rs +++ b/src/plot/xy.rs @@ -7,6 +7,7 @@ use crate::format::F64Formatter; use crate::stats::Stats; #[derive(Debug)] +/// A struct holding data to plot a XY graph. pub struct XyPlot { x_axis: Vec, y_axis: Vec, @@ -17,7 +18,36 @@ pub struct XyPlot { } impl XyPlot { - pub fn new(width: usize, height: usize, stats: Stats, precision: Option) -> XyPlot { + /// Creates a XyPlot from a vector of numerical data. + /// + /// `width` is the number of "columns" to display (capped to the length of + /// input data). The data in every column is the average of the y-values + /// that would be aggregated into the x-value of the column (every column + /// has a width of a character). + /// + /// `height` is the number of "rows" to display (every row has a height of a + /// character). + /// + /// `precision` is an Option with the number of decimals to display. If + /// "None" is used, human units will be used, with an heuristic based on the + /// input data for deciding the units and the decimal places. + pub fn new(vec: &[f64], width: usize, height: usize, precision: Option) -> XyPlot { + let mut plot = XyPlot::new_with_stats(width, height, Stats::new(vec, precision), precision); + plot.load(vec); + plot + } + + /// Creates a XyPlot with no input data. + /// + /// Parameters are similar to those on the `new` method, but a parameter + /// named `stats` is needed to decide how future data (to be injected with + /// the load method) will be accommodated. + pub fn new_with_stats( + width: usize, + height: usize, + stats: Stats, + precision: Option, + ) -> XyPlot { XyPlot { x_axis: Vec::with_capacity(width), y_axis: Vec::with_capacity(height), @@ -28,6 +58,7 @@ impl XyPlot { } } + /// Add to the `XyPlot` data the values of a slice of numerical data. pub fn load(&mut self, vec: &[f64]) { self.width = self.width.min(vec.len()); let num_chunks = vec.len() / self.width; @@ -103,7 +134,7 @@ mod tests { #[test] fn basic_test() { let stats = Stats::new(&[-1.0, 4.0], None); - let mut plot = XyPlot::new(3, 5, stats, Some(3)); + let mut plot = XyPlot::new_with_stats(3, 5, stats, Some(3)); 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); @@ -117,7 +148,7 @@ mod tests { #[test] fn display_test() { let stats = Stats::new(&[-1.0, 4.0], None); - let mut plot = XyPlot::new(3, 5, stats, Some(3)); + let mut plot = XyPlot::new_with_stats(3, 5, stats, Some(3)); plot.load(&[-1.0, 0.0, 1.0, 2.0, 3.0, 4.0, -1.0]); Paint::disable(); let display = format!("{}", plot); @@ -130,9 +161,7 @@ mod tests { #[test] fn display_test_human_units() { let vector = &[1000000.0, -1000000.0, -2000000.0, -4000000.0]; - let stats = Stats::new(vector, None); - let mut plot = XyPlot::new(3, 5, stats, None); - plot.load(vector); + let plot = XyPlot::new(vector, 3, 5, None); Paint::disable(); let display = format!("{}", plot); assert!(display.contains("[ 0 K] ● ")); diff --git a/src/stats/mod.rs b/src/stats/mod.rs index a4f08ae..6a26315 100644 --- a/src/stats/mod.rs +++ b/src/stats/mod.rs @@ -5,18 +5,30 @@ use yansi::Color::Blue; use crate::format::F64Formatter; #[derive(Debug)] +/// A struct holding statistical data regarding a unsorted set of numerical +/// values. pub struct Stats { + /// Minimum of the input values. pub min: f64, + /// Maximum of the input values. pub max: f64, + /// Average of the input values. pub avg: f64, + /// Standard deviation of the input values. pub std: f64, + /// Variance of the input values. pub var: f64, - pub sum: f64, + /// Number of samples of the input values. pub samples: usize, - pub precision: Option, // If None, then human friendly display will be used + precision: Option, // If None, then human friendly display will be used } impl Stats { + /// Creates a Stats struct from a vector of numerical data. + /// + /// `precision` is an Option with the number of decimals to display. If + /// "None" is used, human units will be used, with an heuristic based on the + /// input data for deciding the units and the decimal places. pub fn new(vec: &[f64], precision: Option) -> Stats { let mut max = vec[0]; let mut min = max; @@ -36,7 +48,6 @@ impl Stats { avg, std, var, - sum, samples: vec.len(), precision, } @@ -76,7 +87,6 @@ mod tests { fn basic_test() { let stats = Stats::new(&[1.1, 3.3, 2.2], Some(3)); 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);