Skip to main content

plotkit_core/
series.rs

1//! Data series abstraction for chart input.
2
3/// A sequence of `f64` values representing one dimension of chart data.
4///
5/// `Series` is the canonical data container used throughout the charting
6/// pipeline. Users rarely construct one directly; instead they pass any
7/// type that implements [`IntoSeries`] (slices, vectors, arrays, ranges,
8/// etc.) and the conversion happens automatically.
9#[derive(Debug, Clone)]
10pub struct Series {
11    /// The underlying data values.
12    pub data: Vec<f64>,
13}
14
15impl Series {
16    /// Creates a new series from a vector of values.
17    ///
18    /// # Examples
19    ///
20    /// ```
21    /// use plotkit_core::series::Series;
22    ///
23    /// let s = Series::new(vec![1.0, 2.0, 3.0]);
24    /// assert_eq!(s.len(), 3);
25    /// ```
26    pub fn new(data: Vec<f64>) -> Self {
27        Self { data }
28    }
29
30    /// Returns the number of data points.
31    pub fn len(&self) -> usize {
32        self.data.len()
33    }
34
35    /// Returns `true` if the series contains no data points.
36    pub fn is_empty(&self) -> bool {
37        self.data.is_empty()
38    }
39
40    /// Returns the minimum finite value, or `None` if the series is empty
41    /// or contains no finite values.
42    ///
43    /// Non-finite values (`NaN`, `+Inf`, `-Inf`) are ignored.
44    pub fn min(&self) -> Option<f64> {
45        self.data
46            .iter()
47            .copied()
48            .filter(|v| v.is_finite())
49            .reduce(f64::min)
50    }
51
52    /// Returns the maximum finite value, or `None` if the series is empty
53    /// or contains no finite values.
54    ///
55    /// Non-finite values (`NaN`, `+Inf`, `-Inf`) are ignored.
56    pub fn max(&self) -> Option<f64> {
57        self.data
58            .iter()
59            .copied()
60            .filter(|v| v.is_finite())
61            .reduce(f64::max)
62    }
63
64    /// Returns `(min, max)` of the finite values, or `None` if the series
65    /// is empty or contains no finite values.
66    pub fn bounds(&self) -> Option<(f64, f64)> {
67        Some((self.min()?, self.max()?))
68    }
69}
70
71// ---------------------------------------------------------------------------
72// IntoSeries
73// ---------------------------------------------------------------------------
74
75/// Trait for types that can be converted into a [`Series`].
76///
77/// This is the primary entry-point for user data. Any function that accepts
78/// chart data should be generic over `impl IntoSeries` so that callers can
79/// pass slices, vectors, arrays, integer collections, or ranges without
80/// manual conversion.
81pub trait IntoSeries {
82    /// Converts this value into a [`Series`].
83    fn into_series(self) -> Series;
84}
85
86// -- f64 containers --------------------------------------------------------
87
88impl IntoSeries for Vec<f64> {
89    /// Zero-copy conversion from an owned `Vec<f64>`.
90    fn into_series(self) -> Series {
91        Series::new(self)
92    }
93}
94
95impl IntoSeries for &[f64] {
96    /// Clones the slice data into a new [`Series`].
97    fn into_series(self) -> Series {
98        Series::new(self.to_vec())
99    }
100}
101
102impl IntoSeries for &Vec<f64> {
103    /// Clones the vector data into a new [`Series`].
104    fn into_series(self) -> Series {
105        Series::new(self.clone())
106    }
107}
108
109impl<const N: usize> IntoSeries for [f64; N] {
110    /// Converts a fixed-size array of `f64` into a [`Series`].
111    fn into_series(self) -> Series {
112        Series::new(self.to_vec())
113    }
114}
115
116impl<const N: usize> IntoSeries for &[f64; N] {
117    /// Clones a fixed-size array reference into a [`Series`].
118    fn into_series(self) -> Series {
119        Series::new(self.to_vec())
120    }
121}
122
123// -- Identity --------------------------------------------------------------
124
125impl IntoSeries for Series {
126    /// Identity conversion — returns `self` unchanged.
127    fn into_series(self) -> Series {
128        self
129    }
130}
131
132// -- Range -----------------------------------------------------------------
133
134impl IntoSeries for std::ops::Range<i32> {
135    /// Converts an integer range into a [`Series`] of `f64` values.
136    ///
137    /// # Examples
138    ///
139    /// ```
140    /// use plotkit_core::series::IntoSeries;
141    ///
142    /// let s = (0..5).into_series();
143    /// assert_eq!(s.data, vec![0.0, 1.0, 2.0, 3.0, 4.0]);
144    /// ```
145    fn into_series(self) -> Series {
146        Series::new(self.map(|v| v as f64).collect())
147    }
148}
149
150// -- i32 containers --------------------------------------------------------
151
152impl IntoSeries for Vec<i32> {
153    /// Converts a `Vec<i32>` into a [`Series`] by casting each element to `f64`.
154    fn into_series(self) -> Series {
155        Series::new(self.into_iter().map(|v| v as f64).collect())
156    }
157}
158
159impl IntoSeries for &[i32] {
160    /// Converts an `i32` slice into a [`Series`] by casting each element to `f64`.
161    fn into_series(self) -> Series {
162        Series::new(self.iter().map(|&v| v as f64).collect())
163    }
164}
165
166// -- ndarray (feature-gated) -----------------------------------------------
167
168#[cfg(feature = "ndarray")]
169mod ndarray_impls {
170    use super::{IntoSeries, Series};
171    use ndarray::{Array1, ArrayView1};
172
173    // -- f64 (zero-copy where possible) ------------------------------------
174
175    impl IntoSeries for &Array1<f64> {
176        /// Converts a reference to an `Array1<f64>` into a [`Series`].
177        ///
178        /// Uses `as_slice()` for zero-copy access on contiguous arrays and
179        /// falls back to an iterator copy for non-standard memory layouts.
180        fn into_series(self) -> Series {
181            match self.as_slice() {
182                Some(slice) => Series::new(slice.to_vec()),
183                None => Series::new(self.iter().copied().collect()),
184            }
185        }
186    }
187
188    impl IntoSeries for Array1<f64> {
189        /// Converts an owned `Array1<f64>` into a [`Series`].
190        ///
191        /// Attempts to unwrap the underlying `Vec` without copying. Falls
192        /// back to a copy when the array is not in standard layout.
193        fn into_series(self) -> Series {
194            if self.is_standard_layout() {
195                let len = self.len();
196                let (vec, offset) = self.into_raw_vec_and_offset();
197                let offset = offset.unwrap_or(0);
198                if offset == 0 && vec.len() == len {
199                    Series::new(vec)
200                } else {
201                    Series::new(vec[offset..offset + len].to_vec())
202                }
203            } else {
204                Series::new(self.iter().copied().collect())
205            }
206        }
207    }
208
209    impl IntoSeries for ArrayView1<'_, f64> {
210        /// Converts an `ArrayView1<f64>` into a [`Series`].
211        ///
212        /// Uses `as_slice()` for contiguous views and falls back to
213        /// element-wise iteration for strided views.
214        fn into_series(self) -> Series {
215            match self.as_slice() {
216                Some(slice) => Series::new(slice.to_vec()),
217                None => Series::new(self.iter().copied().collect()),
218            }
219        }
220    }
221
222    // -- Cast types (f32, i32, i64) ----------------------------------------
223
224    macro_rules! impl_into_series_ndarray_cast {
225        ($t:ty) => {
226            impl IntoSeries for &Array1<$t> {
227                fn into_series(self) -> Series {
228                    Series::new(self.iter().map(|&v| v as f64).collect())
229                }
230            }
231
232            impl IntoSeries for Array1<$t> {
233                fn into_series(self) -> Series {
234                    Series::new(self.iter().map(|&v| v as f64).collect())
235                }
236            }
237
238            impl IntoSeries for ArrayView1<'_, $t> {
239                fn into_series(self) -> Series {
240                    Series::new(self.iter().map(|&v| v as f64).collect())
241                }
242            }
243        };
244    }
245
246    impl_into_series_ndarray_cast!(f32);
247    impl_into_series_ndarray_cast!(i32);
248    impl_into_series_ndarray_cast!(i64);
249}
250
251// -- polars (feature-gated) ------------------------------------------------
252
253#[cfg(feature = "polars")]
254mod polars_impls {
255    use super::{Categories, IntoCategories, IntoSeries, Series};
256    use polars::prelude::*;
257
258    /// Extracts numeric values from a Polars [`Series`](polars::prelude::Series),
259    /// casting to `f64`. Null entries become `f64::NAN`.
260    fn extract_numeric(series: &polars::prelude::Series) -> Vec<f64> {
261        let ca = series
262            .cast(&DataType::Float64)
263            .unwrap_or_else(|_| {
264                panic!(
265                    "plotkit-polars: cannot cast series {:?} (dtype {:?}) to Float64",
266                    series.name(),
267                    series.dtype()
268                )
269            });
270        let ca = ca
271            .f64()
272            .expect("cast to Float64 always yields f64 chunked array");
273        ca.into_iter().map(|opt| opt.unwrap_or(f64::NAN)).collect()
274    }
275
276    impl IntoSeries for &polars::prelude::Series {
277        /// Converts a borrowed Polars [`Series`](polars::prelude::Series) into
278        /// a plotkit [`Series`].
279        ///
280        /// Supports numeric dtypes: `Float64`, `Float32`, `Int32`, `Int64`,
281        /// `UInt32`, `UInt64`. Null values become `f64::NAN`.
282        fn into_series(self) -> Series {
283            Series::new(extract_numeric(self))
284        }
285    }
286
287    impl IntoSeries for polars::prelude::Series {
288        /// Converts an owned Polars [`Series`](polars::prelude::Series) into
289        /// a plotkit [`Series`]. Delegates to the borrowed implementation.
290        fn into_series(self) -> Series {
291            (&self).into_series()
292        }
293    }
294
295    impl IntoCategories for &polars::prelude::Series {
296        /// Converts a borrowed Polars string [`Series`](polars::prelude::Series)
297        /// into plotkit [`Categories`]. Null values become the string `"null"`.
298        fn into_categories(self) -> Categories {
299            let ca = self.str().unwrap_or_else(|_| {
300                panic!(
301                    "plotkit-polars: series {:?} (dtype {:?}) is not a string type",
302                    self.name(),
303                    self.dtype()
304                )
305            });
306            let labels: Vec<String> = ca
307                .into_iter()
308                .map(|opt| opt.unwrap_or("null").to_owned())
309                .collect();
310            Categories::new(labels)
311        }
312    }
313}
314
315// -- f32 containers --------------------------------------------------------
316
317impl IntoSeries for Vec<f32> {
318    /// Converts a `Vec<f32>` into a [`Series`] by casting each element to `f64`.
319    fn into_series(self) -> Series {
320        Series::new(self.into_iter().map(|v| v as f64).collect())
321    }
322}
323
324impl IntoSeries for &[f32] {
325    /// Converts an `f32` slice into a [`Series`] by casting each element to `f64`.
326    fn into_series(self) -> Series {
327        Series::new(self.iter().map(|&v| v as f64).collect())
328    }
329}
330
331// ---------------------------------------------------------------------------
332// Categories
333// ---------------------------------------------------------------------------
334
335/// Categorical labels for bar charts, pie charts, and other discrete-axis
336/// visualizations.
337///
338/// Use [`IntoCategories`] to convert common string collections into this
339/// type automatically.
340#[derive(Debug, Clone)]
341pub struct Categories {
342    /// The category labels.
343    pub labels: Vec<String>,
344}
345
346impl Categories {
347    /// Creates a new `Categories` from a vector of label strings.
348    pub fn new(labels: Vec<String>) -> Self {
349        Self { labels }
350    }
351
352    /// Returns the number of categories.
353    pub fn len(&self) -> usize {
354        self.labels.len()
355    }
356
357    /// Returns `true` if there are no categories.
358    pub fn is_empty(&self) -> bool {
359        self.labels.is_empty()
360    }
361}
362
363// ---------------------------------------------------------------------------
364// IntoCategories
365// ---------------------------------------------------------------------------
366
367/// Trait for types that can be converted into [`Categories`].
368pub trait IntoCategories {
369    /// Converts this value into [`Categories`].
370    fn into_categories(self) -> Categories;
371}
372
373impl IntoCategories for &[&str] {
374    /// Converts a slice of string slices into [`Categories`].
375    fn into_categories(self) -> Categories {
376        Categories::new(self.iter().map(|s| (*s).to_owned()).collect())
377    }
378}
379
380impl IntoCategories for Vec<String> {
381    /// Zero-copy conversion from an owned `Vec<String>`.
382    fn into_categories(self) -> Categories {
383        Categories::new(self)
384    }
385}
386
387impl IntoCategories for &[String] {
388    /// Clones the string slice into [`Categories`].
389    fn into_categories(self) -> Categories {
390        Categories::new(self.to_vec())
391    }
392}
393
394impl IntoCategories for Vec<&str> {
395    /// Converts a vector of string slices into [`Categories`].
396    fn into_categories(self) -> Categories {
397        Categories::new(self.into_iter().map(|s| s.to_owned()).collect())
398    }
399}
400
401// ---------------------------------------------------------------------------
402// Tests
403// ---------------------------------------------------------------------------
404
405#[cfg(test)]
406mod tests {
407    use super::*;
408
409    #[test]
410    fn series_from_vec_f64() {
411        let s = vec![1.0, 2.0, 3.0].into_series();
412        assert_eq!(s.len(), 3);
413        assert_eq!(s.data, vec![1.0, 2.0, 3.0]);
414    }
415
416    #[test]
417    fn series_from_slice_f64() {
418        let data: &[f64] = &[4.0, 5.0];
419        let s = data.into_series();
420        assert_eq!(s.data, vec![4.0, 5.0]);
421    }
422
423    #[test]
424    fn series_from_vec_ref() {
425        let v = vec![1.0, 2.0];
426        let s = (&v).into_series();
427        assert_eq!(s.data, vec![1.0, 2.0]);
428    }
429
430    #[test]
431    fn series_from_array() {
432        let s = [10.0, 20.0, 30.0].into_series();
433        assert_eq!(s.data, vec![10.0, 20.0, 30.0]);
434    }
435
436    #[test]
437    fn series_from_array_ref() {
438        let arr = [7.0, 8.0];
439        let s = (&arr).into_series();
440        assert_eq!(s.data, vec![7.0, 8.0]);
441    }
442
443    #[test]
444    fn series_identity() {
445        let original = Series::new(vec![1.0]);
446        let s = original.into_series();
447        assert_eq!(s.data, vec![1.0]);
448    }
449
450    #[test]
451    fn series_from_range() {
452        let s = (0..4).into_series();
453        assert_eq!(s.data, vec![0.0, 1.0, 2.0, 3.0]);
454    }
455
456    #[test]
457    fn series_from_vec_i32() {
458        let s = vec![1i32, 2, 3].into_series();
459        assert_eq!(s.data, vec![1.0, 2.0, 3.0]);
460    }
461
462    #[test]
463    fn series_from_slice_i32() {
464        let data: &[i32] = &[10, 20];
465        let s = data.into_series();
466        assert_eq!(s.data, vec![10.0, 20.0]);
467    }
468
469    #[test]
470    fn series_from_vec_f32() {
471        let s = vec![1.5f32, 2.5].into_series();
472        assert_eq!(s.data, vec![1.5f64, 2.5]);
473    }
474
475    #[test]
476    fn series_from_slice_f32() {
477        let data: &[f32] = &[0.1, 0.2];
478        let s = data.into_series();
479        assert_eq!(s.len(), 2);
480    }
481
482    #[test]
483    fn series_empty() {
484        let s = Series::new(vec![]);
485        assert!(s.is_empty());
486        assert_eq!(s.min(), None);
487        assert_eq!(s.max(), None);
488        assert_eq!(s.bounds(), None);
489    }
490
491    #[test]
492    fn series_min_max_bounds() {
493        let s = vec![3.0, 1.0, 4.0, 1.5, 9.0].into_series();
494        assert_eq!(s.min(), Some(1.0));
495        assert_eq!(s.max(), Some(9.0));
496        assert_eq!(s.bounds(), Some((1.0, 9.0)));
497    }
498
499    #[test]
500    fn series_min_max_ignores_nan() {
501        let s = vec![f64::NAN, 2.0, f64::INFINITY, 1.0, f64::NEG_INFINITY].into_series();
502        assert_eq!(s.min(), Some(1.0));
503        assert_eq!(s.max(), Some(2.0));
504    }
505
506    #[test]
507    fn categories_from_str_slice() {
508        let cats: &[&str] = &["a", "b", "c"];
509        let c = cats.into_categories();
510        assert_eq!(c.labels, vec!["a", "b", "c"]);
511    }
512
513    #[test]
514    fn categories_from_vec_string() {
515        let c = vec!["x".to_string(), "y".to_string()].into_categories();
516        assert_eq!(c.labels, vec!["x", "y"]);
517    }
518
519    #[test]
520    fn categories_from_string_slice() {
521        let v = vec!["p".to_string(), "q".to_string()];
522        let c = v.as_slice().into_categories();
523        assert_eq!(c.labels, vec!["p", "q"]);
524    }
525
526    #[test]
527    fn categories_from_vec_str_ref() {
528        let c = vec!["foo", "bar"].into_categories();
529        assert_eq!(c.labels, vec!["foo", "bar"]);
530        assert_eq!(c.len(), 2);
531        assert!(!c.is_empty());
532    }
533}