Skip to main content

embassy_st7789v_plot/
lib.rs

1#![no_std]
2#![forbid(unsafe_code)]
3
4//! # embassy-st7789v-plot
5//!
6//! Moteur de tracé de graphiques cartésiens (X, Y) adaptatifs et configurables
7//! pour écrans TFT LCD ST7789V, s'appuyant sur la structure `Graphics`.
8//!
9//! ## Caractéristiques principales
10//!
11//! - **`#![no_std]` + `#![forbid(unsafe_code)]`** : Sûr et optimisé pour le bare-metal.
12//! - **Zéro allocation dynamique** : Buffers statiques uniquement (ring buffer).
13//! - **API async** : Basée sur Embassy pour des transferts SPI non bloquants.
14//! - **Rendu intelligent (Double Phase)** : Le cadre, les étiquettes et les titres sont tracés 
15//!   une seule fois à l'initialisation. Seuls la grille interne et le signal sont rafraîchis dynamiquement.
16//! - **Axes configurables** : Graduations statiques avec labels personnalisés et détection automatique / coloration du zéro.
17//! - **Historique circulaire** : Jusqu'à 240 points (limité par la largeur physique de l'écran).
18//! - **Protection stricte des bordures** : Clamping des primitives géométriques à l'espace interne utile pour éviter tout débordement sur les axes.
19//!
20//! ## Structures principales
21//!
22//! - [`Graphics`] : Contexte graphique pour les primitives de dessin.
23//! - [`AxisConfig`] : Configuration d'un axe (min, max, pas de graduation, label).
24//! - [`PlotConfig`] : Configuration complète du graphique (position, marges, couleurs).
25//! - [`LineChart`] : Gestionnaire du graphique avec données historiques.
26//!
27//! ## Exemple d'utilisation
28//!
29//! ```no_run
30//! # use embassy_st7789v::{Color, St7789v, NoPin};
31//! # use embedded_hal::digital::OutputPin;
32//! # use embedded_hal_async::spi::SpiDevice;
33//! use embassy_st7789v_plot::{Graphics, AxisConfig, PlotConfig, LineChart};
34//!
35//! # async fn example<SPI, DC>(display: &mut St7789v<SPI, DC, NoPin>)
36//! # where
37//! #     SPI: SpiDevice,
38//! #     DC: OutputPin,
39//! # {
40//! // Créer les configurations d'axes
41//! let x_axis = AxisConfig::new(0.0, 10.0, 2.0, b"Time (s)");
42//! let y_axis = AxisConfig::new(-50.0, 50.0, 20.0, b"Volt (mV)");
43//!
44//! // Créer la configuration complète du graphique
45//! let config = PlotConfig {
46//!     x: 10,
47//!     y: 10,
48//!     width: 220,
49//!     height: 200,
50//!     margin_left: 30,
51//!     margin_right: 10,
52//!     margin_top: 10,
53//!     margin_bottom: 30,
54//!     x_axis,
55//!     y_axis,
56//!     bg_color: Color::BLACK,
57//!     line_color: Color::GREEN,
58//!     axis_color: Color::WHITE,
59//!     grid_color: Color::from_rgb(64, 64, 64),
60//!     text_color: Color::WHITE,
61//!     label_color: Color::CYAN,
62//! };
63//!
64//! // Créer le gestionnaire de graphique (N = 100 points max)
65//! let mut chart: LineChart<100> = LineChart::new(config);
66//!
67//! // Ajouter des données
68//! chart.push(12.4);
69//! chart.push(-5.2);
70//! chart.push(22.1);
71//!
72//! // Rendu asynchrone sur l'affichage (mise à jour incrémentale de la zone interne)
73//! let mut gfx = Graphics::new_no_rst(display);
74//! chart.render(&mut gfx).await;
75//! # }
76//! ```
77
78use embassy_st7789v::{Color, NoPin, St7789v, SCREEN_H, SCREEN_W};
79use embedded_hal::digital::OutputPin;
80use embedded_hal_async::spi::SpiDevice;
81
82/// Taille maximale de l'historique des données du graphique.
83pub const PLOT_HISTORY_LIMIT: usize = 240;
84
85// ─────────────────────────────────────────────────────────────────────────────
86// Contexte graphique embarqué (repris de tes primitives)
87// ─────────────────────────────────────────────────────────────────────────────
88
89pub struct Graphics<'a, SPI, DC, RST = NoPin>
90where
91    SPI: SpiDevice,
92    DC: OutputPin,
93    RST: OutputPin,
94{
95    /// Référence vers l'affichage ST7789V
96    pub display: &'a mut St7789v<SPI, DC, RST>,
97}
98
99impl<'a, SPI, DC> Graphics<'a, SPI, DC, NoPin>
100where
101    SPI: SpiDevice,
102    DC: OutputPin,
103{
104    /// Crée un nouveau contexte graphique sans broche RST.
105    ///
106    /// # Arguments
107    ///
108    /// * `display` - Référence mutable vers l'affichage ST7789V
109    #[inline]
110    pub fn new_no_rst(display: &'a mut St7789v<SPI, DC, NoPin>) -> Self {
111        Self { display }
112    }
113}
114
115impl<'a, SPI, DC, RST> Graphics<'a, SPI, DC, RST>
116where
117    SPI: SpiDevice,
118    DC: OutputPin,
119    RST: OutputPin,
120{
121    /// Crée un nouveau contexte graphique avec broche RST.
122    ///
123    /// # Arguments
124    ///
125    /// * `display` - Référence mutable vers l'affichage ST7789V
126    #[inline]
127    pub fn new(display: &'a mut St7789v<SPI, DC, RST>) -> Self {
128        Self { display }
129    }
130
131    /// Trace un pixel à la position (x, y) avec la couleur donnée.
132    ///
133    /// Les coordonnées négatives ou en dehors de l'écran sont ignorées silencieusement.
134    ///
135    /// # Arguments
136    ///
137    /// * `x` - Coordonnée X (peut être négative ou hors écran)
138    /// * `y` - Coordonnée Y (peut être négative ou hors écran)
139    /// * `color` - Couleur du pixel
140 
141    #[inline(always)]
142    pub async fn pixel(&mut self, x: i32, y: i32, color: Color) {
143        if x >= 0 && y >= 0 && x < SCREEN_W as i32 && y < SCREEN_H as i32 {
144            let _ = self.display.draw_pixel(x as u16, y as u16, color).await;
145        }
146    }
147}
148
149/// Trace une ligne entre deux points en utilisant l'algorithme de Bresenham.
150///
151/// Cette fonction utilise l'algorithme de Bresenham pour tracer une ligne
152/// entre les points (x0, y0) et (x1, y1). Elle gère correctement les lignes
153/// en dehors de l'écran via la vérification de limites dans [`Graphics::pixel`].
154///
155/// # Arguments
156///
157/// * `gfx` - Contexte graphique
158/// * `x0` - Coordonnée X du point de départ
159/// * `y0` - Coordonnée Y du point de départ
160/// * `x1` - Coordonnée X du point d'arrivée
161/// * `y1` - Coordonnée Y du point d'arrivée
162/// * `color` - Couleur de la ligne
163pub async fn line<SPI, DC, RST>(
164    gfx: &mut Graphics<'_, SPI, DC, RST>,
165    mut x0: i32,
166    mut y0: i32,
167    x1: i32,
168    y1: i32,
169    color: Color,
170) where
171    SPI: SpiDevice,
172    DC: OutputPin,
173    RST: OutputPin,
174{
175    let dx = (x1 - x0).abs();
176    let sx = if x0 < x1 { 1 } else { -1 };
177    let dy = -(y1 - y0).abs();
178    let sy = if y0 < y1 { 1 } else { -1 };
179    let mut err = dx + dy;
180
181    loop {
182        gfx.pixel(x0, y0, color).await;
183        if x0 == x1 && y0 == y1 {
184            break;
185        }
186        let e2 = 2 * err;
187        if e2 >= dy {
188            err += dy;
189            x0 += sx;
190        }
191        if e2 <= dx {
192            err += dx;
193            y0 += sy;
194        }
195    }
196}
197
198// ─────────────────────────────────────────────────────────────────────────────
199// Configuration des Axes
200// ─────────────────────────────────────────────────────────────────────────────
201
202/// Définit un axe avec graduation statique fixe.
203///
204/// Cette structure configure un axe du graphique avec une plage de valeurs,
205/// un pas de graduation régulier et un label descriptif.
206///
207/// # Champs
208///
209/// * `start` - Valeur minimale de l'axe (ex: 0.0)
210/// * `end` - Valeur maximale de l'axe (ex: 10.0)
211/// * `step` - Espacement régulier entre graduations (ex: 1.0)
212/// * `label` - Label texte affiché le long de l'axe (ex: b"Temp (C)")
213#[derive(Clone, Copy, Debug)]
214pub struct AxisConfig {
215    pub start: f32,
216    pub end: f32,
217    pub step: f32,
218    pub label: &'static [u8],
219}
220
221impl AxisConfig {
222    /// Crée une nouvelle configuration d'axe.
223    ///
224    /// # Arguments
225    ///
226    /// * `start` - Valeur minimale (doit être < `end`)
227    /// * `end` - Valeur maximale (doit être > `start`)
228    /// * `step` - Pas de graduation (doit être > 0)
229    /// * `label` - Label statique affiché (par exemple b"Temp (C)")
230    ///
231    /// # Panics
232    ///
233    /// Ne paniquera pas ici, mais utilisez [`is_valid`](Self::is_valid) après construction
234    /// pour vérifier la cohérence.
235    pub const fn new(start: f32, end: f32, step: f32, label: &'static [u8]) -> Self {
236        Self { start, end, step, label }
237    }
238
239    /// Vérifie la cohérence de la configuration.
240    ///
241    /// Retourne `true` si :
242    /// - `step` > 0.0
243    /// - `end` > `start`
244    ///
245
246    pub fn is_valid(&self) -> bool {
247        self.step > 0.0 && self.end > self.start
248    }
249
250    /// Calcule le nombre de graduations (incluant start et end).
251    ///
252    /// En `no_std`, le cast direct remplace `f32::floor()`.
253    ///
254    /// # Retour
255    ///
256    /// Nombre de ticks incluant les extrémités, ou 0 si la configuration est invalide.
257
258    pub fn tick_count(&self) -> usize {
259        if !self.is_valid() {
260            return 0;
261        }
262        let count = ((self.end - self.start) / self.step) as usize;
263        count + 1
264    }
265}
266
267// ─────────────────────────────────────────────────────────────────────────────
268// Configuration et Structure de Traçage
269// ─────────────────────────────────────────────────────────────────────────────
270
271/// Configuration complète du tracé graphique.
272///
273/// Cette structure définit tous les paramètres visuels et géométriques du graphique :
274/// position, taille, marges, axes, et palette de couleurs.
275///
276/// # Champs
277///
278/// * `x`, `y` - Position du coin haut-gauche du graphique (en pixels)
279/// * `width`, `height` - Dimensions du graphique (en pixels)
280/// * `margin_*` - Marges internes pour les axes et labels
281/// * `x_axis`, `y_axis` - Configurations des deux axes
282/// * `*_color` - Couleurs pour le fond, les lignes, axes, grille, texte, labels
283
284#[derive(Clone, Copy, Debug)]
285pub struct PlotConfig {
286    pub x: i32,
287    pub y: i32,
288    pub width: i32,
289    pub height: i32,
290    pub margin_left: i32,
291    pub margin_right: i32,
292    pub margin_top: i32,
293    pub margin_bottom: i32,
294    pub x_axis: AxisConfig,
295    pub y_axis: AxisConfig,
296    pub bg_color: Color,
297    pub line_color: Color,
298    pub axis_color: Color,
299    pub grid_color: Color,
300    pub text_color: Color,
301    pub label_color: Color,
302}
303
304/// Gestionnaire du graphique avec axes statiques configurables.
305///
306/// `LineChart<N>` maintient un historique circulaire de N points de données et
307/// gère le rendu du graphique avec grille, axes, graduations et labels.
308///
309/// # Paramètre générique
310///
311/// * `N` - Nombre maximum de points historiques (doit être ≤ `PLOT_HISTORY_LIMIT` = 240)
312///
313/// # Fonctionnement interne
314///
315/// - **Ring buffer** : Les données sont stockées dans un tableau fixe avec un pointeur
316///   `head` qui tourne. Quand le buffer est plein, les nouvelles données écrasent les
317///   plus anciennes.
318/// - **Historique** : Seuls les N points les plus récents sont affichés.
319/// - **Rendu** : Les données sont converties en pixels via `scale_x()` et `scale_y()`,
320///   puis connectées par des lignes (Bresenham).
321pub struct LineChart<const N: usize> {
322    config: PlotConfig,
323    data: [f32; N],
324    head: usize,
325    count: usize,
326    plot_x: i32,
327    plot_y: i32,
328    plot_w: i32,
329    plot_h: i32,
330    initialized: bool, // Ajout du flag pour suivre l'état du tracé
331}
332
333impl<const N: usize> LineChart<N> {
334    /// Crée un nouveau gestionnaire de graphique.
335    ///
336    /// # Arguments
337    ///
338    /// * `config` - Configuration complète du graphique
339    ///
340    /// # Panics
341    ///
342    /// Panique si :
343    /// - N > `PLOT_HISTORY_LIMIT` (240)
344    /// - La configuration d'axe X est invalide
345    /// - La configuration d'axe Y est invalide
346
347    pub fn new(config: PlotConfig) -> Self {
348        assert!(N <= PLOT_HISTORY_LIMIT, "L'historique dépasse la limite physique horizontale.");
349        assert!(config.x_axis.is_valid(), "Configuration axe X invalide.");
350        assert!(config.y_axis.is_valid(), "Configuration axe Y invalide.");
351        
352        let plot_x = config.x + config.margin_left;
353        let plot_y = config.y + config.margin_top;
354        let plot_w = config.width - config.margin_left - config.margin_right;
355        let plot_h = config.height - config.margin_top - config.margin_bottom;
356
357        Self {
358            config,
359            data: [0.0; N],
360            head: 0,
361            count: 0,
362            plot_x,
363            plot_y,
364            plot_w,
365            plot_h,
366            initialized: false, // Initialisé à false par défaut
367        }
368    }
369
370    /// Retourne une référence à la configuration du graphique.
371    #[inline]
372    pub fn config(&self) -> &PlotConfig {
373        &self.config
374    }
375
376    /// Ajoute une nouvelle valeur à l'historique.
377    ///
378    /// Si le buffer est plein (N points), la valeur la plus ancienne est remplacée.
379    ///
380    /// # Arguments
381    ///
382    /// * `value` - Valeur à ajouter (sera clampée à la plage Y-axis lors du rendu)
383
384    pub fn push(&mut self, value: f32) {
385        self.data[self.head] = value;
386        self.head = (self.head + 1) % N;
387        if self.count < N {
388            self.count += 1;
389        }
390    }
391
392    /// Efface l'historique et réinitialise le graphique.
393    pub fn clear(&mut self) {
394        self.head = 0;
395        self.count = 0;
396        self.data.fill(0.0);
397        self.initialized = false; // Permet de redessiner le cadre lors du prochain render
398    }
399
400    #[inline]
401    fn get_sample(&self, index: usize) -> f32 {
402        let oldest = if self.count < N { 0 } else { self.head };
403        self.data[(oldest + index) % N]
404    }
405
406    /// Convertit une valeur Y en coordonnée écran (pixels).
407    ///
408    /// La valeur est clampée à la plage [y_min, y_max] définie par `y_axis`,
409    /// puis convertie linéairement en pixels.
410    #[inline]
411    fn scale_y(&self, value: f32) -> i32 {
412        let y_min = self.config.y_axis.start;
413        let y_max = self.config.y_axis.end;
414        
415        if y_max <= y_min {
416            return self.plot_y + self.plot_h - 1;
417        }
418
419        let clamped_val = value.max(y_min).min(y_max);
420        let ratio = (clamped_val - y_min) / (y_max - y_min);
421        
422        self.plot_y + self.plot_h - 1 - (ratio * (self.plot_h - 1) as f32) as i32
423    }
424
425    /// Convertit un index de données en coordonnée X écran (pixels).
426    ///
427    /// Les N points sont distribués uniformément sur la largeur de la zone de tracé.
428    #[inline]
429    fn scale_x(&self, index: usize) -> i32 {
430        if N <= 1 {
431            return self.plot_x;
432        }
433        self.plot_x + (index as i32 * (self.plot_w - 1)) / (N as i32 - 1)
434    }
435
436    /// Convertit une valeur d'axe X en coordonnée écran (pour labels).
437    ///
438    /// Similaire à `scale_y`, mais pour l'axe X.
439    #[inline]
440    fn scale_x_value(&self, value: f32) -> i32 {
441        let x_min = self.config.x_axis.start;
442        let x_max = self.config.x_axis.end;
443        
444        if x_max <= x_min {
445            return self.plot_x;
446        }
447        
448        let clamped = value.max(x_min).min(x_max);
449        let ratio = (clamped - x_min) / (x_max - x_min);
450        self.plot_x + (ratio * (self.plot_w - 1) as f32) as i32
451    }
452
453    /// Affiche le graphique complet avec grille, axes, graduations et courbe.
454    ///
455    /// Cette méthode effectue :
456    /// 1. Remplissage du fond
457    /// 2. Grille horizontale (Y) et labels des graduations
458    /// 3. Grille verticale (X) et labels des graduations
459    /// 4. Labels des axes (titres)
460    /// 5. Bordures externes
461    /// 6. Tracé de la courbe (données)
462    ///
463    /// # Arguments
464    ///
465    /// * `gfx` - Contexte graphique initialisé
466    ///
467
468   pub async fn render<SPI, DC, RST>(&mut self, gfx: &mut Graphics<'_, SPI, DC, RST>)
469    where
470        SPI: SpiDevice,
471        DC: OutputPin,
472        RST: OutputPin,
473    {
474        let right_edge = self.plot_x + self.plot_w - 1;
475        let bottom_edge = self.plot_y + self.plot_h - 1;
476
477        //  1. TRACÉ UNIQUE DU CADRE ET DU TEXTE (Uniquement au premier passage) ──
478        if !self.initialized {
479            // Effacer l'intégralité de l'espace alloué au composant
480            let _ = gfx.display.fill_rect(
481                self.config.x as u16,
482                (self.config.y - 16).max(0) as u16,
483                (self.config.x + self.config.width) as u16,
484                (self.config.y + self.config.height + 16).min(SCREEN_H as i32) as u16,
485                self.config.bg_color,
486            ).await;
487
488            //Labels Y
489            let y_axis = &self.config.y_axis;
490            let y_range = y_axis.end - y_axis.start;
491            let tick_count_y = y_axis.tick_count();
492
493            for i in 0..tick_count_y {
494                let value = y_axis.start + (i as f32 * y_axis.step);
495                let ratio = (value - y_axis.start) / y_range;
496                let y_grid = bottom_edge - (ratio * (self.plot_h - 1) as f32) as i32;
497
498                // Couleur spéciale pour le vrai zéro
499                let label_color = if value.abs() < 0.001 {
500                    Color::GREEN
501                } else {
502                    self.config.text_color
503                };
504
505                let label_y = (y_grid - 4).max(self.config.y + 8).min(self.config.y + self.config.height - 8);
506
507                let _ = gfx.display.draw_f32(
508                    (self.config.x + 2) as u16,
509                    label_y as u16,
510                    value,
511                    1,
512                    label_color,
513                    self.config.bg_color,
514                ).await;
515            }
516
517            //Labels X
518            let x_axis = &self.config.x_axis;
519            let tick_count_x = x_axis.tick_count();
520
521            for i in 0..tick_count_x {
522                let value = x_axis.start + (i as f32 * x_axis.step);
523                let x_grid = self.scale_x_value(value);
524
525                let label_x = (x_grid - 8).max(self.config.x + 2).min(self.config.x + self.config.width - 20);
526
527                let _ = gfx.display.draw_f32(
528                    label_x as u16,
529                    (bottom_edge + 4) as u16,
530                    value,
531                    1,
532                    self.config.text_color,
533                    self.config.bg_color,
534                ).await;
535            }
536
537            // Titres des axes
538            let _ = gfx.display.draw_str(
539                (self.config.x + 2) as u16,
540                (self.config.y - 16).max(0) as u16,
541                y_axis.label,
542                self.config.label_color,
543                self.config.bg_color,
544            ).await;
545
546            let label_x_x = self.plot_x + (self.plot_w / 2) - ((x_axis.label.len() as i32 * 6) / 2);
547            let _ = gfx.display.draw_str(
548                label_x_x.max(self.config.x + 2) as u16,
549                (self.config.y + self.config.height + 4).min(SCREEN_H as i32 - 8) as u16,
550                x_axis.label,
551                self.config.label_color,
552                self.config.bg_color,
553            ).await;
554
555            // Bordures externes fixes
556            let _ = gfx.display.draw_hline(self.plot_x as u16, self.plot_y as u16, self.plot_w as u16, self.config.axis_color).await;
557            let _ = gfx.display.draw_hline(self.plot_x as u16, bottom_edge as u16, self.plot_w as u16, self.config.axis_color).await;
558            let _ = gfx.display.draw_vline(self.plot_x as u16, self.plot_y as u16, self.plot_h as u16, self.config.axis_color).await;
559            let _ = gfx.display.draw_vline(right_edge as u16, self.plot_y as u16, self.plot_h as u16, self.config.axis_color).await;
560
561            self.initialized = true;
562        }
563
564        //  2. NETTOYAGE DYNAMIQUE DE L'INTÉRIEUR STRICT (Sans toucher aux bordures) ──
565        let _ = gfx.display.fill_rect(
566            (self.plot_x + 1) as u16,
567            (self.plot_y + 1) as u16,
568            (right_edge - 1) as u16,
569            (bottom_edge - 1) as u16,
570            self.config.bg_color,
571        ).await;
572
573        // Grille horizontale (Y) interne
574        let y_axis = &self.config.y_axis;
575        let y_range = y_axis.end - y_axis.start;
576        let tick_count_y = y_axis.tick_count();
577        for i in 1..tick_count_y - 1 {
578            let value = y_axis.start + (i as f32 * y_axis.step);
579            let ratio = (value - y_axis.start) / y_range;
580            let y_grid = bottom_edge - (ratio * (self.plot_h - 1) as f32) as i32;
581
582            // Éviter de dessiner une grille si elle se superpose aux bordures de 1 pixel
583            if y_grid > self.plot_y && y_grid < bottom_edge {
584                // Si c'est la ligne du zéro, on peut optionnellement lui donner une couleur distinctive
585                let color = if value.abs() < 0.001 { Color::GREEN } else { self.config.grid_color };
586                
587                let _ = gfx.display.draw_hline(
588                    (self.plot_x + 1) as u16, 
589                    y_grid as u16, 
590                    (self.plot_w - 2) as u16, 
591                    color
592                ).await;
593            }
594        }
595
596        // Grille verticale (X) interne
597        let x_axis = &self.config.x_axis;
598        let tick_count_x = x_axis.tick_count();
599        for i in 1..tick_count_x - 1 {
600            let value = x_axis.start + (i as f32 * x_axis.step);
601            let x_grid = self.scale_x_value(value);
602
603            if x_grid > self.plot_x && x_grid < right_edge {
604                let _ = gfx.display.draw_vline(
605                    x_grid as u16,
606                    (self.plot_y + 1) as u16,
607                    (self.plot_h - 2) as u16,
608                    self.config.grid_color
609                ).await;
610            }
611        }
612
613        //  3. TRACÉ DE LA COURBE DES DONNÉES (Contrainte à l'intérieur strict) ──
614        if self.count < 2 {
615            if self.count == 1 {
616                let px = self.scale_x(0).max(self.plot_x + 1).min(right_edge - 1);
617                let py = self.scale_y(self.get_sample(0)).max(self.plot_y + 1).min(bottom_edge - 1);
618                gfx.pixel(px, py, self.config.line_color).await;
619            }
620            return;
621        }
622
623        // Récupération et clamping des points pour qu'ils ne bavent jamais sur la bordure blanche
624        let mut prev_x = self.scale_x(0).max(self.plot_x + 1).min(right_edge - 1);
625        let mut prev_y = self.scale_y(self.get_sample(0)).max(self.plot_y + 1).min(bottom_edge - 1);
626
627        for i in 1..self.count {
628            let next_x = self.scale_x(i).max(self.plot_x + 1).min(right_edge - 1);
629            let next_y = self.scale_y(self.get_sample(i)).max(self.plot_y + 1).min(bottom_edge - 1);
630
631            line(gfx, prev_x, prev_y, next_x, next_y, self.config.line_color).await;
632
633            prev_x = next_x;
634            prev_y = next_y;
635        }
636    }
637}