Skip to main content

zest_widget/widget/
chart.rs

1//! Passive multi-series chart. Plots one or more borrowed integer series
2//! as line and/or bar graphs, auto-scaling the Y axis to the combined data
3//! range and fitting everything inside the arranged rect.
4//!
5//! Drawing uses only the existing renderer primitives:
6//! [`stroke_line`](zest_core::Renderer::stroke_line) for line series, axes,
7//! and gridlines; [`fill_rect`](zest_core::Renderer::fill_rect) for bars;
8//! and [`fill_circle`](zest_core::Renderer::fill_circle) for optional data
9//! point markers.
10//!
11//! Series data is borrowed (`&'a [i32]`) so the owner keeps it alive for
12//! the frame:
13//!
14//! ```ignore
15//! Chart::new()
16//!     .line_series(&temps)
17//!     .bar_series(&counts)
18//!     .axes(true)
19//!     .gridlines(4)
20//!     .points(true)
21//! ```
22
23use super::Widget;
24use alloc::vec::Vec;
25use core::marker::PhantomData;
26use embedded_graphics::{pixelcolor::PixelColor, prelude::*, primitives::Rectangle};
27use zest_core::{Constraints, Length, RenderError, Renderer, TouchPhase};
28use zest_theme::Theme;
29
30/// How a single series is rendered.
31#[derive(Copy, Clone, Debug, PartialEq, Eq)]
32enum SeriesKind {
33    /// Connected line through the data points.
34    Line,
35    /// Vertical bar per data point.
36    Bar,
37}
38
39/// One plotted data series.
40struct Series<'a, C> {
41    kind: SeriesKind,
42    data: &'a [i32],
43    /// Explicit color; `None` falls back to a theme color at draw time.
44    color: Option<C>,
45}
46
47/// A line/bar chart over one or more borrowed integer series.
48pub struct Chart<'a, C: PixelColor, M: Clone> {
49    rect: Rectangle,
50    series: Vec<Series<'a, C>>,
51    axes: bool,
52    gridlines: u32,
53    points: bool,
54    width: Length,
55    height: Length,
56    _phantom: PhantomData<M>,
57}
58
59impl<'a, C: PixelColor + 'a, M: Clone> Chart<'a, C, M> {
60    /// Create a new empty chart. Add series with [`Self::line_series`] /
61    /// [`Self::bar_series`]. Position and size are assigned by the parent
62    /// via `arrange`.
63    pub fn new() -> Self {
64        Self {
65            rect: Rectangle::zero(),
66            series: Vec::new(),
67            axes: false,
68            gridlines: 0,
69            points: false,
70            width: Length::Fill,
71            height: Length::Fill,
72            _phantom: PhantomData,
73        }
74    }
75
76    /// Add a line series. Color defaults to `theme.accent.base`.
77    #[must_use]
78    pub fn line_series(mut self, data: &'a [i32]) -> Self {
79        self.series.push(Series {
80            kind: SeriesKind::Line,
81            data,
82            color: None,
83        });
84        self
85    }
86
87    /// Add a line series with an explicit color.
88    #[must_use]
89    pub fn line_series_colored(mut self, data: &'a [i32], color: C) -> Self {
90        self.series.push(Series {
91            kind: SeriesKind::Line,
92            data,
93            color: Some(color),
94        });
95        self
96    }
97
98    /// Add a bar series. Color defaults to `theme.accent.base`.
99    #[must_use]
100    pub fn bar_series(mut self, data: &'a [i32]) -> Self {
101        self.series.push(Series {
102            kind: SeriesKind::Bar,
103            data,
104            color: None,
105        });
106        self
107    }
108
109    /// Add a bar series with an explicit color.
110    #[must_use]
111    pub fn bar_series_colored(mut self, data: &'a [i32], color: C) -> Self {
112        self.series.push(Series {
113            kind: SeriesKind::Bar,
114            data,
115            color: Some(color),
116        });
117        self
118    }
119
120    /// Draw left/bottom axis lines (default: off).
121    #[must_use]
122    pub fn axes(mut self, axes: bool) -> Self {
123        self.axes = axes;
124        self
125    }
126
127    /// Number of horizontal gridlines to draw (default: 0).
128    #[must_use]
129    pub fn gridlines(mut self, count: u32) -> Self {
130        self.gridlines = count;
131        self
132    }
133
134    /// Draw a small marker at each line-series data point
135    /// (default: off).
136    #[must_use]
137    pub fn points(mut self, points: bool) -> Self {
138        self.points = points;
139        self
140    }
141
142    /// Width sizing intent.
143    #[must_use]
144    pub fn width(mut self, width: impl Into<Length>) -> Self {
145        self.width = width.into();
146        self
147    }
148
149    /// Height sizing intent.
150    #[must_use]
151    pub fn height(mut self, height: impl Into<Length>) -> Self {
152        self.height = height.into();
153        self
154    }
155
156    /// Combined `(min, max)` across all series, or `None` if there is no
157    /// data. A flat range is widened by 1 so the divisor is never zero.
158    fn data_range(&self) -> Option<(i32, i32)> {
159        let mut min = i32::MAX;
160        let mut max = i32::MIN;
161        let mut any = false;
162        for s in &self.series {
163            for &v in s.data {
164                any = true;
165                min = min.min(v);
166                max = max.max(v);
167            }
168        }
169        if !any {
170            return None;
171        }
172        if min == max {
173            max = min + 1;
174        }
175        Some((min, max))
176    }
177
178    /// Map a data value to a screen Y inside `[top, bottom]` (inverted so
179    /// larger values sit higher on screen).
180    fn map_y(value: i32, min: i32, max: i32, top: i32, bottom: i32) -> i32 {
181        let span = (max - min) as i64;
182        let frac = ((value - min) as i64 * (bottom - top) as i64) / span;
183        bottom - frac as i32
184    }
185
186    /// Map a data index to a screen X inside `[left, right]`. With a single
187    /// point everything collapses to `left`.
188    fn map_x(index: usize, count: usize, left: i32, right: i32) -> i32 {
189        if count <= 1 {
190            return left;
191        }
192        left + ((index as i64 * (right - left) as i64) / (count as i64 - 1)) as i32
193    }
194}
195
196impl<'a, C: PixelColor + 'a, M: Clone> Default for Chart<'a, C, M> {
197    fn default() -> Self {
198        Self::new()
199    }
200}
201
202impl<'a, C: PixelColor + 'a, M: Clone> Widget<C, M> for Chart<'a, C, M> {
203    fn measure(&mut self, constraints: Constraints) -> Size {
204        let w = self
205            .width
206            .resolve(constraints.max.width, constraints.max.width);
207        let h = self
208            .height
209            .resolve(constraints.max.height, constraints.max.height);
210        constraints.clamp(Size::new(w, h))
211    }
212
213    fn preferred_size(&self) -> (Length, Length) {
214        (self.width, self.height)
215    }
216
217    fn arrange(&mut self, rect: Rectangle) {
218        self.rect = rect;
219    }
220
221    fn rect(&self) -> Rectangle {
222        self.rect
223    }
224
225    fn handle_touch(&mut self, _point: Point, _phase: TouchPhase) -> Option<M> {
226        None
227    }
228
229    fn draw<'t>(
230        &self,
231        renderer: &mut dyn Renderer<C>,
232        theme: &Theme<'t, C>,
233    ) -> Result<(), RenderError> {
234        // A 1px inset keeps strokes off the very edge of the slot.
235        let left = self.rect.top_left.x + 1;
236        let right = self.rect.top_left.x + self.rect.size.width as i32 - 2;
237        let top = self.rect.top_left.y + 1;
238        let bottom = self.rect.top_left.y + self.rect.size.height as i32 - 2;
239        if right <= left || bottom <= top {
240            return Ok(());
241        }
242
243        let grid = theme.background.divider;
244
245        // Horizontal gridlines, evenly spaced across the plot height.
246        if self.gridlines > 0 {
247            let n = self.gridlines;
248            for i in 0..=n {
249                let y = top + ((i as i64 * (bottom - top) as i64) / n as i64) as i32;
250                renderer.stroke_line(Point::new(left, y), Point::new(right, y), grid, 1)?;
251            }
252        }
253
254        // Axes (left + bottom).
255        if self.axes {
256            let axis = theme.background.on_base;
257            renderer.stroke_line(Point::new(left, top), Point::new(left, bottom), axis, 1)?;
258            renderer.stroke_line(Point::new(left, bottom), Point::new(right, bottom), axis, 1)?;
259        }
260
261        let Some((min, max)) = self.data_range() else {
262            return Ok(());
263        };
264
265        // Count bar series so multiple bar series can share each x-slot.
266        let bar_series_count = self
267            .series
268            .iter()
269            .filter(|s| s.kind == SeriesKind::Bar)
270            .count()
271            .max(1);
272        let mut bar_index = 0usize;
273
274        for s in &self.series {
275            if s.data.is_empty() {
276                continue;
277            }
278            let color = s.color.unwrap_or(theme.accent.base);
279            let count = s.data.len();
280            match s.kind {
281                SeriesKind::Line => {
282                    let mut prev: Option<Point> = None;
283                    for (i, &v) in s.data.iter().enumerate() {
284                        let x = Self::map_x(i, count, left, right);
285                        let y = Self::map_y(v, min, max, top, bottom);
286                        let p = Point::new(x, y);
287                        if let Some(prev) = prev {
288                            renderer.stroke_line(prev, p, color, 1)?;
289                        }
290                        if self.points {
291                            renderer.fill_circle(p, 2, color)?;
292                        }
293                        prev = Some(p);
294                    }
295                }
296                SeriesKind::Bar => {
297                    // Slot width per data point; bar series split each slot.
298                    let slot = ((right - left) / count.max(1) as i32).max(1);
299                    let group_w = (slot * 4 / 5).max(1);
300                    let bar_w = (group_w / bar_series_count as i32).max(1);
301                    for (i, &v) in s.data.iter().enumerate() {
302                        let slot_left = left + i as i32 * slot;
303                        let bx = slot_left + bar_index as i32 * bar_w;
304                        let y = Self::map_y(v, min, max, top, bottom);
305                        let h = (bottom - y).max(0) as u32;
306                        if h == 0 {
307                            continue;
308                        }
309                        renderer.fill_rect(
310                            Rectangle::new(Point::new(bx, y), Size::new(bar_w as u32, h)),
311                            color,
312                        )?;
313                    }
314                    bar_index += 1;
315                }
316            }
317        }
318        Ok(())
319    }
320}