1#![deny(missing_docs)]
30
31pub use polars;
33
34pub use plotkit_core::series::{IntoCategories, IntoSeries, Series};
36
37use plotkit_core::error::PlotError;
38use plotkit_core::figure::Figure;
39use polars::prelude::DataFrame;
40
41pub trait DataFramePlotExt {
60 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
75pub struct DfPlot<'a> {
79 df: &'a DataFrame,
80 width: u32,
81 height: u32,
82 color_by: Option<String>,
83}
84
85impl DfPlot<'_> {
86 pub fn size(mut self, width: u32, height: u32) -> Self {
88 self.width = width;
89 self.height = height;
90 self
91 }
92
93 pub fn color_by(mut self, column: &str) -> Self {
96 self.color_by = Some(column.to_string());
97 self
98 }
99
100 pub fn line(self, x: &str, y: &str) -> Result<Figure, PlotError> {
102 self.xy(x, y, XyKind::Line)
103 }
104
105 pub fn scatter(self, x: &str, y: &str) -> Result<Figure, PlotError> {
107 self.xy(x, y, XyKind::Scatter)
108 }
109
110 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 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 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
186fn 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
196fn 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#[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[test]
333 fn series_string_cast_produces_nan() {
334 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}