use crate::data::IntoSeries;
use crate::mark::{Align, Area, Bars, Cells, Line, Points, Range, Text};
use crate::plot::Plot;
use crate::scale::{Colormap, NumberFormat};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub struct HistogramOptions {
pub max_bins: usize,
pub normalization: crate::stat::Normalization,
pub cumulative: bool,
}
impl HistogramOptions {
pub const fn new(max_bins: usize) -> HistogramOptions {
HistogramOptions {
max_bins,
normalization: crate::stat::Normalization::Count,
cumulative: false,
}
}
#[must_use]
pub const fn normalization(
mut self,
normalization: crate::stat::Normalization,
) -> HistogramOptions {
self.normalization = normalization;
self
}
#[must_use]
pub const fn cumulative(mut self, cumulative: bool) -> HistogramOptions {
self.cumulative = cumulative;
self
}
}
impl Default for HistogramOptions {
fn default() -> HistogramOptions {
HistogramOptions::new(60)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct Histogram2dOptions {
pub columns: usize,
pub rows: usize,
pub colormap: Colormap,
pub colorbar: bool,
}
impl Histogram2dOptions {
pub const fn new(columns: usize, rows: usize) -> Histogram2dOptions {
Histogram2dOptions {
columns,
rows,
colormap: Colormap::DEFAULT,
colorbar: true,
}
}
#[must_use]
pub fn colormap(mut self, colormap: Colormap) -> Histogram2dOptions {
self.colormap = colormap;
self
}
#[must_use]
pub const fn colorbar(mut self, visible: bool) -> Histogram2dOptions {
self.colorbar = visible;
self
}
}
impl Default for Histogram2dOptions {
fn default() -> Histogram2dOptions {
Histogram2dOptions::new(48, 32)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct HeatmapOptions {
pub colormap: Colormap,
pub colorbar: bool,
}
impl HeatmapOptions {
pub const fn new() -> HeatmapOptions {
HeatmapOptions {
colormap: Colormap::DEFAULT,
colorbar: true,
}
}
#[must_use]
pub fn colormap(mut self, colormap: Colormap) -> HeatmapOptions {
self.colormap = colormap;
self
}
#[must_use]
pub const fn colorbar(mut self, visible: bool) -> HeatmapOptions {
self.colorbar = visible;
self
}
}
impl Default for HeatmapOptions {
fn default() -> HeatmapOptions {
HeatmapOptions::new()
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
#[non_exhaustive]
pub struct TrendOptions {
pub band: Option<f64>,
pub band_samples: usize,
}
impl TrendOptions {
pub const fn new() -> TrendOptions {
TrendOptions {
band: None,
band_samples: 64,
}
}
#[must_use]
pub const fn band(mut self, multiplier: f64) -> TrendOptions {
self.band = Some(multiplier);
self
}
}
impl Default for TrendOptions {
fn default() -> TrendOptions {
TrendOptions::new()
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
#[non_exhaustive]
pub struct EcdfOptions {
pub band_alpha: Option<f64>,
}
impl EcdfOptions {
pub const fn new() -> EcdfOptions {
EcdfOptions { band_alpha: None }
}
#[must_use]
pub const fn band(mut self, alpha: f64) -> EcdfOptions {
self.band_alpha = Some(alpha);
self
}
}
impl Default for EcdfOptions {
fn default() -> EcdfOptions {
EcdfOptions::new()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub struct DensityOptions {
pub samples: usize,
pub kde: crate::stat::KdeOptions,
}
impl DensityOptions {
pub const fn new(samples: usize) -> DensityOptions {
DensityOptions {
samples,
kde: crate::stat::KdeOptions::new(),
}
}
#[must_use]
pub const fn kde(mut self, kde: crate::stat::KdeOptions) -> DensityOptions {
self.kde = kde;
self
}
}
impl Default for DensityOptions {
fn default() -> DensityOptions {
DensityOptions::new(256)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub struct ViolinOptions {
pub samples: usize,
pub kde: crate::stat::KdeOptions,
}
impl ViolinOptions {
pub const fn new(samples: usize) -> ViolinOptions {
ViolinOptions {
samples,
kde: crate::stat::KdeOptions::new(),
}
}
#[must_use]
pub const fn kde(mut self, kde: crate::stat::KdeOptions) -> ViolinOptions {
self.kde = kde;
self
}
}
impl Default for ViolinOptions {
fn default() -> ViolinOptions {
ViolinOptions::new(128)
}
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum ContourLevels {
Automatic(usize),
Explicit(Vec<f64>),
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct ContourOptions {
pub levels: ContourLevels,
pub colormap: Colormap,
}
impl ContourOptions {
pub const fn automatic(target: usize) -> ContourOptions {
ContourOptions {
levels: ContourLevels::Automatic(target),
colormap: Colormap::DEFAULT,
}
}
pub fn explicit(levels: impl IntoIterator<Item = f64>) -> ContourOptions {
ContourOptions {
levels: ContourLevels::Explicit(levels.into_iter().collect()),
colormap: Colormap::DEFAULT,
}
}
#[must_use]
pub fn colormap(mut self, colormap: Colormap) -> ContourOptions {
self.colormap = colormap;
self
}
}
impl Default for ContourOptions {
fn default() -> ContourOptions {
ContourOptions::automatic(7)
}
}
fn check_count(
count: usize,
minimum: usize,
what: &'static str,
minimum_detail: &'static str,
) -> crate::Result<()> {
if count < minimum {
return Err(crate::Error::InvalidParameter {
detail: minimum_detail,
});
}
if count > crate::stat::MAX_STAT_ELEMENTS {
return Err(crate::Error::DimensionTooLarge {
what,
requested: count,
limit: crate::stat::MAX_STAT_ELEMENTS,
});
}
Ok(())
}
fn check_colormap(colormap: &Colormap) -> crate::Result<()> {
colormap.validate()
}
fn check_contour_coordinates(
value_count: usize,
columns: usize,
level_count: usize,
) -> crate::Result<()> {
let rows = value_count / columns;
let estimated = columns
.saturating_sub(1)
.checked_mul(rows.saturating_sub(1))
.and_then(|blocks| blocks.checked_mul(level_count))
.and_then(|crossings| crossings.checked_mul(6))
.unwrap_or(usize::MAX);
if estimated > crate::stat::MAX_STAT_ELEMENTS {
return Err(crate::Error::DimensionTooLarge {
what: "contour coordinate count",
requested: estimated,
limit: crate::stat::MAX_STAT_ELEMENTS,
});
}
Ok(())
}
pub fn line<'a>(values: impl IntoSeries<'a>) -> Plot<'a> {
Plot::new().layer(Line::y(values))
}
pub fn scatter<'a>(x: impl IntoSeries<'a>, y: impl IntoSeries<'a>) -> Plot<'a> {
Plot::new().layer(Points::xy(x, y))
}
pub fn sparkline<'a>(values: impl IntoSeries<'a>) -> Plot<'a> {
Plot::new().layer(Bars::spans(0.0, 1.0, values)).axes(false)
}
pub fn bar<'a>(
categories: impl IntoIterator<Item = impl Into<String>>,
values: impl IntoSeries<'a>,
) -> Plot<'a> {
Plot::new().layer(Bars::new(categories, values))
}
pub fn hist<'a>(values: impl IntoSeries<'a>) -> Plot<'a> {
hist_with(values, HistogramOptions::default()).expect("default histogram options are valid")
}
pub fn hist_with<'a>(
values: impl IntoSeries<'a>,
options: HistogramOptions,
) -> crate::Result<Plot<'a>> {
check_count(
options.max_bins,
1,
"histogram bin cap",
"histogram max_bins must be at least one",
)?;
let series = values.into_series();
Ok(
match crate::stat::Bins::try_auto(series.as_slice(), options.max_bins)? {
Some(bins) => {
let heights = bins.heights(options.normalization, options.cumulative);
let plot = Plot::new().layer(Bars::spans(bins.start(), bins.width(), heights));
histogram_axis(plot, options.normalization)
}
None => Plot::new(),
},
)
}
fn histogram_axis(plot: Plot<'_>, normalization: crate::stat::Normalization) -> Plot<'_> {
use crate::stat::Normalization;
match normalization {
Normalization::Count => plot.y_scale(crate::scale::Scale::Integer),
Normalization::Percent => plot.y_unit(crate::scale::Unit::suffix("%")),
Normalization::Probability | Normalization::Density => plot,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[non_exhaustive]
pub struct StairsOptions {
pub direction: crate::stat::StepDirection,
}
impl StairsOptions {
pub const fn new() -> StairsOptions {
StairsOptions {
direction: crate::stat::StepDirection::Post,
}
}
#[must_use]
pub const fn direction(mut self, direction: crate::stat::StepDirection) -> StairsOptions {
self.direction = direction;
self
}
}
pub fn stairs<'a>(values: impl IntoSeries<'a>) -> Plot<'a> {
stairs_with(values, StairsOptions::new())
}
pub fn stairs_with<'a>(values: impl IntoSeries<'a>, options: StairsOptions) -> Plot<'a> {
let series = values.into_series();
let indices: Vec<f64> = (0..series.len()).map(|index| index as f64).collect();
let (x, y) = crate::stat::steps(&indices, series.as_slice(), options.direction);
Plot::new().layer(Line::xy(x, y))
}
pub fn ecdf<'a>(values: impl IntoSeries<'a>) -> Plot<'a> {
ecdf_with(values, EcdfOptions::default()).expect("default ecdf options are valid")
}
pub fn ecdf_with<'a>(values: impl IntoSeries<'a>, options: EcdfOptions) -> crate::Result<Plot<'a>> {
if let Some(alpha) = options.band_alpha
&& !(alpha > 0.0 && alpha < 1.0)
{
return Err(crate::Error::InvalidParameter {
detail: "an ecdf band level must be strictly between 0 and 1",
});
}
let series = values.into_series();
let (sorted, fractions) = crate::stat::ecdf(series.as_slice());
let count = sorted.len();
let mut x = Vec::with_capacity(count * 2);
let mut y = Vec::with_capacity(count * 2);
let mut previous = 0.0f64;
for (value, fraction) in sorted.into_iter().zip(fractions) {
x.push(value);
y.push(previous);
x.push(value);
y.push(fraction);
previous = fraction;
}
let mut plot = Plot::new();
if let (Some(alpha), true) = (options.band_alpha, count > 0) {
let epsilon = ((2.0 / alpha).ln() / (2.0 * count as f64)).sqrt();
let low: Vec<f64> = y.iter().map(|f| (f - epsilon).max(0.0)).collect();
let high: Vec<f64> = y.iter().map(|f| (f + epsilon).min(1.0)).collect();
plot = plot.layer(Area::between(x.clone(), low, high));
}
Ok(plot.layer(Line::xy(x, y)))
}
pub fn heatmap<'a>(columns: usize, values: impl IntoSeries<'a>) -> Plot<'a> {
heatmap_with(columns, values, HeatmapOptions::default())
.expect("heatmap requires columns to divide the value count evenly")
}
pub fn heatmap_with<'a>(
columns: usize,
values: impl IntoSeries<'a>,
options: HeatmapOptions,
) -> crate::Result<Plot<'a>> {
check_colormap(&options.colormap)?;
let plot = Plot::new().layer(Cells::try_matrix(columns, values)?.colormap(options.colormap));
Ok(if options.colorbar {
plot.colorbar()
} else {
plot
})
}
pub fn hist2d<'a>(x: impl IntoSeries<'a>, y: impl IntoSeries<'a>) -> Plot<'a> {
hist2d_with(x, y, Histogram2dOptions::default())
.expect("hist2d requires series of equal length")
}
pub fn hist2d_with<'a>(
x: impl IntoSeries<'a>,
y: impl IntoSeries<'a>,
options: Histogram2dOptions,
) -> crate::Result<Plot<'a>> {
check_colormap(&options.colormap)?;
let xs = x.into_series();
let ys = y.into_series();
Ok(
match crate::stat::try_bins2(xs.as_slice(), ys.as_slice(), options.columns, options.rows)? {
Some(grid) => {
let counts: Vec<f64> = grid
.counts
.into_iter()
.map(|count| if count == 0.0 { f64::NAN } else { count })
.collect();
let cells = Cells::try_matrix(grid.columns, counts)?
.try_extents(grid.x, grid.y)?
.colormap(options.colormap);
let plot = Plot::new().layer(cells);
if options.colorbar {
plot.colorbar()
} else {
plot
}
}
None => Plot::new(),
},
)
}
pub fn contour<'a>(columns: usize, values: impl IntoSeries<'a>) -> Plot<'a> {
contour_with(columns, values, ContourOptions::default())
.expect("contour requires a rectangular grid and valid default options")
}
pub fn contour_with<'a>(
columns: usize,
values: impl IntoSeries<'a>,
options: ContourOptions,
) -> crate::Result<Plot<'a>> {
let series = values.into_series();
let Some((levels, (min, max))) = contour_levels(series.as_slice(), columns, options.levels)?
else {
return Ok(Plot::new());
};
check_colormap(&options.colormap)?;
let values: Vec<f64> = levels.iter().map(|(level, _)| *level).collect();
let mut plot = Plot::new();
for ((level, label), line) in
levels
.into_iter()
.zip(crate::stat::contours(series.as_slice(), columns, &values))
{
plot = plot.layer(
Line::xy(line.x, line.y).label(label).color(
options
.colormap
.color(options.colormap.position_in(level, min, max)),
),
);
}
Ok(plot)
}
pub fn contourf<'a>(columns: usize, values: impl IntoSeries<'a>) -> Plot<'a> {
contourf_with(columns, values, ContourOptions::default())
.expect("contourf requires a rectangular grid and valid default options")
}
pub fn contourf_with<'a>(
columns: usize,
values: impl IntoSeries<'a>,
options: ContourOptions,
) -> crate::Result<Plot<'a>> {
let series = values.into_series();
let Some((levels, _)) = contour_levels(series.as_slice(), columns, options.levels)? else {
return Ok(Plot::new());
};
let colormap = options
.colormap
.thresholds(levels.into_iter().map(|(level, _)| level));
heatmap_with(columns, series, HeatmapOptions::new().colormap(colormap))
}
#[allow(clippy::type_complexity)]
fn contour_levels(
values: &[f64],
columns: usize,
levels: ContourLevels,
) -> crate::Result<Option<(Vec<(f64, String)>, (f64, f64))>> {
use crate::scale::Ticks;
if columns == 0 {
return Err(crate::Error::EmptyDimension {
what: "contour columns",
});
}
if !values.len().is_multiple_of(columns) {
return Err(crate::Error::NonRectangular {
mark: "contour",
shape: (values.len(), columns),
});
}
let level_selection = match levels {
ContourLevels::Automatic(target) => {
check_count(
target,
2,
"contour automatic level target",
"contour automatic target must be at least two",
)?;
check_contour_coordinates(values.len(), columns, target)?;
ContourLevels::Automatic(target)
}
ContourLevels::Explicit(mut levels) => {
check_count(
levels.len(),
1,
"contour explicit level count",
"contour explicit levels must not be empty",
)?;
check_contour_coordinates(values.len(), columns, levels.len())?;
if levels.iter().any(|level| !level.is_finite()) {
return Err(crate::Error::InvalidParameter {
detail: "contour explicit levels must be finite",
});
}
levels.sort_by(f64::total_cmp);
levels.dedup();
ContourLevels::Explicit(levels)
}
};
let mut extent: Option<(f64, f64)> = None;
for &value in values {
if value.is_finite() {
let (low, high) = extent.get_or_insert((value, value));
*low = low.min(value);
*high = high.max(value);
}
}
let Some((min, max)) = extent.filter(|(low, high)| low < high) else {
return Ok(None);
};
let levels: Vec<(f64, String)> = match level_selection {
ContourLevels::Automatic(target) => Ticks::linear(min, max, target)
.iter()
.filter(|tick| tick.value > min && tick.value < max)
.map(|tick| (tick.value, tick.label.clone()))
.collect(),
ContourLevels::Explicit(levels) => levels
.into_iter()
.filter(|level| *level > min && *level < max)
.map(|level| (level, level.to_string()))
.collect(),
};
check_contour_coordinates(values.len(), columns, levels.len())?;
Ok(Some((levels, (min, max))))
}
pub fn quiver<'a>(
x: impl IntoSeries<'a>,
y: impl IntoSeries<'a>,
u: impl IntoSeries<'a>,
v: impl IntoSeries<'a>,
) -> Plot<'a> {
let (x, y) = (x.into_series(), y.into_series());
let (u, v) = (u.into_series(), v.into_series());
assert!(
x.len() == y.len() && y.len() == u.len() && u.len() == v.len(),
"quiver requires series of equal length"
);
let mut xs = Vec::with_capacity(x.len() * 9);
let mut ys = Vec::with_capacity(x.len() * 9);
let mut segment = |from: (f64, f64), to: (f64, f64)| {
xs.extend([from.0, to.0, f64::NAN]);
ys.extend([from.1, to.1, f64::NAN]);
};
for index in 0..x.len() {
let (x, y) = (x.as_slice()[index], y.as_slice()[index]);
let (u, v) = (u.as_slice()[index], v.as_slice()[index]);
if !(x.is_finite() && y.is_finite() && u.is_finite() && v.is_finite()) {
continue;
}
let tip = (x + u, y + v);
segment((x, y), tip);
let angle = v.atan2(u);
let reach = 0.3 * u.hypot(v);
for barb in [-1.0, 1.0] {
let theta = angle + barb * (std::f64::consts::PI * 5.0 / 6.0);
segment(
tip,
(tip.0 + reach * theta.cos(), tip.1 + reach * theta.sin()),
);
}
}
Plot::new().layer(Line::xy(xs, ys))
}
#[derive(Debug, Clone, Copy, PartialEq, Default)]
#[non_exhaustive]
pub struct BoxOptions {
pub whiskers: crate::stat::Whiskers,
}
impl BoxOptions {
pub const fn new() -> BoxOptions {
BoxOptions {
whiskers: crate::stat::Whiskers::Tukey(1.5),
}
}
#[must_use]
pub const fn whiskers(mut self, whiskers: crate::stat::Whiskers) -> BoxOptions {
self.whiskers = whiskers;
self
}
}
pub fn box_plot<'a>(
categories: impl IntoIterator<Item = impl Into<String>>,
groups: impl IntoIterator<Item = impl IntoSeries<'a>>,
) -> Plot<'a> {
box_plot_with(categories, groups, BoxOptions::new())
.expect("box_plot requires one category per group")
}
pub fn box_plot_with<'a>(
categories: impl IntoIterator<Item = impl Into<String>>,
groups: impl IntoIterator<Item = impl IntoSeries<'a>>,
options: BoxOptions,
) -> crate::Result<Plot<'a>> {
options.whiskers.validate()?;
let categories: Vec<String> = categories.into_iter().map(Into::into).collect();
let stats: Vec<Option<crate::stat::BoxStats>> = groups
.into_iter()
.map(|group| {
crate::stat::BoxStats::of_with(group.into_series().as_slice(), options.whiskers)
})
.collect();
if categories.len() != stats.len() {
return Err(crate::Error::UnequalChannels {
mark: "box_plot: categories and groups",
lengths: (categories.len(), stats.len()),
});
}
let pick = |f: &dyn Fn(&crate::stat::BoxStats) -> f64| -> Vec<f64> {
stats
.iter()
.map(|s| s.as_ref().map_or(f64::NAN, f))
.collect()
};
let mut outlier_x = Vec::new();
let mut outlier_y = Vec::new();
for (index, stat) in stats.iter().enumerate() {
if let Some(stat) = stat {
for &outlier in &stat.outliers {
outlier_x.push(index as f64);
outlier_y.push(outlier);
}
}
}
let plot = Plot::new().layer(
Range::over(
categories,
pick(&|s| s.whisker_low),
pick(&|s| s.whisker_high),
)
.body(pick(&|s| s.q1), pick(&|s| s.q3))
.marker(pick(&|s| s.median)),
);
Ok(if outlier_x.is_empty() {
plot
} else {
plot.layer(Points::xy(outlier_x, outlier_y))
})
}
pub fn error_bars<'a>(
x: impl IntoSeries<'a>,
y: impl IntoSeries<'a>,
error: impl IntoSeries<'a>,
) -> Plot<'a> {
let x = x.into_series();
let y = y.into_series();
let error = error.into_series();
assert!(
x.len() == y.len() && y.len() == error.len(),
"error_bars requires series of equal length"
);
let low: Vec<f64> = y.iter().zip(error.iter()).map(|(y, e)| y - e).collect();
let high: Vec<f64> = y.iter().zip(error.iter()).map(|(y, e)| y + e).collect();
let xs = x.as_slice().to_vec();
Plot::new()
.layer(Range::xy(xs.clone(), low, high))
.layer(Points::xy(xs, y.as_slice().to_vec()))
}
pub fn error_bars_asymmetric<'a>(
x: impl IntoSeries<'a>,
y: impl IntoSeries<'a>,
minus: impl IntoSeries<'a>,
plus: impl IntoSeries<'a>,
) -> Plot<'a> {
let x = x.into_series();
let y = y.into_series();
let minus = minus.into_series();
let plus = plus.into_series();
assert!(
x.len() == y.len() && y.len() == minus.len() && minus.len() == plus.len(),
"error_bars_asymmetric requires series of equal length"
);
let low: Vec<f64> = y.iter().zip(minus.iter()).map(|(y, e)| y - e).collect();
let high: Vec<f64> = y.iter().zip(plus.iter()).map(|(y, e)| y + e).collect();
let xs = x.as_slice().to_vec();
Plot::new()
.layer(Range::xy(xs.clone(), low, high))
.layer(Points::xy(xs, y.as_slice().to_vec()))
}
pub fn trend<'a>(x: impl IntoSeries<'a>, y: impl IntoSeries<'a>) -> Plot<'a> {
trend_with(x, y, TrendOptions::default()).expect("trend requires series of equal length")
}
pub fn trend_with<'a>(
x: impl IntoSeries<'a>,
y: impl IntoSeries<'a>,
options: TrendOptions,
) -> crate::Result<Plot<'a>> {
if let Some(multiplier) = options.band {
if !multiplier.is_finite() || multiplier <= 0.0 {
return Err(crate::Error::InvalidParameter {
detail: "a trend band multiplier must be finite and positive",
});
}
check_count(
options.band_samples,
2,
"trend band samples",
"a trend band needs at least two samples",
)?;
}
let x = x.into_series();
let y = y.into_series();
crate::mark::pair("trend: x and y", x.len(), y.len())?;
let fit = crate::stat::Fit::xy(x.as_slice(), y.as_slice());
let extent = x.iter().filter(|value| value.is_finite()).fold(
None,
|extent: Option<(f64, f64)>, value| {
Some(match extent {
Some((low, high)) => (low.min(value), high.max(value)),
None => (value, value),
})
},
);
let mut plot = Plot::new();
if let (Some((x0, x1)), Some(_)) = (extent, fit.slope()) {
if let (Some(multiplier), true) = (options.band, x1 > x0) {
let samples = options.band_samples;
let positions: Vec<f64> = (0..samples)
.map(|index| crate::numeric::lerp(x0, x1, index as f64 / (samples - 1) as f64))
.collect();
let band: Option<(Vec<f64>, Vec<f64>)> = positions
.iter()
.map(|&at| {
let center = fit.predict(at)?;
let error = fit.standard_error(at)?;
Some((center - multiplier * error, center + multiplier * error))
})
.collect();
if let Some((low, high)) = band {
plot = plot.layer(Area::between(positions, low, high));
}
}
let fitted = [fit.predict(x0), fit.predict(x1)];
if let [Some(y0), Some(y1)] = fitted {
plot = plot.layer(Line::xy(vec![x0, x1], vec![y0, y1]));
}
}
Ok(plot.layer(Points::xy(x.as_slice().to_vec(), y.as_slice().to_vec())))
}
pub fn density<'a>(values: impl IntoSeries<'a>) -> Plot<'a> {
density_with(values, DensityOptions::default()).expect("default density options are valid")
}
pub fn density_with<'a>(
values: impl IntoSeries<'a>,
options: DensityOptions,
) -> crate::Result<Plot<'a>> {
check_count(
options.samples,
2,
"density sample count",
"density samples must be at least two",
)?;
let series = values.into_series();
Ok(
match crate::stat::kde_with(series.as_slice(), options.samples, options.kde)? {
Some((positions, densities)) => Plot::new().layer(Line::xy(positions, densities)),
None => Plot::new(),
},
)
}
pub fn violin<'a>(
categories: impl IntoIterator<Item = impl Into<String>>,
groups: impl IntoIterator<Item = impl IntoSeries<'a>>,
) -> Plot<'a> {
violin_with(categories, groups, ViolinOptions::default())
.expect("violin requires one category per group and valid default options")
}
pub fn violin_with<'a>(
categories: impl IntoIterator<Item = impl Into<String>>,
groups: impl IntoIterator<Item = impl IntoSeries<'a>>,
options: ViolinOptions,
) -> crate::Result<Plot<'a>> {
check_count(
options.samples,
2,
"violin sample count",
"violin samples must be at least two",
)?;
let categories: Vec<String> = categories.into_iter().map(Into::into).collect();
let groups: Vec<_> = groups
.into_iter()
.map(|group| group.into_series())
.collect();
if categories.len() != groups.len() {
return Err(crate::Error::UnequalChannels {
mark: "violin: categories and groups",
lengths: (categories.len(), groups.len()),
});
}
let requested = groups.len().saturating_mul(options.samples);
if requested > crate::stat::MAX_STAT_ELEMENTS {
return Err(crate::Error::DimensionTooLarge {
what: "violin KDE sample count",
requested,
limit: crate::stat::MAX_STAT_ELEMENTS,
});
}
let kde = crate::stat::KdeOptions {
cumulative: false,
..options.kde
};
let densities: Vec<Option<(Vec<f64>, Vec<f64>)>> = groups
.iter()
.map(|group| crate::stat::kde_with(group.as_slice(), options.samples, kde))
.collect::<crate::Result<_>>()?;
let mut plot = Plot::new().x_scale(crate::scale::Scale::bands(categories));
for (index, density) in densities.into_iter().enumerate() {
let Some((positions, values)) = density else {
continue;
};
let peak = values.iter().copied().fold(f64::MIN_POSITIVE, f64::max);
let center = index as f64;
let half: Vec<f64> = values.iter().map(|v| v / peak * 0.35).collect();
let left: Vec<f64> = half.iter().map(|w| center - w).collect();
let right: Vec<f64> = half.iter().map(|w| center + w).collect();
plot = plot.layer(Area::horizontal(positions, left, right));
}
Ok(plot)
}
#[derive(Debug, Clone, PartialEq, Default)]
#[non_exhaustive]
pub struct TableOptions {
pub colormap: Option<Colormap>,
}
impl TableOptions {
pub const fn new() -> TableOptions {
TableOptions { colormap: None }
}
#[must_use]
pub fn colormap(mut self, colormap: Colormap) -> TableOptions {
self.colormap = Some(colormap);
self
}
}
pub fn table<'a>(
rows: impl IntoIterator<Item = impl Into<String>>,
columns: impl IntoIterator<Item = impl Into<String>>,
values: impl IntoSeries<'a>,
) -> Plot<'static> {
try_table(rows, columns, values).expect("table requires one value per row × column")
}
pub fn try_table<'a>(
rows: impl IntoIterator<Item = impl Into<String>>,
columns: impl IntoIterator<Item = impl Into<String>>,
values: impl IntoSeries<'a>,
) -> crate::Result<Plot<'static>> {
table_with(rows, columns, values, TableOptions::new())
}
pub fn table_with<'a>(
rows: impl IntoIterator<Item = impl Into<String>>,
columns: impl IntoIterator<Item = impl Into<String>>,
values: impl IntoSeries<'a>,
options: TableOptions,
) -> crate::Result<Plot<'static>> {
let rows: Vec<String> = rows.into_iter().map(Into::into).collect();
let columns: Vec<String> = columns.into_iter().map(Into::into).collect();
let series = values.into_series();
let values = series.as_slice();
if columns.is_empty() {
return Err(crate::Error::EmptyDimension {
what: "table columns",
});
}
if rows.is_empty() {
return Err(crate::Error::EmptyDimension { what: "table rows" });
}
if !values.len().is_multiple_of(columns.len()) {
return Err(crate::Error::NonRectangular {
mark: "table",
shape: (values.len(), columns.len()),
});
}
if values.len() / columns.len() != rows.len() {
return Err(crate::Error::UnequalChannels {
mark: "table: row labels and value rows",
lengths: (rows.len(), values.len() / columns.len()),
});
}
let plot = Plot::new()
.x_scale(crate::scale::Scale::bands(
columns.iter().map(String::as_str),
))
.y_scale(crate::scale::Scale::bands(rows.iter().map(String::as_str)));
Ok(table_cells(
plot,
rows.len(),
columns.len(),
values,
options.colormap.as_ref(),
))
}
fn table_cells<'a>(
mut plot: Plot<'a>,
rows: usize,
columns: usize,
values: &[f64],
colormap: Option<&Colormap>,
) -> Plot<'a> {
for column in 0..columns {
let cells: Vec<f64> = (0..rows)
.map(|row| values[row * columns + column])
.collect();
let format = NumberFormat::for_values(&cells);
let labels: Vec<String> = cells.iter().map(|&value| format.format(value)).collect();
let width = labels
.iter()
.map(|label| label.chars().count())
.max()
.unwrap_or(0);
let extent = cells
.iter()
.copied()
.filter(|value| value.is_finite())
.fold(None, |extent: Option<(f64, f64)>, value| {
Some(extent.map_or((value, value), |(low, high)| {
(low.min(value), high.max(value))
}))
});
for (row, label) in labels.into_iter().enumerate() {
let mut text = Text::at(column as f64, row as f64, format!("{label:>width$}"))
.align(Align::Center);
if let (Some(colormap), Some((low, high))) = (colormap, extent)
&& cells[row].is_finite()
{
text = text.color(colormap.color(colormap.position_in(cells[row], low, high)));
}
plot = plot.layer(text);
}
}
plot
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[non_exhaustive]
pub struct DescribeOptions {
pub histogram: Option<usize>,
}
impl DescribeOptions {
pub const fn new() -> DescribeOptions {
DescribeOptions { histogram: None }
}
#[must_use]
pub const fn histogram(mut self, bins: usize) -> DescribeOptions {
self.histogram = Some(bins);
self
}
}
const MAX_INLINE_BINS: usize = 200;
const DESCRIBE_COLUMNS: [&str; 8] = ["count", "mean", "sd", "min", "p25", "p50", "p75", "max"];
pub fn describe<'a>(
names: impl IntoIterator<Item = impl Into<String>>,
groups: impl IntoIterator<Item = impl IntoSeries<'a>>,
) -> Plot<'static> {
describe_with(names, groups, DescribeOptions::new())
.expect("describe requires one name per group")
}
pub fn describe_with<'a>(
names: impl IntoIterator<Item = impl Into<String>>,
groups: impl IntoIterator<Item = impl IntoSeries<'a>>,
options: DescribeOptions,
) -> crate::Result<Plot<'static>> {
if let Some(bins) = options.histogram {
check_count(
bins,
1,
"describe histogram bins",
"a describe histogram needs at least one bin",
)?;
if bins > MAX_INLINE_BINS {
return Err(crate::Error::DimensionTooLarge {
what: "describe histogram bins",
requested: bins,
limit: MAX_INLINE_BINS,
});
}
}
let names: Vec<String> = names.into_iter().map(Into::into).collect();
let mut values = Vec::new();
let mut strips = Vec::new();
for group in groups {
let series = group.into_series();
let mut moments = crate::stat::Moments::new();
for &value in series.as_slice() {
moments.add(value);
}
let stats = crate::stat::BoxStats::of(series.as_slice());
let quartile = |pick: &dyn Fn(&crate::stat::BoxStats) -> f64| -> f64 {
stats.as_ref().map_or(f64::NAN, pick)
};
values.extend([
moments.count() as f64,
moments.mean().unwrap_or(f64::NAN),
moments.sample_standard_deviation().unwrap_or(f64::NAN),
moments.min().unwrap_or(f64::NAN),
quartile(&|s| s.q1),
quartile(&|s| s.median),
quartile(&|s| s.q3),
moments.max().unwrap_or(f64::NAN),
]);
if let Some(bins) = options.histogram {
strips.push(inline_histogram(series.as_slice(), bins)?);
}
}
if names.len() != values.len() / DESCRIBE_COLUMNS.len() {
return Err(crate::Error::UnequalChannels {
mark: "describe: names and groups",
lengths: (names.len(), values.len() / DESCRIBE_COLUMNS.len()),
});
}
let mut columns: Vec<&str> = DESCRIBE_COLUMNS.to_vec();
if options.histogram.is_some() {
columns.push("hist");
}
let rows = names.len();
let mut plot = Plot::new()
.x_scale(crate::scale::Scale::bands(columns.iter().copied()))
.y_scale(crate::scale::Scale::bands(names.iter().map(String::as_str)));
plot = table_cells(plot, rows, DESCRIBE_COLUMNS.len(), &values, None);
for (row, strip) in strips.into_iter().enumerate() {
plot = plot
.layer(Text::at(DESCRIBE_COLUMNS.len() as f64, row as f64, strip).align(Align::Center));
}
Ok(plot)
}
fn inline_histogram(values: &[f64], bins: usize) -> crate::Result<String> {
const RAMP: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
let Some(histogram) = crate::stat::Bins::try_uniform(values, bins)? else {
return Ok(" ".repeat(bins));
};
let fullest = histogram.counts().iter().copied().max().unwrap_or(0);
Ok(histogram
.counts()
.iter()
.map(|&count| {
if count == 0 || fullest == 0 {
' '
} else {
let level = (count as f64 / fullest as f64 * 8.0).ceil() as usize;
RAMP[level.clamp(1, 8) - 1]
}
})
.collect())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Color, Frame};
#[test]
fn a_custom_histogram_cap_matches_the_grammar() {
let values: Vec<f64> = (0..200).map(|index| (index % 37) as f64).collect();
let frame = Frame::plain(44, 10);
let actual = hist_with(&values[..], HistogramOptions::new(4))
.unwrap()
.render(&frame);
let bins = crate::stat::Bins::auto(&values, 4).unwrap();
let counts: Vec<f64> = bins.counts().iter().map(|&count| count as f64).collect();
let expected = Plot::new()
.layer(Bars::spans(bins.start(), bins.width(), counts))
.y_scale(crate::scale::Scale::Integer)
.render(&frame);
assert_eq!(actual, expected);
}
#[test]
fn normalized_histograms_match_the_grammar() {
use crate::stat::Normalization;
let values: Vec<f64> = (0..200).map(|index| (index % 37) as f64).collect();
let frame = Frame::plain(44, 10);
let bins = crate::stat::Bins::auto(&values, 6).unwrap();
let share = HistogramOptions::new(6)
.normalization(Normalization::Percent)
.cumulative(true);
let actual = hist_with(&values[..], share).unwrap().render(&frame);
let heights = bins.heights(Normalization::Percent, true);
let expected = Plot::new()
.layer(Bars::spans(bins.start(), bins.width(), heights))
.y_unit(crate::scale::Unit::suffix("%"))
.render(&frame);
assert_eq!(actual, expected);
assert!(actual.contains("100%"), "{actual}");
let density = HistogramOptions::new(6).normalization(Normalization::Density);
let actual = hist_with(&values[..], density).unwrap().render(&frame);
let heights = bins.heights(Normalization::Density, false);
let expected = Plot::new()
.layer(Bars::spans(bins.start(), bins.width(), heights))
.render(&frame);
assert_eq!(actual, expected);
}
#[test]
fn histograms_handle_the_full_finite_range_without_panicking() {
let plot = hist([-f64::MAX, f64::MAX]);
assert!(plot.validate().is_ok());
let _ = plot.render(&Frame::plain(40, 10));
assert!(matches!(
hist_with([-f64::MAX, f64::MAX], HistogramOptions::new(1)),
Err(crate::Error::InvalidParameter { .. })
));
}
#[test]
fn configured_grids_and_kdes_reach_the_generated_marks() {
let x = [0.0, 1.0, 2.0, 3.0];
let y = [0.0, 1.0, 0.5, 1.5];
let histogram = hist2d_with(
&x[..],
&y[..],
Histogram2dOptions::new(3, 2).colorbar(false),
)
.unwrap();
let debug = format!("{histogram:?}");
assert!(debug.contains("columns: 3"), "{debug}");
assert!(debug.contains("rows: 2"), "{debug}");
let density = density_with(&y[..], DensityOptions::new(24)).unwrap();
assert!(format!("{density:?}").contains("points: 24"));
let violin = violin_with(["one"], [&y[..]], ViolinOptions::new(20)).unwrap();
assert!(format!("{violin:?}").contains("points: 20"));
}
#[test]
fn explicit_contours_are_sorted_deduplicated_and_colored() {
let grid: Vec<f64> = (0..9).map(f64::from).collect();
let grayscale = Colormap::try_from_stops(vec![(0, 0, 0), (255, 255, 255)]).unwrap();
let plot = contour_with(
3,
&grid[..],
ContourOptions::explicit([6.0, 2.0, 6.0]).colormap(grayscale),
)
.unwrap();
let debug = format!("{plot:?}");
assert_eq!(debug.matches("Line {").count(), 2, "{debug}");
assert!(debug.contains(&format!("{:?}", Color::Rgb(34, 34, 34))));
assert!(debug.contains(&format!("{:?}", Color::Rgb(174, 174, 174))));
}
#[test]
fn invalid_preset_options_return_typed_errors() {
assert!(matches!(
hist_with([1.0], HistogramOptions::new(0)),
Err(crate::Error::InvalidParameter { .. })
));
assert!(matches!(
hist2d_with([1.0], [1.0], Histogram2dOptions::new(0, 2)),
Err(crate::Error::EmptyDimension { .. })
));
assert!(matches!(
heatmap_with(0, [1.0], HeatmapOptions::new()),
Err(crate::Error::EmptyDimension { .. })
));
assert!(matches!(
heatmap_with(2, [1.0, 2.0, 3.0], HeatmapOptions::new()),
Err(crate::Error::NonRectangular { .. })
));
assert!(matches!(
trend_with([1.0], [1.0, 2.0], TrendOptions::new()),
Err(crate::Error::UnequalChannels { .. })
));
assert!(matches!(
density_with([1.0], DensityOptions::new(1)),
Err(crate::Error::InvalidParameter { .. })
));
assert!(matches!(
violin_with(["a", "b"], [[1.0, 2.0]], ViolinOptions::default()),
Err(crate::Error::UnequalChannels { .. })
));
assert!(matches!(
contour_with(
2,
[0.0, 1.0, 2.0, 3.0],
ContourOptions::explicit([f64::NAN])
),
Err(crate::Error::InvalidParameter { .. })
));
assert!(matches!(
contour_with(2, [1.0; 4], ContourOptions::explicit([])),
Err(crate::Error::InvalidParameter { .. })
));
assert!(matches!(
contour_with(
2,
[0.0, 1.0, 2.0, 3.0],
ContourOptions::automatic(crate::stat::MAX_STAT_ELEMENTS)
),
Err(crate::Error::DimensionTooLarge { .. })
));
}
}