Skip to main content

rlvgl_widgets/
chart.rs

1//! LVGL-parity chart widget for data-series visualization (LPAR-14d).
2//!
3//! [`Chart`] draws one or more data series as [`Line`](ChartType::Line),
4//! [`Bar`](ChartType::Bar), or [`Scatter`](ChartType::Scatter) plots inside a
5//! configurable axis range and background grid.
6//!
7//! # Navigation
8//!
9//! A point cursor can be moved through the data set with
10//! [`navigate_cursor_left`](Chart::navigate_cursor_left) and
11//! [`navigate_cursor_right`](Chart::navigate_cursor_right). Wire these to
12//! `ObjectEvent::Key(Key::ArrowLeft/Right)` in a node handler; do not route
13//! key events inside `Widget::handle_event`.
14
15use alloc::vec;
16use alloc::vec::Vec;
17
18use rlvgl_core::draw::draw_widget_bg;
19use rlvgl_core::event::Event;
20use rlvgl_core::renderer::Renderer;
21use rlvgl_core::style::Style;
22use rlvgl_core::widget::{Color, Rect, Widget};
23
24// ---------------------------------------------------------------------------
25// ChartType
26// ---------------------------------------------------------------------------
27
28/// Visual representation for a [`Chart`] series.
29///
30/// Registration policy: **Specification Required** — new variants require a
31/// LPAR-14 §15 amendment.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum ChartType {
34    /// Connect consecutive points with line segments.
35    Line,
36    /// Draw one vertical bar per point per series.
37    Bar,
38    /// Draw one small filled square per point.
39    Scatter,
40}
41
42// ---------------------------------------------------------------------------
43// ChartAxis
44// ---------------------------------------------------------------------------
45
46/// Axis a series is plotted against.
47///
48/// `Secondary` is reserved and treated identically to `Primary` in v1.
49/// Registration policy: **Specification Required**.
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum ChartAxis {
52    /// Primary (left) Y axis. All v1 series use this.
53    Primary,
54    /// Secondary (right) Y axis — **deferred-Safe**; treated as `Primary` in v1.
55    Secondary,
56}
57
58// ---------------------------------------------------------------------------
59// SeriesId
60// ---------------------------------------------------------------------------
61
62/// Opaque identifier for a data series within a [`Chart`].
63///
64/// Returned by [`Chart::add_series`] and used for subsequent data operations.
65/// Registration policy: **Expert Review**.
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub struct SeriesId(pub u8);
68
69/// Sentinel [`SeriesId`] meaning "no series".
70pub const SERIES_NONE: SeriesId = SeriesId(u8::MAX);
71
72// ---------------------------------------------------------------------------
73// Internal series descriptor
74// ---------------------------------------------------------------------------
75
76struct SeriesDescriptor {
77    color: Color,
78    #[allow(dead_code)]
79    axis: ChartAxis,
80    points: Vec<i32>,
81}
82
83// ---------------------------------------------------------------------------
84// Chart
85// ---------------------------------------------------------------------------
86
87/// LVGL-parity chart widget.
88///
89/// Owns a list of named data series and draws them as [`ChartType::Line`],
90/// [`ChartType::Bar`], or [`ChartType::Scatter`] plots.
91///
92/// # Example
93///
94/// ```ignore
95/// let mut chart = Chart::new(bounds);
96/// let s = chart.add_series(Color(0, 120, 215, 255), ChartAxis::Primary);
97/// chart.set_points(s, &[10, 40, 20, 80, 60]);
98/// ```
99pub struct Chart {
100    bounds: Rect,
101    chart_type: ChartType,
102    y_min: i32,
103    y_max: i32,
104    point_count: usize,
105    h_div: u8,
106    v_div: u8,
107    series: Vec<SeriesDescriptor>,
108    /// Cursor index into the point axis (0-based); `None` = no cursor.
109    cursor_index: Option<usize>,
110    /// Background and border style.
111    pub style: Style,
112    /// Color used for the background grid div lines.
113    pub grid_color: Color,
114}
115
116impl Chart {
117    /// Create a chart with default settings:
118    /// [`ChartType::Line`], `y` range `0..=100`, 10 points, 5×5 div lines.
119    pub fn new(bounds: Rect) -> Self {
120        Self {
121            bounds,
122            chart_type: ChartType::Line,
123            y_min: 0,
124            y_max: 100,
125            point_count: 10,
126            h_div: 5,
127            v_div: 5,
128            series: Vec::new(),
129            cursor_index: None,
130            style: Style::default(),
131            grid_color: Color(180, 180, 180, 180),
132        }
133    }
134
135    // ── Type ─────────────────────────────────────────────────────────────
136
137    /// Set the chart drawing type.
138    pub fn set_type(&mut self, t: ChartType) {
139        self.chart_type = t;
140    }
141
142    /// Return the current chart type.
143    pub fn chart_type(&self) -> ChartType {
144        self.chart_type
145    }
146
147    // ── Series ────────────────────────────────────────────────────────────
148
149    /// Append a new series drawn in `color` against `axis`.
150    ///
151    /// The series starts with all points at `0`. Returns the series id.
152    pub fn add_series(&mut self, color: Color, axis: ChartAxis) -> SeriesId {
153        if self.series.len() >= u8::MAX as usize {
154            return SERIES_NONE;
155        }
156        let id = SeriesId(self.series.len() as u8);
157        self.series.push(SeriesDescriptor {
158            color,
159            axis,
160            points: vec![0; self.point_count],
161        });
162        id
163    }
164
165    /// Resize all series to `count` points.
166    ///
167    /// Existing values are preserved up to `count`; new slots are filled
168    /// with `0`.
169    pub fn set_point_count(&mut self, count: usize) {
170        self.point_count = count;
171        for s in &mut self.series {
172            s.points.resize(count, 0);
173        }
174        // Clamp cursor if needed.
175        if let Some(idx) = self.cursor_index {
176            if count == 0 {
177                self.cursor_index = None;
178            } else if idx >= count {
179                self.cursor_index = Some(count - 1);
180            }
181        }
182    }
183
184    /// Return the current point count per series.
185    pub fn point_count(&self) -> usize {
186        self.point_count
187    }
188
189    /// Set the value of one point in a series.
190    ///
191    /// Out-of-range `idx` is silently ignored.
192    pub fn set_point(&mut self, series: SeriesId, idx: usize, value: i32) {
193        if let Some(s) = self.series.get_mut(series.0 as usize)
194            && let Some(slot) = s.points.get_mut(idx)
195        {
196            *slot = value;
197        }
198    }
199
200    /// Return the value of one point, or `None` if out of range.
201    pub fn get_point(&self, series: SeriesId, idx: usize) -> Option<i32> {
202        self.series
203            .get(series.0 as usize)
204            .and_then(|s| s.points.get(idx).copied())
205    }
206
207    /// Bulk-replace a series' points from a slice (clamped to `point_count`).
208    pub fn set_points(&mut self, series: SeriesId, values: &[i32]) {
209        if let Some(s) = self.series.get_mut(series.0 as usize) {
210            let n = values.len().min(self.point_count);
211            s.points[..n].copy_from_slice(&values[..n]);
212        }
213    }
214
215    /// Update the draw color for an existing series.
216    pub fn set_series_color(&mut self, series: SeriesId, color: Color) {
217        if let Some(s) = self.series.get_mut(series.0 as usize) {
218            s.color = color;
219        }
220    }
221
222    // ── Axis range ────────────────────────────────────────────────────────
223
224    /// Set the displayed value range for `axis`.
225    ///
226    /// In v1, `axis` is ignored and all series share the same range.
227    pub fn set_axis_range(&mut self, _axis: ChartAxis, min: i32, max: i32) {
228        self.y_min = min;
229        self.y_max = max;
230    }
231
232    // ── Div lines ─────────────────────────────────────────────────────────
233
234    /// Set the number of horizontal and vertical background grid lines.
235    pub fn set_div_line_count(&mut self, h_div: u8, v_div: u8) {
236        self.h_div = h_div;
237        self.v_div = v_div;
238    }
239
240    /// Return horizontal div line count.
241    pub fn h_div_line_count(&self) -> u8 {
242        self.h_div
243    }
244
245    /// Return vertical div line count.
246    pub fn v_div_line_count(&self) -> u8 {
247        self.v_div
248    }
249
250    // ── Cursor ────────────────────────────────────────────────────────────
251
252    /// Return the current cursor index (0-based into the point axis), or
253    /// `None` if no cursor is active.
254    pub fn cursor_index(&self) -> Option<usize> {
255        self.cursor_index
256    }
257
258    /// Activate the cursor at the leftmost point.
259    pub fn activate_cursor(&mut self) {
260        if self.point_count > 0 {
261            self.cursor_index = Some(0);
262        }
263    }
264
265    /// Clear the cursor.
266    pub fn clear_cursor(&mut self) {
267        self.cursor_index = None;
268    }
269
270    /// Move the cursor one step to the left (wraps).
271    ///
272    /// Wire this to `ObjectEvent::Key(Key::ArrowLeft)` in a node handler.
273    pub fn navigate_cursor_left(&mut self) {
274        if self.point_count == 0 {
275            return;
276        }
277        let idx = self.cursor_index.unwrap_or(0);
278        self.cursor_index = Some(if idx == 0 {
279            self.point_count - 1
280        } else {
281            idx - 1
282        });
283    }
284
285    /// Move the cursor one step to the right (wraps).
286    ///
287    /// Wire this to `ObjectEvent::Key(Key::ArrowRight)` in a node handler.
288    pub fn navigate_cursor_right(&mut self) {
289        if self.point_count == 0 {
290            return;
291        }
292        let idx = self.cursor_index.unwrap_or(self.point_count - 1);
293        self.cursor_index = Some((idx + 1) % self.point_count);
294    }
295
296    // ── Private helpers ───────────────────────────────────────────────────
297
298    /// Map a data value to a Y pixel coordinate within `bounds`.
299    fn value_to_y(&self, value: i32) -> i32 {
300        let range = self.y_max - self.y_min;
301        if range == 0 {
302            return self.bounds.y + self.bounds.height / 2;
303        }
304        let clamped = value.clamp(self.y_min, self.y_max);
305        let frac_num = (clamped - self.y_min) as i64;
306        let frac_den = range as i64;
307        let h = self.bounds.height as i64;
308        let offset = (frac_num * h / frac_den) as i32;
309        self.bounds.y + self.bounds.height - offset
310    }
311
312    /// X pixel for point index `idx` (center of the slot).
313    fn point_to_x(&self, idx: usize) -> i32 {
314        if self.point_count == 0 {
315            return self.bounds.x;
316        }
317        let slot_w = self.bounds.width / self.point_count as i32;
318        let slot_w = slot_w.max(1);
319        self.bounds.x + idx as i32 * slot_w + slot_w / 2
320    }
321
322    fn draw_grid(&self, renderer: &mut dyn Renderer) {
323        let color = self.grid_color.with_alpha(self.style.alpha);
324        // Horizontal div lines
325        if self.h_div > 0 {
326            let step = self.bounds.height / (self.h_div as i32 + 1);
327            for i in 1..=self.h_div as i32 {
328                let y = self.bounds.y + i * step;
329                renderer.fill_rect(
330                    Rect {
331                        x: self.bounds.x,
332                        y,
333                        width: self.bounds.width,
334                        height: 1,
335                    },
336                    color,
337                );
338            }
339        }
340        // Vertical div lines
341        if self.v_div > 0 {
342            let step = self.bounds.width / (self.v_div as i32 + 1);
343            for i in 1..=self.v_div as i32 {
344                let x = self.bounds.x + i * step;
345                renderer.fill_rect(
346                    Rect {
347                        x,
348                        y: self.bounds.y,
349                        width: 1,
350                        height: self.bounds.height,
351                    },
352                    color,
353                );
354            }
355        }
356    }
357
358    fn draw_series(&self, renderer: &mut dyn Renderer) {
359        for s in &self.series {
360            let color = s.color.with_alpha(self.style.alpha);
361            match self.chart_type {
362                ChartType::Line => self.draw_line_series(renderer, s, color),
363                ChartType::Bar => self.draw_bar_series(renderer, s, color),
364                ChartType::Scatter => self.draw_scatter_series(renderer, s, color),
365            }
366        }
367    }
368
369    fn draw_line_series(&self, renderer: &mut dyn Renderer, s: &SeriesDescriptor, color: Color) {
370        if s.points.len() < 2 {
371            return;
372        }
373        for i in 0..s.points.len() - 1 {
374            let x0 = self.point_to_x(i);
375            let y0 = self.value_to_y(s.points[i]);
376            let x1 = self.point_to_x(i + 1);
377            let y1 = self.value_to_y(s.points[i + 1]);
378            renderer.stroke_line_aa(
379                rlvgl_core::raster::PointF {
380                    x: x0 as f32,
381                    y: y0 as f32,
382                },
383                rlvgl_core::raster::PointF {
384                    x: x1 as f32,
385                    y: y1 as f32,
386                },
387                1.0,
388                color,
389            );
390        }
391    }
392
393    fn draw_bar_series(&self, renderer: &mut dyn Renderer, s: &SeriesDescriptor, color: Color) {
394        if self.point_count == 0 {
395            return;
396        }
397        let slot_w = (self.bounds.width / self.point_count as i32).max(1);
398        let bar_w = (slot_w * 3 / 5).max(1);
399        let baseline_y = self.value_to_y(self.y_min.max(0).min(self.y_max));
400        for (i, &v) in s.points.iter().enumerate() {
401            let x = self.bounds.x + i as i32 * slot_w + (slot_w - bar_w) / 2;
402            let y = self.value_to_y(v);
403            let (top, height) = if y <= baseline_y {
404                (y, baseline_y - y)
405            } else {
406                (baseline_y, y - baseline_y)
407            };
408            if height > 0 {
409                renderer.fill_rect(
410                    Rect {
411                        x,
412                        y: top,
413                        width: bar_w,
414                        height,
415                    },
416                    color,
417                );
418            }
419        }
420    }
421
422    fn draw_scatter_series(&self, renderer: &mut dyn Renderer, s: &SeriesDescriptor, color: Color) {
423        for (i, &v) in s.points.iter().enumerate() {
424            let cx = self.point_to_x(i);
425            let cy = self.value_to_y(v);
426            renderer.fill_rect(
427                Rect {
428                    x: cx - 2,
429                    y: cy - 2,
430                    width: 5,
431                    height: 5,
432                },
433                color,
434            );
435        }
436    }
437
438    fn draw_cursor(&self, renderer: &mut dyn Renderer) {
439        let Some(idx) = self.cursor_index else {
440            return;
441        };
442        let cx = self.point_to_x(idx);
443        let cursor_color = Color(255, 80, 80, 200).with_alpha(self.style.alpha);
444        // Vertical crosshair
445        renderer.fill_rect(
446            Rect {
447                x: cx,
448                y: self.bounds.y,
449                width: 1,
450                height: self.bounds.height,
451            },
452            cursor_color,
453        );
454    }
455}
456
457impl Widget for Chart {
458    fn bounds(&self) -> Rect {
459        self.bounds
460    }
461
462    fn set_bounds(&mut self, bounds: Rect) {
463        self.bounds = bounds;
464    }
465
466    fn draw(&self, renderer: &mut dyn Renderer) {
467        // Part::MAIN — background
468        draw_widget_bg(renderer, self.bounds, &self.style);
469        // Part::ITEMS — grid
470        self.draw_grid(renderer);
471        // Part::INDICATOR — series data
472        self.draw_series(renderer);
473        // Part::CURSOR — point cursor
474        self.draw_cursor(renderer);
475    }
476
477    fn handle_event(&mut self, _event: &Event) -> bool {
478        false
479    }
480}
481
482// ---------------------------------------------------------------------------
483// Tests
484// ---------------------------------------------------------------------------
485
486#[cfg(test)]
487mod tests {
488    use super::*;
489
490    fn r(x: i32, y: i32, w: i32, h: i32) -> Rect {
491        Rect {
492            x,
493            y,
494            width: w,
495            height: h,
496        }
497    }
498
499    #[test]
500    fn add_series_returns_sequential_ids() {
501        let mut chart = Chart::new(r(0, 0, 100, 100));
502        let a = chart.add_series(Color(255, 0, 0, 255), ChartAxis::Primary);
503        let b = chart.add_series(Color(0, 255, 0, 255), ChartAxis::Primary);
504        assert_eq!(a, SeriesId(0));
505        assert_eq!(b, SeriesId(1));
506    }
507
508    #[test]
509    fn set_and_get_point() {
510        let mut chart = Chart::new(r(0, 0, 200, 100));
511        let s = chart.add_series(Color(0, 0, 255, 255), ChartAxis::Primary);
512        chart.set_point(s, 3, 42);
513        assert_eq!(chart.get_point(s, 3), Some(42));
514        assert_eq!(chart.get_point(s, 9), Some(0));
515    }
516
517    #[test]
518    fn set_points_bulk() {
519        let mut chart = Chart::new(r(0, 0, 200, 100));
520        let s = chart.add_series(Color(0, 0, 0, 255), ChartAxis::Primary);
521        chart.set_points(s, &[1, 2, 3, 4, 5]);
522        assert_eq!(chart.get_point(s, 0), Some(1));
523        assert_eq!(chart.get_point(s, 4), Some(5));
524    }
525
526    #[test]
527    fn value_to_y_mapping() {
528        let chart = Chart::new(r(0, 0, 100, 100));
529        // y_min=0 maps to bottom
530        assert_eq!(chart.value_to_y(0), 100);
531        // y_max=100 maps to top
532        assert_eq!(chart.value_to_y(100), 0);
533        // midpoint
534        assert_eq!(chart.value_to_y(50), 50);
535    }
536
537    #[test]
538    fn chart_type_dispatch() {
539        let mut chart = Chart::new(r(0, 0, 100, 100));
540        chart.set_type(ChartType::Bar);
541        assert_eq!(chart.chart_type(), ChartType::Bar);
542        chart.set_type(ChartType::Scatter);
543        assert_eq!(chart.chart_type(), ChartType::Scatter);
544    }
545
546    #[test]
547    fn cursor_navigation_wraps() {
548        let mut chart = Chart::new(r(0, 0, 100, 100));
549        chart.set_point_count(5);
550        chart.activate_cursor();
551        assert_eq!(chart.cursor_index(), Some(0));
552        chart.navigate_cursor_left();
553        assert_eq!(chart.cursor_index(), Some(4)); // wrapped
554        chart.navigate_cursor_right();
555        assert_eq!(chart.cursor_index(), Some(0)); // back
556    }
557
558    #[test]
559    fn set_point_count_clamps_cursor() {
560        let mut chart = Chart::new(r(0, 0, 100, 100));
561        chart.activate_cursor();
562        chart.navigate_cursor_right();
563        chart.navigate_cursor_right();
564        // cursor at 2
565        chart.set_point_count(2);
566        assert_eq!(chart.cursor_index(), Some(1));
567    }
568
569    #[test]
570    fn resize_via_set_bounds() {
571        let mut chart = Chart::new(r(0, 0, 100, 100));
572        chart.set_bounds(r(10, 20, 200, 150));
573        assert_eq!(chart.bounds(), r(10, 20, 200, 150));
574    }
575
576    #[test]
577    fn div_line_counts_stored() {
578        let mut chart = Chart::new(r(0, 0, 100, 100));
579        chart.set_div_line_count(3, 7);
580        assert_eq!(chart.h_div_line_count(), 3);
581        assert_eq!(chart.v_div_line_count(), 7);
582    }
583}