#![deny(missing_docs)]
pub use polars;
pub use plotkit_core::series::{IntoCategories, IntoSeries, Series};
use plotkit_core::error::PlotError;
use plotkit_core::figure::Figure;
use polars::prelude::DataFrame;
pub trait DataFramePlotExt {
fn plot(&self) -> DfPlot<'_>;
}
impl DataFramePlotExt for DataFrame {
fn plot(&self) -> DfPlot<'_> {
DfPlot {
df: self,
width: 800,
height: 600,
color_by: None,
}
}
}
pub struct DfPlot<'a> {
df: &'a DataFrame,
width: u32,
height: u32,
color_by: Option<String>,
}
impl DfPlot<'_> {
pub fn size(mut self, width: u32, height: u32) -> Self {
self.width = width;
self.height = height;
self
}
pub fn color_by(mut self, column: &str) -> Self {
self.color_by = Some(column.to_string());
self
}
pub fn line(self, x: &str, y: &str) -> Result<Figure, PlotError> {
self.xy(x, y, XyKind::Line)
}
pub fn scatter(self, x: &str, y: &str) -> Result<Figure, PlotError> {
self.xy(x, y, XyKind::Scatter)
}
pub fn hist(self, column: &str, bins: usize) -> Result<Figure, PlotError> {
let data = col_f64(self.df, column)?;
let mut fig = Figure::with_size(self.width, self.height);
let ax = fig.add_subplot(1, 1, 1);
ax.hist(data, bins)?.label(column);
ax.set_xlabel(column);
ax.set_ylabel("count");
Ok(fig)
}
pub fn bar(self, category: &str, value: &str) -> Result<Figure, PlotError> {
let cats = col_str(self.df, category)?;
let vals = col_f64(self.df, value)?;
let mut fig = Figure::with_size(self.width, self.height);
let ax = fig.add_subplot(1, 1, 1);
ax.bar(cats, vals)?;
ax.set_xlabel(category);
ax.set_ylabel(value);
Ok(fig)
}
fn xy(self, x: &str, y: &str, kind: XyKind) -> Result<Figure, PlotError> {
let xs = col_f64(self.df, x)?;
let ys = col_f64(self.df, y)?;
let mut fig = Figure::with_size(self.width, self.height);
let ax = fig.add_subplot(1, 1, 1);
if let Some(group_col) = &self.color_by {
let keys = col_str(self.df, group_col)?;
let mut groups: Vec<(String, Vec<f64>, Vec<f64>)> = Vec::new();
for i in 0..xs.len().min(ys.len()).min(keys.len()) {
match groups.iter_mut().find(|g| g.0 == keys[i]) {
Some(g) => {
g.1.push(xs[i]);
g.2.push(ys[i]);
}
None => groups.push((keys[i].clone(), vec![xs[i]], vec![ys[i]])),
}
}
for (name, gx, gy) in groups {
match kind {
XyKind::Line => {
ax.plot(gx, gy)?.label(&name);
}
XyKind::Scatter => {
ax.scatter(gx, gy)?.label(&name);
}
}
}
ax.legend();
} else {
match kind {
XyKind::Line => {
ax.plot(xs, ys)?.label(y);
}
XyKind::Scatter => {
ax.scatter(xs, ys)?.label(y);
}
}
}
ax.set_xlabel(x);
ax.set_ylabel(y);
Ok(fig)
}
}
#[derive(Clone, Copy)]
enum XyKind {
Line,
Scatter,
}
fn col_f64(df: &DataFrame, name: &str) -> Result<Vec<f64>, PlotError> {
let column = df
.column(name)
.map_err(|_| PlotError::UnknownColumn(name.to_string()))?;
let series = column.as_materialized_series();
Ok(IntoSeries::into_series(series).data)
}
fn col_str(df: &DataFrame, name: &str) -> Result<Vec<String>, PlotError> {
let column = df
.column(name)
.map_err(|_| PlotError::UnknownColumn(name.to_string()))?;
let series = column.as_materialized_series();
let chunked = series
.str()
.map_err(|e| PlotError::UnknownColumn(format!("{name}: not a string column ({e})")))?;
Ok(chunked
.into_iter()
.map(|opt| opt.unwrap_or("null").to_string())
.collect())
}
#[cfg(test)]
mod tests {
use plotkit_core::series::IntoCategories;
use plotkit_core::series::IntoSeries as PlotIntoSeries;
use polars::prelude::{
DataType, Float64Chunked, IntoSeries as PolarsIntoSeries, NamedFrom, Series, StringChunked,
};
#[test]
fn series_from_polars_f64() {
let s = Series::new("a".into(), &[1.0_f64, 2.5, 3.0]);
let ps = PlotIntoSeries::into_series(&s);
assert_eq!(ps.data, vec![1.0, 2.5, 3.0]);
}
#[test]
fn series_from_polars_f64_owned() {
let s = Series::new("a".into(), &[4.0_f64, 5.0]);
let ps = PlotIntoSeries::into_series(s);
assert_eq!(ps.data, vec![4.0, 5.0]);
}
#[test]
fn series_from_polars_f32() {
let s = Series::new("b".into(), &[1.5_f32, 2.5]);
let ps = PlotIntoSeries::into_series(&s);
assert_eq!(ps.data, vec![1.5_f64, 2.5]);
}
#[test]
fn series_from_polars_i32() {
let s = Series::new("c".into(), &[10_i32, 20, 30]);
let ps = PlotIntoSeries::into_series(&s);
assert_eq!(ps.data, vec![10.0, 20.0, 30.0]);
}
#[test]
fn series_from_polars_i64() {
let s = Series::new("d".into(), &[100_i64, 200]);
let ps = PlotIntoSeries::into_series(&s);
assert_eq!(ps.data, vec![100.0, 200.0]);
}
#[test]
fn series_from_polars_u32() {
let s = Series::new("e".into(), &[1_u32, 2, 3]);
let ps = PlotIntoSeries::into_series(&s);
assert_eq!(ps.data, vec![1.0, 2.0, 3.0]);
}
#[test]
fn series_from_polars_u64() {
let s = Series::new("f".into(), &[42_u64, 99]);
let ps = PlotIntoSeries::into_series(&s);
assert_eq!(ps.data, vec![42.0, 99.0]);
}
#[test]
fn series_null_values_become_nan() {
let ca = Float64Chunked::new("g".into(), &[Some(1.0), None, Some(3.0)]);
let s: Series = PolarsIntoSeries::into_series(ca);
let ps = PlotIntoSeries::into_series(&s);
assert_eq!(ps.data.len(), 3);
assert_eq!(ps.data[0], 1.0);
assert!(ps.data[1].is_nan());
assert_eq!(ps.data[2], 3.0);
}
#[test]
fn series_empty_polars() {
let s = Series::new_empty("empty".into(), &DataType::Float64);
let ps = PlotIntoSeries::into_series(&s);
assert!(ps.is_empty());
}
#[test]
fn categories_from_polars_str() {
let s = Series::new("labels".into(), &["foo", "bar", "baz"]);
let cats = IntoCategories::into_categories(&s);
assert_eq!(cats.labels, vec!["foo", "bar", "baz"]);
}
#[test]
fn categories_null_becomes_null_string() {
let ca = StringChunked::new("h".into(), &[Some("a"), None, Some("c")]);
let s: Series = PolarsIntoSeries::into_series(ca);
let cats = IntoCategories::into_categories(&s);
assert_eq!(cats.labels, vec!["a", "null", "c"]);
}
#[test]
fn categories_empty_polars() {
let s = Series::new_empty("empty".into(), &DataType::String);
let cats = IntoCategories::into_categories(&s);
assert!(cats.is_empty());
}
#[test]
fn series_string_cast_produces_nan() {
let s = Series::new("bad".into(), &["not", "numeric"]);
let ps = PlotIntoSeries::into_series(&s);
assert_eq!(ps.data.len(), 2);
assert!(ps.data[0].is_nan());
assert!(ps.data[1].is_nan());
}
#[test]
#[should_panic(expected = "is not a string type")]
fn categories_panics_on_numeric_dtype() {
let s = Series::new("bad".into(), &[1.0_f64, 2.0]);
let _ = IntoCategories::into_categories(&s);
}
}