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}