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.cast(&DataType::Float64).unwrap_or_else(|_| {
262            panic!(
263                "plotkit-polars: cannot cast series {:?} (dtype {:?}) to Float64",
264                series.name(),
265                series.dtype()
266            )
267        });
268        let ca = ca
269            .f64()
270            .expect("cast to Float64 always yields f64 chunked array");
271        ca.into_iter().map(|opt| opt.unwrap_or(f64::NAN)).collect()
272    }
273
274    impl IntoSeries for &polars::prelude::Series {
275        /// Converts a borrowed Polars [`Series`](polars::prelude::Series) into
276        /// a plotkit [`Series`].
277        ///
278        /// Supports numeric dtypes: `Float64`, `Float32`, `Int32`, `Int64`,
279        /// `UInt32`, `UInt64`. Null values become `f64::NAN`.
280        fn into_series(self) -> Series {
281            Series::new(extract_numeric(self))
282        }
283    }
284
285    impl IntoSeries for polars::prelude::Series {
286        /// Converts an owned Polars [`Series`](polars::prelude::Series) into
287        /// a plotkit [`Series`]. Delegates to the borrowed implementation.
288        fn into_series(self) -> Series {
289            (&self).into_series()
290        }
291    }
292
293    impl IntoCategories for &polars::prelude::Series {
294        /// Converts a borrowed Polars string [`Series`](polars::prelude::Series)
295        /// into plotkit [`Categories`]. Null values become the string `"null"`.
296        fn into_categories(self) -> Categories {
297            let ca = self.str().unwrap_or_else(|_| {
298                panic!(
299                    "plotkit-polars: series {:?} (dtype {:?}) is not a string type",
300                    self.name(),
301                    self.dtype()
302                )
303            });
304            let labels: Vec<String> = ca
305                .into_iter()
306                .map(|opt| opt.unwrap_or("null").to_owned())
307                .collect();
308            Categories::new(labels)
309        }
310    }
311}
312
313// -- f32 containers --------------------------------------------------------
314
315impl IntoSeries for Vec<f32> {
316    /// Converts a `Vec<f32>` into a [`Series`] by casting each element to `f64`.
317    fn into_series(self) -> Series {
318        Series::new(self.into_iter().map(|v| v as f64).collect())
319    }
320}
321
322impl IntoSeries for &[f32] {
323    /// Converts an `f32` slice into a [`Series`] by casting each element to `f64`.
324    fn into_series(self) -> Series {
325        Series::new(self.iter().map(|&v| v as f64).collect())
326    }
327}
328
329// ---------------------------------------------------------------------------
330// Categories
331// ---------------------------------------------------------------------------
332
333/// Categorical labels for bar charts, pie charts, and other discrete-axis
334/// visualizations.
335///
336/// Use [`IntoCategories`] to convert common string collections into this
337/// type automatically.
338#[derive(Debug, Clone)]
339pub struct Categories {
340    /// The category labels.
341    pub labels: Vec<String>,
342}
343
344impl Categories {
345    /// Creates a new `Categories` from a vector of label strings.
346    pub fn new(labels: Vec<String>) -> Self {
347        Self { labels }
348    }
349
350    /// Returns the number of categories.
351    pub fn len(&self) -> usize {
352        self.labels.len()
353    }
354
355    /// Returns `true` if there are no categories.
356    pub fn is_empty(&self) -> bool {
357        self.labels.is_empty()
358    }
359}
360
361// ---------------------------------------------------------------------------
362// IntoCategories
363// ---------------------------------------------------------------------------
364
365/// Trait for types that can be converted into [`Categories`].
366pub trait IntoCategories {
367    /// Converts this value into [`Categories`].
368    fn into_categories(self) -> Categories;
369}
370
371impl IntoCategories for &[&str] {
372    /// Converts a slice of string slices into [`Categories`].
373    fn into_categories(self) -> Categories {
374        Categories::new(self.iter().map(|s| (*s).to_owned()).collect())
375    }
376}
377
378impl IntoCategories for Vec<String> {
379    /// Zero-copy conversion from an owned `Vec<String>`.
380    fn into_categories(self) -> Categories {
381        Categories::new(self)
382    }
383}
384
385impl IntoCategories for &[String] {
386    /// Clones the string slice into [`Categories`].
387    fn into_categories(self) -> Categories {
388        Categories::new(self.to_vec())
389    }
390}
391
392impl IntoCategories for Vec<&str> {
393    /// Converts a vector of string slices into [`Categories`].
394    fn into_categories(self) -> Categories {
395        Categories::new(self.into_iter().map(|s| s.to_owned()).collect())
396    }
397}
398
399// ---------------------------------------------------------------------------
400// Tests
401// ---------------------------------------------------------------------------
402
403#[cfg(test)]
404mod tests {
405    use super::*;
406
407    #[test]
408    fn series_from_vec_f64() {
409        let s = vec![1.0, 2.0, 3.0].into_series();
410        assert_eq!(s.len(), 3);
411        assert_eq!(s.data, vec![1.0, 2.0, 3.0]);
412    }
413
414    #[test]
415    fn series_from_slice_f64() {
416        let data: &[f64] = &[4.0, 5.0];
417        let s = data.into_series();
418        assert_eq!(s.data, vec![4.0, 5.0]);
419    }
420
421    #[test]
422    fn series_from_vec_ref() {
423        let v = vec![1.0, 2.0];
424        let s = (&v).into_series();
425        assert_eq!(s.data, vec![1.0, 2.0]);
426    }
427
428    #[test]
429    fn series_from_array() {
430        let s = [10.0, 20.0, 30.0].into_series();
431        assert_eq!(s.data, vec![10.0, 20.0, 30.0]);
432    }
433
434    #[test]
435    fn series_from_array_ref() {
436        let arr = [7.0, 8.0];
437        let s = (&arr).into_series();
438        assert_eq!(s.data, vec![7.0, 8.0]);
439    }
440
441    #[test]
442    fn series_identity() {
443        let original = Series::new(vec![1.0]);
444        let s = original.into_series();
445        assert_eq!(s.data, vec![1.0]);
446    }
447
448    #[test]
449    fn series_from_range() {
450        let s = (0..4).into_series();
451        assert_eq!(s.data, vec![0.0, 1.0, 2.0, 3.0]);
452    }
453
454    #[test]
455    fn series_from_vec_i32() {
456        let s = vec![1i32, 2, 3].into_series();
457        assert_eq!(s.data, vec![1.0, 2.0, 3.0]);
458    }
459
460    #[test]
461    fn series_from_slice_i32() {
462        let data: &[i32] = &[10, 20];
463        let s = data.into_series();
464        assert_eq!(s.data, vec![10.0, 20.0]);
465    }
466
467    #[test]
468    fn series_from_vec_f32() {
469        let s = vec![1.5f32, 2.5].into_series();
470        assert_eq!(s.data, vec![1.5f64, 2.5]);
471    }
472
473    #[test]
474    fn series_from_slice_f32() {
475        let data: &[f32] = &[0.1, 0.2];
476        let s = data.into_series();
477        assert_eq!(s.len(), 2);
478    }
479
480    #[test]
481    fn series_empty() {
482        let s = Series::new(vec![]);
483        assert!(s.is_empty());
484        assert_eq!(s.min(), None);
485        assert_eq!(s.max(), None);
486        assert_eq!(s.bounds(), None);
487    }
488
489    #[test]
490    fn series_min_max_bounds() {
491        let s = vec![3.0, 1.0, 4.0, 1.5, 9.0].into_series();
492        assert_eq!(s.min(), Some(1.0));
493        assert_eq!(s.max(), Some(9.0));
494        assert_eq!(s.bounds(), Some((1.0, 9.0)));
495    }
496
497    #[test]
498    fn series_min_max_ignores_nan() {
499        let s = vec![f64::NAN, 2.0, f64::INFINITY, 1.0, f64::NEG_INFINITY].into_series();
500        assert_eq!(s.min(), Some(1.0));
501        assert_eq!(s.max(), Some(2.0));
502    }
503
504    #[test]
505    fn categories_from_str_slice() {
506        let cats: &[&str] = &["a", "b", "c"];
507        let c = cats.into_categories();
508        assert_eq!(c.labels, vec!["a", "b", "c"]);
509    }
510
511    #[test]
512    fn categories_from_vec_string() {
513        let c = vec!["x".to_string(), "y".to_string()].into_categories();
514        assert_eq!(c.labels, vec!["x", "y"]);
515    }
516
517    #[test]
518    fn categories_from_string_slice() {
519        let v = vec!["p".to_string(), "q".to_string()];
520        let c = v.as_slice().into_categories();
521        assert_eq!(c.labels, vec!["p", "q"]);
522    }
523
524    #[test]
525    fn categories_from_vec_str_ref() {
526        let c = vec!["foo", "bar"].into_categories();
527        assert_eq!(c.labels, vec!["foo", "bar"]);
528        assert_eq!(c.len(), 2);
529        assert!(!c.is_empty());
530    }
531}