Skip to main content

plotkit_polars/
lib.rs

1//! Polars integration for plotkit.
2//!
3//! This crate activates Polars support in `plotkit-core`, providing
4//! [`IntoSeries`](plotkit_core::series::IntoSeries) and
5//! [`IntoCategories`](plotkit_core::series::IntoCategories) implementations
6//! for Polars [`Series`](polars::prelude::Series). Depend on this crate (or
7//! enable the `polars` feature on the `plotkit` umbrella crate) to pass
8//! Polars data directly to any plotkit charting function.
9//!
10//! # Supported types
11//!
12//! | Type | Conversion |
13//! |---|---|
14//! | `&Series` (numeric: f64, f32, i32, i64, u32, u64) | O(n) cast to `f64`, nulls become `NAN` |
15//! | `Series` (numeric, owned) | delegates to borrowed impl |
16//! | `&Series` (string / Utf8) | O(n) extraction to `Vec<String>`, nulls become `"null"` |
17//!
18//! # Examples
19//!
20//! ```
21//! use polars::prelude::*;
22//! use plotkit_core::series::IntoSeries;
23//!
24//! let s = Series::new("values".into(), &[1.0_f64, 2.0, 3.0]);
25//! let plotkit_series = (&s).into_series();
26//! assert_eq!(plotkit_series.data, vec![1.0, 2.0, 3.0]);
27//! ```
28
29#![deny(missing_docs)]
30
31// Re-export polars so downstream users can access the version we link against.
32pub use polars;
33
34// Re-export the core series types for convenience.
35pub use plotkit_core::series::{IntoCategories, IntoSeries, Series};
36
37use plotkit_core::error::PlotError;
38use plotkit_core::figure::Figure;
39use polars::prelude::DataFrame;
40
41/// Adds a pandas/seaborn-style `.plot()` accessor to a Polars [`DataFrame`].
42///
43/// The terminal methods return a [`Figure`]; save it via the umbrella crate's
44/// `FigureExt` (`use plotkit::prelude::*;` then `fig.save("out.png")?`).
45///
46/// ```
47/// use plotkit_polars::DataFramePlotExt;
48/// use plotkit_polars::polars::prelude::*;
49///
50/// let df = DataFrame::new(vec![
51///     Series::new("x".into(), &[1.0, 2.0, 3.0]).into(),
52///     Series::new("y".into(), &[1.0, 4.0, 9.0]).into(),
53/// ])
54/// .unwrap();
55///
56/// let fig = df.plot().line("x", "y").unwrap();
57/// assert_eq!(fig.num_axes(), 1);
58/// ```
59pub trait DataFramePlotExt {
60    /// Begins a plot built directly from this DataFrame's columns.
61    fn plot(&self) -> DfPlot<'_>;
62}
63
64impl DataFramePlotExt for DataFrame {
65    fn plot(&self) -> DfPlot<'_> {
66        DfPlot {
67            df: self,
68            width: 800,
69            height: 600,
70            color_by: None,
71        }
72    }
73}
74
75/// A builder produced by [`DataFramePlotExt::plot`]. Terminal methods
76/// (`line`, `scatter`, `hist`, `bar`) consume the builder and return a fully
77/// built [`Figure`], ready to `.save(...)` via the umbrella crate's `FigureExt`.
78pub struct DfPlot<'a> {
79    df: &'a DataFrame,
80    width: u32,
81    height: u32,
82    color_by: Option<String>,
83}
84
85impl DfPlot<'_> {
86    /// Sets the output figure size in pixels (default 800x600).
87    pub fn size(mut self, width: u32, height: u32) -> Self {
88        self.width = width;
89        self.height = height;
90        self
91    }
92
93    /// Splits the data into one labelled series per distinct value of `column`
94    /// (categorical grouping, like seaborn `hue`). Applies to `line`/`scatter`.
95    pub fn color_by(mut self, column: &str) -> Self {
96        self.color_by = Some(column.to_string());
97        self
98    }
99
100    /// Plots `y` against `x` as a line chart.
101    pub fn line(self, x: &str, y: &str) -> Result<Figure, PlotError> {
102        self.xy(x, y, XyKind::Line)
103    }
104
105    /// Plots `y` against `x` as a scatter chart.
106    pub fn scatter(self, x: &str, y: &str) -> Result<Figure, PlotError> {
107        self.xy(x, y, XyKind::Scatter)
108    }
109
110    /// Draws a histogram of a single numeric `column`.
111    pub fn hist(self, column: &str, bins: usize) -> Result<Figure, PlotError> {
112        let data = col_f64(self.df, column)?;
113        let mut fig = Figure::with_size(self.width, self.height);
114        let ax = fig.add_subplot(1, 1, 1);
115        ax.hist(data, bins)?.label(column);
116        ax.set_xlabel(column);
117        ax.set_ylabel("count");
118        Ok(fig)
119    }
120
121    /// Draws a bar chart with string categories from `category` and heights
122    /// from the numeric column `value`.
123    pub fn bar(self, category: &str, value: &str) -> Result<Figure, PlotError> {
124        let cats = col_str(self.df, category)?;
125        let vals = col_f64(self.df, value)?;
126        let mut fig = Figure::with_size(self.width, self.height);
127        let ax = fig.add_subplot(1, 1, 1);
128        ax.bar(cats, vals)?;
129        ax.set_xlabel(category);
130        ax.set_ylabel(value);
131        Ok(fig)
132    }
133
134    fn xy(self, x: &str, y: &str, kind: XyKind) -> Result<Figure, PlotError> {
135        let xs = col_f64(self.df, x)?;
136        let ys = col_f64(self.df, y)?;
137        let mut fig = Figure::with_size(self.width, self.height);
138        let ax = fig.add_subplot(1, 1, 1);
139
140        if let Some(group_col) = &self.color_by {
141            let keys = col_str(self.df, group_col)?;
142            // Group (x, y) rows by category value, preserving first-seen order.
143            let mut groups: Vec<(String, Vec<f64>, Vec<f64>)> = Vec::new();
144            for i in 0..xs.len().min(ys.len()).min(keys.len()) {
145                match groups.iter_mut().find(|g| g.0 == keys[i]) {
146                    Some(g) => {
147                        g.1.push(xs[i]);
148                        g.2.push(ys[i]);
149                    }
150                    None => groups.push((keys[i].clone(), vec![xs[i]], vec![ys[i]])),
151                }
152            }
153            for (name, gx, gy) in groups {
154                match kind {
155                    XyKind::Line => {
156                        ax.plot(gx, gy)?.label(&name);
157                    }
158                    XyKind::Scatter => {
159                        ax.scatter(gx, gy)?.label(&name);
160                    }
161                }
162            }
163            ax.legend();
164        } else {
165            match kind {
166                XyKind::Line => {
167                    ax.plot(xs, ys)?.label(y);
168                }
169                XyKind::Scatter => {
170                    ax.scatter(xs, ys)?.label(y);
171                }
172            }
173        }
174        ax.set_xlabel(x);
175        ax.set_ylabel(y);
176        Ok(fig)
177    }
178}
179
180#[derive(Clone, Copy)]
181enum XyKind {
182    Line,
183    Scatter,
184}
185
186/// Extracts a numeric column as `Vec<f64>` (nulls → NaN) via the `IntoSeries`
187/// bridge. Returns [`PlotError::UnknownColumn`] if the column is absent.
188fn col_f64(df: &DataFrame, name: &str) -> Result<Vec<f64>, PlotError> {
189    let column = df
190        .column(name)
191        .map_err(|_| PlotError::UnknownColumn(name.to_string()))?;
192    let series = column.as_materialized_series();
193    Ok(IntoSeries::into_series(series).data)
194}
195
196/// Extracts a string/categorical column as `Vec<String>` (nulls → "null").
197fn col_str(df: &DataFrame, name: &str) -> Result<Vec<String>, PlotError> {
198    let column = df
199        .column(name)
200        .map_err(|_| PlotError::UnknownColumn(name.to_string()))?;
201    let series = column.as_materialized_series();
202    let chunked = series
203        .str()
204        .map_err(|e| PlotError::UnknownColumn(format!("{name}: not a string column ({e})")))?;
205    Ok(chunked
206        .into_iter()
207        .map(|opt| opt.unwrap_or("null").to_string())
208        .collect())
209}
210
211// ---------------------------------------------------------------------------
212// Tests
213// ---------------------------------------------------------------------------
214
215#[cfg(test)]
216mod tests {
217    use plotkit_core::series::IntoCategories;
218    use plotkit_core::series::IntoSeries as PlotIntoSeries;
219    use polars::prelude::{
220        DataType, Float64Chunked, IntoSeries as PolarsIntoSeries, NamedFrom, Series, StringChunked,
221    };
222
223    // -- IntoSeries: f64 ---------------------------------------------------
224
225    #[test]
226    fn series_from_polars_f64() {
227        let s = Series::new("a".into(), &[1.0_f64, 2.5, 3.0]);
228        let ps = PlotIntoSeries::into_series(&s);
229        assert_eq!(ps.data, vec![1.0, 2.5, 3.0]);
230    }
231
232    #[test]
233    fn series_from_polars_f64_owned() {
234        let s = Series::new("a".into(), &[4.0_f64, 5.0]);
235        let ps = PlotIntoSeries::into_series(s);
236        assert_eq!(ps.data, vec![4.0, 5.0]);
237    }
238
239    // -- IntoSeries: f32 ---------------------------------------------------
240
241    #[test]
242    fn series_from_polars_f32() {
243        let s = Series::new("b".into(), &[1.5_f32, 2.5]);
244        let ps = PlotIntoSeries::into_series(&s);
245        assert_eq!(ps.data, vec![1.5_f64, 2.5]);
246    }
247
248    // -- IntoSeries: i32 ---------------------------------------------------
249
250    #[test]
251    fn series_from_polars_i32() {
252        let s = Series::new("c".into(), &[10_i32, 20, 30]);
253        let ps = PlotIntoSeries::into_series(&s);
254        assert_eq!(ps.data, vec![10.0, 20.0, 30.0]);
255    }
256
257    // -- IntoSeries: i64 ---------------------------------------------------
258
259    #[test]
260    fn series_from_polars_i64() {
261        let s = Series::new("d".into(), &[100_i64, 200]);
262        let ps = PlotIntoSeries::into_series(&s);
263        assert_eq!(ps.data, vec![100.0, 200.0]);
264    }
265
266    // -- IntoSeries: u32 ---------------------------------------------------
267
268    #[test]
269    fn series_from_polars_u32() {
270        let s = Series::new("e".into(), &[1_u32, 2, 3]);
271        let ps = PlotIntoSeries::into_series(&s);
272        assert_eq!(ps.data, vec![1.0, 2.0, 3.0]);
273    }
274
275    // -- IntoSeries: u64 ---------------------------------------------------
276
277    #[test]
278    fn series_from_polars_u64() {
279        let s = Series::new("f".into(), &[42_u64, 99]);
280        let ps = PlotIntoSeries::into_series(&s);
281        assert_eq!(ps.data, vec![42.0, 99.0]);
282    }
283
284    // -- IntoSeries: null handling -----------------------------------------
285
286    #[test]
287    fn series_null_values_become_nan() {
288        let ca = Float64Chunked::new("g".into(), &[Some(1.0), None, Some(3.0)]);
289        let s: Series = PolarsIntoSeries::into_series(ca);
290        let ps = PlotIntoSeries::into_series(&s);
291        assert_eq!(ps.data.len(), 3);
292        assert_eq!(ps.data[0], 1.0);
293        assert!(ps.data[1].is_nan());
294        assert_eq!(ps.data[2], 3.0);
295    }
296
297    // -- IntoSeries: empty -------------------------------------------------
298
299    #[test]
300    fn series_empty_polars() {
301        let s = Series::new_empty("empty".into(), &DataType::Float64);
302        let ps = PlotIntoSeries::into_series(&s);
303        assert!(ps.is_empty());
304    }
305
306    // -- IntoCategories: string series -------------------------------------
307
308    #[test]
309    fn categories_from_polars_str() {
310        let s = Series::new("labels".into(), &["foo", "bar", "baz"]);
311        let cats = IntoCategories::into_categories(&s);
312        assert_eq!(cats.labels, vec!["foo", "bar", "baz"]);
313    }
314
315    #[test]
316    fn categories_null_becomes_null_string() {
317        let ca = StringChunked::new("h".into(), &[Some("a"), None, Some("c")]);
318        let s: Series = PolarsIntoSeries::into_series(ca);
319        let cats = IntoCategories::into_categories(&s);
320        assert_eq!(cats.labels, vec!["a", "null", "c"]);
321    }
322
323    #[test]
324    fn categories_empty_polars() {
325        let s = Series::new_empty("empty".into(), &DataType::String);
326        let cats = IntoCategories::into_categories(&s);
327        assert!(cats.is_empty());
328    }
329
330    // -- Panic on wrong dtype ----------------------------------------------
331
332    #[test]
333    fn series_string_cast_produces_nan() {
334        // Polars casts non-numeric strings to null (NAN in plotkit).
335        let s = Series::new("bad".into(), &["not", "numeric"]);
336        let ps = PlotIntoSeries::into_series(&s);
337        assert_eq!(ps.data.len(), 2);
338        assert!(ps.data[0].is_nan());
339        assert!(ps.data[1].is_nan());
340    }
341
342    #[test]
343    #[should_panic(expected = "is not a string type")]
344    fn categories_panics_on_numeric_dtype() {
345        let s = Series::new("bad".into(), &[1.0_f64, 2.0]);
346        let _ = IntoCategories::into_categories(&s);
347    }
348}