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}