ailloli_ui_core/
chart.rs

1//! Backend-neutral chart domains, points, and automatic range calculation.
2
3/// A numeric domain used to map chart values.
4///
5/// [`Self::new`] and [`Self::sanitized`] replace non-finite input bounds and
6/// order them. They attempt to widen a degenerate domain by adding `1.0` to
7/// its maximum, but `f32` rounding can leave very large equal bounds unchanged.
8/// A span between finite bounds can also overflow to infinity.
9///
10/// # Examples
11///
12/// ```
13/// use ailloli_ui_core::ChartRange;
14///
15/// let range = ChartRange::new(10.0, 30.0);
16/// assert_eq!(range.fraction_for_value(20.0), 0.5);
17/// assert_eq!(range.value_for_fraction(0.25), 15.0);
18/// ```
19#[derive(Clone, Copy, Debug, PartialEq)]
20pub struct ChartRange {
21    /// Inclusive lower domain bound.
22    pub min: f32,
23    /// Inclusive upper domain bound.
24    pub max: f32,
25}
26
27impl Default for ChartRange {
28    fn default() -> Self {
29        Self { min: 0.0, max: 1.0 }
30    }
31}
32
33impl ChartRange {
34    /// Creates a sanitized range from two candidate bounds.
35    ///
36    /// Non-finite `min` and `max` become `0.0` and `1.0`, respectively;
37    /// reversed bounds are swapped; and a range no wider than
38    /// [`f32::EPSILON`] is expanded upward by adding `1.0` to its maximum.
39    /// At magnitudes where that increment is not representable, the bounds can
40    /// remain equal.
41    ///
42    /// # Examples
43    ///
44    /// ```
45    /// use ailloli_ui_core::ChartRange;
46    /// assert_eq!(ChartRange::new(4.0, 2.0), ChartRange { min: 2.0, max: 4.0 });
47    /// ```
48    pub fn new(min: f32, max: f32) -> Self {
49        Self { min, max }.sanitized()
50    }
51
52    /// Returns this range with finite, ascending bounds and attempts to make it
53    /// non-degenerate.
54    ///
55    /// This applies the same normalization as [`Self::new`].
56    ///
57    /// # Examples
58    ///
59    /// ```
60    /// use ailloli_ui_core::ChartRange;
61    /// assert_eq!(ChartRange { min: 3.0, max: 3.0 }.sanitized().max, 4.0);
62    /// ```
63    pub fn sanitized(self) -> Self {
64        let mut min = if self.min.is_finite() { self.min } else { 0.0 };
65        let mut max = if self.max.is_finite() { self.max } else { 1.0 };
66        if min > max {
67            std::mem::swap(&mut min, &mut max);
68        }
69        if (max - min).abs() <= f32::EPSILON {
70            max = min + 1.0;
71        }
72        Self { min, max }
73    }
74
75    /// Maps `value` into the inclusive normalized interval `0.0..=1.0`.
76    ///
77    /// Values outside the range are clamped. A non-finite value maps to `0.0`.
78    /// Extreme finite bounds can produce an infinite or zero intermediate span;
79    /// in that case IEEE-754 arithmetic can produce `NaN` instead of a fraction.
80    ///
81    /// # Examples
82    ///
83    /// ```
84    /// use ailloli_ui_core::ChartRange;
85    /// assert_eq!(ChartRange::new(10.0, 30.0).fraction_for_value(20.0), 0.5);
86    /// ```
87    pub fn fraction_for_value(self, value: f32) -> f32 {
88        let range = self.sanitized();
89        if !value.is_finite() {
90            return 0.0;
91        }
92        ((value - range.min) / (range.max - range.min)).clamp(0.0, 1.0)
93    }
94
95    /// Maps a normalized fraction back into this range.
96    ///
97    /// Fractions are clamped to `0.0..=1.0`; a non-finite fraction selects
98    /// [`Self::min`] after sanitization. Extreme finite bounds can overflow the
99    /// intermediate span, so the returned value can be non-finite.
100    ///
101    /// # Examples
102    ///
103    /// ```
104    /// use ailloli_ui_core::ChartRange;
105    /// assert_eq!(ChartRange::new(10.0, 30.0).value_for_fraction(0.25), 15.0);
106    /// ```
107    pub fn value_for_fraction(self, fraction: f32) -> f32 {
108        let range = self.sanitized();
109        let fraction = if fraction.is_finite() {
110            fraction.clamp(0.0, 1.0)
111        } else {
112            0.0
113        };
114        range.min + (range.max - range.min) * fraction
115    }
116}
117
118/// One point in a chart's logical data coordinate space.
119///
120/// # Examples
121///
122/// ```
123/// use ailloli_ui_core::ChartPoint;
124///
125/// let point = ChartPoint::new(2.0, 8.5);
126/// assert_eq!((point.x, point.y), (2.0, 8.5));
127/// ```
128#[derive(Clone, Copy, Debug, Default, PartialEq)]
129pub struct ChartPoint {
130    /// Horizontal data coordinate, without an implied unit.
131    pub x: f32,
132    /// Vertical data coordinate, without an implied unit.
133    pub y: f32,
134}
135
136impl ChartPoint {
137    /// Creates a point without normalizing or rejecting non-finite coordinates.
138    ///
139    /// # Examples
140    ///
141    /// ```
142    /// use ailloli_ui_core::ChartPoint;
143    /// assert_eq!(ChartPoint::new(1.0, 2.0), ChartPoint { x: 1.0, y: 2.0 });
144    /// ```
145    pub fn new(x: f32, y: f32) -> Self {
146        Self { x, y }
147    }
148}
149
150/// A named, ordered sequence of chart points.
151///
152/// # Examples
153///
154/// ```
155/// use ailloli_ui_core::ChartSeries;
156///
157/// let series = ChartSeries::from_values("latency", [4.0, 7.0]);
158/// assert_eq!(series.points[1].x, 1.0);
159/// assert_eq!(series.points[1].y, 7.0);
160/// ```
161#[derive(Clone, Debug, Default, PartialEq)]
162pub struct ChartSeries {
163    /// Consumer-facing series label; an empty label is allowed.
164    pub name: String,
165    /// Points in render and traversal order.
166    pub points: Vec<ChartPoint>,
167}
168
169impl ChartSeries {
170    /// Collects an ordered point iterator into a named series.
171    ///
172    /// # Examples
173    ///
174    /// ```
175    /// use ailloli_ui_core::{ChartPoint, ChartSeries};
176    /// let series = ChartSeries::new("cpu", [ChartPoint::new(1.0, 2.0)]);
177    /// assert_eq!(series.points.len(), 1);
178    /// ```
179    pub fn new(name: impl Into<String>, points: impl IntoIterator<Item = ChartPoint>) -> Self {
180        Self {
181            name: name.into(),
182            points: points.into_iter().collect(),
183        }
184    }
185
186    /// Builds a series whose `x` coordinates are zero-based input indices.
187    ///
188    /// Indices are converted to `f32`; values, including non-finite values, are
189    /// preserved as `y` coordinates.
190    ///
191    /// # Examples
192    ///
193    /// ```
194    /// use ailloli_ui_core::ChartSeries;
195    /// let series = ChartSeries::from_values("cpu", [2.0, 3.0]);
196    /// assert_eq!(series.points[1].x, 1.0);
197    /// ```
198    pub fn from_values(name: impl Into<String>, values: impl IntoIterator<Item = f32>) -> Self {
199        Self {
200            name: name.into(),
201            points: values
202                .into_iter()
203                .enumerate()
204                .map(|(idx, value)| ChartPoint::new(idx as f32, value))
205                .collect(),
206        }
207    }
208
209    /// Builds a series from `(x, y)` pairs without normalizing coordinates.
210    ///
211    /// # Examples
212    ///
213    /// ```
214    /// use ailloli_ui_core::ChartSeries;
215    /// let series = ChartSeries::from_xy("cpu", [(4.0, 8.0)]);
216    /// assert_eq!(series.points[0].y, 8.0);
217    /// ```
218    pub fn from_xy(name: impl Into<String>, points: impl IntoIterator<Item = (f32, f32)>) -> Self {
219        Self {
220            name: name.into(),
221            points: points
222                .into_iter()
223                .map(|(x, y)| ChartPoint::new(x, y))
224                .collect(),
225        }
226    }
227
228    /// Returns `true` when the series contains no points.
229    ///
230    /// # Examples
231    ///
232    /// ```
233    /// use ailloli_ui_core::ChartSeries;
234    /// assert!(ChartSeries::from_values("empty", []).is_empty());
235    /// ```
236    pub fn is_empty(&self) -> bool {
237        self.points.is_empty()
238    }
239}
240
241/// Computes a finite vertical domain across all supplied series.
242///
243/// Non-finite `y` coordinates are ignored. A non-empty positive-only or
244/// negative-only domain is expanded to include zero; empty input returns
245/// [`ChartRange::default`].
246///
247/// # Examples
248///
249/// ```
250/// use ailloli_ui_core::{auto_y_range, ChartRange, ChartSeries};
251///
252/// let series = ChartSeries::from_values("latency", [2.0, 5.0, 3.0]);
253/// assert_eq!(auto_y_range([&series]), ChartRange::new(0.0, 5.0));
254/// ```
255pub fn auto_y_range<'a>(series: impl IntoIterator<Item = &'a ChartSeries>) -> ChartRange {
256    let mut found = false;
257    let mut min = 0.0f32;
258    let mut max = 0.0f32;
259    for point in series
260        .into_iter()
261        .flat_map(|series| series.points.iter())
262        .filter(|point| point.y.is_finite())
263    {
264        if !found {
265            min = point.y;
266            max = point.y;
267            found = true;
268        } else {
269            min = min.min(point.y);
270            max = max.max(point.y);
271        }
272    }
273
274    if !found {
275        return ChartRange::default();
276    }
277    if min > 0.0 {
278        min = 0.0;
279    }
280    if max < 0.0 {
281        max = 0.0;
282    }
283    ChartRange::new(min, max)
284}
285
286/// Computes a finite horizontal domain across all supplied series.
287///
288/// Non-finite `x` coordinates are ignored. Unlike [`auto_y_range`], this
289/// function does not force zero into a non-empty domain. Empty input returns
290/// [`ChartRange::default`].
291///
292/// # Examples
293///
294/// ```
295/// use ailloli_ui_core::{auto_x_range, ChartRange, ChartSeries};
296///
297/// let series = ChartSeries::from_xy("samples", [(10.0, 1.0), (20.0, 2.0)]);
298/// assert_eq!(auto_x_range([&series]), ChartRange::new(10.0, 20.0));
299/// ```
300pub fn auto_x_range<'a>(series: impl IntoIterator<Item = &'a ChartSeries>) -> ChartRange {
301    let mut found = false;
302    let mut min = 0.0f32;
303    let mut max = 0.0f32;
304    for point in series
305        .into_iter()
306        .flat_map(|series| series.points.iter())
307        .filter(|point| point.x.is_finite())
308    {
309        if !found {
310            min = point.x;
311            max = point.x;
312            found = true;
313        } else {
314            min = min.min(point.x);
315            max = max.max(point.x);
316        }
317    }
318
319    if found {
320        ChartRange::new(min, max)
321    } else {
322        ChartRange::default()
323    }
324}
325
326#[cfg(test)]
327mod tests {
328    //! Covers range normalization, clamped mapping, and automatic domains.
329
330    use super::*;
331
332    #[test]
333    fn range_sanitizes_invalid_inputs() {
334        assert_eq!(
335            ChartRange::new(10.0, 0.0),
336            ChartRange {
337                min: 0.0,
338                max: 10.0
339            }
340        );
341        assert_eq!(
342            ChartRange::new(f32::NAN, f32::INFINITY),
343            ChartRange { min: 0.0, max: 1.0 }
344        );
345        assert_eq!(ChartRange::new(4.0, 4.0), ChartRange { min: 4.0, max: 5.0 });
346    }
347
348    #[test]
349    fn fraction_mapping_clamps() {
350        let range = ChartRange::new(-10.0, 30.0);
351        assert_eq!(range.fraction_for_value(-20.0), 0.0);
352        assert_eq!(range.fraction_for_value(50.0), 1.0);
353        assert!((range.fraction_for_value(10.0) - 0.5).abs() <= 0.001);
354        assert!((range.value_for_fraction(0.5) - 10.0).abs() <= 0.001);
355    }
356
357    #[test]
358    fn auto_domain_handles_empty_positive_and_negative_values() {
359        assert_eq!(auto_y_range([]), ChartRange::default());
360
361        let positive = ChartSeries::from_values("positive", [4.0, 8.0, 2.0]);
362        assert_eq!(auto_y_range([&positive]), ChartRange::new(0.0, 8.0));
363
364        let negative = ChartSeries::from_values("negative", [-4.0, -8.0, -2.0]);
365        assert_eq!(auto_y_range([&negative]), ChartRange::new(-8.0, 0.0));
366    }
367}