Skip to main content

tui_piechart/
radar.rs

1//! [`RadarChart`]: several series compared over shared axes (spider / web chart).
2
3#![allow(
4    clippy::cast_possible_truncation,
5    clippy::cast_precision_loss,
6    clippy::cast_sign_loss,
7    clippy::many_single_char_names,
8    clippy::too_many_lines,
9    clippy::cast_possible_wrap
10)]
11
12use ratatui::buffer::Buffer;
13use ratatui::layout::Rect;
14use ratatui::style::{Color, Modifier, Style};
15use ratatui::widgets::{Block, Widget};
16
17use crate::braille::{polar, Braille};
18use crate::util::{block_inner, palette, sane, text_width, truncate};
19
20/// One polygon of a [`RadarChart`].
21#[derive(Debug, Clone, PartialEq)]
22pub struct RadarSeries<'a> {
23    name: &'a str,
24    values: Vec<f64>,
25    color: Option<Color>,
26}
27
28impl<'a> RadarSeries<'a> {
29    /// A series with one value per axis.
30    #[must_use]
31    pub fn new(name: &'a str, values: Vec<f64>) -> Self {
32        Self {
33            name,
34            values,
35            color: None,
36        }
37    }
38
39    /// Fixes the series color (default: the palette, by position).
40    #[must_use]
41    pub const fn color(mut self, color: Color) -> Self {
42        self.color = Some(color);
43        self
44    }
45}
46
47/// A radar (spider) chart.
48///
49/// # Examples
50///
51/// ```
52/// use tui_piechart::{RadarChart, RadarSeries};
53///
54/// let chart = RadarChart::new(
55///     vec!["Speed", "Power", "Range", "Armor", "Agility"],
56///     vec![
57///         RadarSeries::new("Scout", vec![9.0, 3.0, 7.0, 2.0, 8.0]),
58///         RadarSeries::new("Tank", vec![3.0, 8.0, 4.0, 9.0, 2.0]),
59///     ],
60/// );
61/// ```
62#[derive(Debug, Clone)]
63pub struct RadarChart<'a> {
64    axes: Vec<&'a str>,
65    series: Vec<RadarSeries<'a>>,
66    max: Option<f64>,
67    rings: usize,
68    legend: bool,
69    block: Option<Block<'a>>,
70    base: Style,
71}
72
73impl<'a> RadarChart<'a> {
74    /// Creates a chart from axis labels and series.
75    #[must_use]
76    pub fn new(axes: Vec<&'a str>, series: Vec<RadarSeries<'a>>) -> Self {
77        Self {
78            axes,
79            series,
80            max: None,
81            rings: 4,
82            legend: true,
83            block: None,
84            base: Style::default(),
85        }
86    }
87
88    /// Value at the outer ring (default: the largest value).
89    #[must_use]
90    pub fn max(mut self, max: f64) -> Self {
91        self.max = Some(max);
92        self
93    }
94
95    /// Number of grid rings (`1..=10`, default 4).
96    #[must_use]
97    pub fn rings(mut self, rings: usize) -> Self {
98        self.rings = rings.clamp(1, 10);
99        self
100    }
101
102    /// Shows or hides the series legend.
103    #[must_use]
104    pub const fn legend(mut self, show: bool) -> Self {
105        self.legend = show;
106        self
107    }
108
109    /// Wraps the chart in a block.
110    #[must_use]
111    pub fn block(mut self, block: Block<'a>) -> Self {
112        self.block = Some(block);
113        self
114    }
115
116    /// Base style of the whole area.
117    #[must_use]
118    pub const fn base_style(mut self, style: Style) -> Self {
119        self.base = style;
120        self
121    }
122
123    fn scale(&self) -> f64 {
124        let m = self.max.map_or(0.0, sane);
125        if m > 0.0 {
126            return m;
127        }
128        let auto = self
129            .series
130            .iter()
131            .flat_map(|s| s.values.iter())
132            .map(|v| sane(*v))
133            .fold(0.0, f64::max);
134        if auto > 0.0 {
135            auto
136        } else {
137            1.0
138        }
139    }
140}
141
142const GRID: Color = Color::DarkGray;
143
144impl Widget for RadarChart<'_> {
145    fn render(self, area: Rect, buf: &mut Buffer) {
146        buf.set_style(area, self.base);
147        let inner = block_inner(self.block.as_ref(), area, buf);
148        let n = self.axes.len();
149        if inner.is_empty() || n < 3 {
150            return;
151        }
152        let show_legend = self.legend && !self.series.is_empty() && inner.height >= 8;
153        let legend_rows = u16::from(show_legend);
154        let label_w = self
155            .axes
156            .iter()
157            .map(|a| text_width(a))
158            .max()
159            .unwrap_or(0)
160            .min(usize::from(inner.width) / 4) as u16;
161        let grid = Rect::new(
162            inner.x + label_w + 1,
163            inner.y + 1,
164            inner.width.saturating_sub(2 * (label_w + 1)),
165            inner.height.saturating_sub(2 + legend_rows),
166        );
167        if grid.is_empty() {
168            return;
169        }
170        let mut g = Braille::new(grid.width, grid.height);
171        let c = (g.dots_w() / 2.0, g.dots_h() / 2.0);
172        let r = c.0.min(c.1) - 1.0;
173        if r < 3.0 {
174            return;
175        }
176        let turn = |k: usize| k as f64 / n as f64;
177
178        // Grid rings and spokes.
179        for ring in 1..=self.rings {
180            let rr = r * ring as f64 / self.rings as f64;
181            for k in 0..n {
182                g.line(
183                    polar(c.0, c.1, rr, turn(k)),
184                    polar(c.0, c.1, rr, turn((k + 1) % n)),
185                    GRID,
186                    0,
187                );
188            }
189        }
190        for k in 0..n {
191            g.line(c, polar(c.0, c.1, r, turn(k)), GRID, 0);
192        }
193
194        // Series.
195        let scale = self.scale();
196        for (si, s) in self.series.iter().enumerate() {
197            let color = s.color.unwrap_or_else(|| palette(si));
198            let prio = 1 + si.min(200) as u8;
199            let pts: Vec<(f64, f64)> = (0..n)
200                .map(|k| {
201                    let v = (sane(s.values.get(k).copied().unwrap_or(0.0)) / scale).min(1.0);
202                    polar(c.0, c.1, r * v, turn(k))
203                })
204                .collect();
205            for k in 0..n {
206                g.line(pts[k], pts[(k + 1) % n], color, prio);
207            }
208            for &(x, y) in &pts {
209                for (dx, dy) in [(0.0, 0.0), (1.0, 0.0), (0.0, 1.0), (1.0, 1.0)] {
210                    g.plot(x + dx - 0.5, y + dy - 0.5, color, prio);
211                }
212            }
213        }
214        g.blit(grid, buf);
215
216        // Axis labels around the web.
217        let bold = Style::default().add_modifier(Modifier::BOLD);
218        for (k, label) in self.axes.iter().enumerate() {
219            let (dx, dy) = polar(c.0, c.1, r + 2.0, turn(k));
220            let cx = i32::from(grid.x) + (dx / 2.0).floor() as i32;
221            let cy = i32::from(grid.y) + (dy / 4.0).floor() as i32;
222            let text = truncate(label, usize::from(label_w.max(1)));
223            let w = text_width(&text) as i32;
224            let s = (turn(k) * std::f64::consts::TAU).sin();
225            let x = if s > 0.3 {
226                cx + 1
227            } else if s < -0.3 {
228                cx - w
229            } else {
230                cx - w / 2
231            };
232            let y = cy.clamp(
233                i32::from(inner.y),
234                i32::from(inner.bottom()) - 1 - i32::from(legend_rows),
235            );
236            let x = x.clamp(
237                i32::from(inner.x),
238                (i32::from(inner.right()) - w).max(i32::from(inner.x)),
239            );
240            buf.set_string(x as u16, y as u16, &text, bold);
241        }
242
243        if show_legend {
244            let y = inner.bottom() - 1;
245            let mut x = inner.x;
246            for (si, s) in self.series.iter().enumerate() {
247                let color = s.color.unwrap_or_else(|| palette(si));
248                let name = format!("■ {}  ", s.name);
249                let w = text_width(&name) as u16;
250                if x + w > inner.right() {
251                    break;
252                }
253                buf.set_string(x, y, &name, Style::default().fg(color));
254                x += w;
255            }
256        }
257    }
258}
259
260#[cfg(test)]
261mod tests {
262    #![allow(clippy::assert_is_empty, clippy::float_cmp)]
263    use super::*;
264
265    fn chart() -> RadarChart<'static> {
266        RadarChart::new(
267            vec!["Speed", "Power", "Range", "Armor", "Agility"],
268            vec![
269                RadarSeries::new("Scout", vec![9.0, 3.0, 7.0, 2.0, 8.0]).color(Color::Red),
270                RadarSeries::new("Tank", vec![3.0, 8.0, 4.0, 9.0, 2.0]).color(Color::Blue),
271            ],
272        )
273    }
274
275    fn draw(c: RadarChart<'_>, w: u16, h: u16) -> Buffer {
276        let a = Rect::new(0, 0, w, h);
277        let mut b = Buffer::empty(a);
278        c.render(a, &mut b);
279        b
280    }
281
282    fn text(b: &Buffer) -> String {
283        b.content()
284            .iter()
285            .map(ratatui::buffer::Cell::symbol)
286            .collect()
287    }
288
289    #[test]
290    fn renders_axes_series_and_legend() {
291        let b = draw(chart(), 60, 22);
292        let t = text(&b);
293        for l in [
294            "Speed", "Power", "Range", "Armor", "Agility", "Scout", "Tank",
295        ] {
296            assert!(t.contains(l), "missing {l}");
297        }
298        for col in [Color::Red, Color::Blue] {
299            assert!(b.content().iter().any(|c| c.fg == col && c.symbol() != " "));
300        }
301    }
302
303    #[test]
304    fn needs_three_axes() {
305        let b = draw(RadarChart::new(vec!["a", "b"], vec![]), 40, 20);
306        assert_eq!(text(&b).trim(), "");
307    }
308
309    #[test]
310    fn values_change_picture_and_max_scales() {
311        let a = draw(chart(), 60, 22);
312        let b = draw(chart().max(100.0), 60, 22);
313        assert_ne!(a, b);
314    }
315
316    #[test]
317    fn legend_toggle() {
318        assert!(!text(&draw(chart().legend(false), 60, 22)).contains("Scout"));
319    }
320
321    #[test]
322    fn robust() {
323        for (w, h) in [(0, 0), (1, 1), (6, 3), (20, 8), (80, 40)] {
324            for axes in [vec![], vec!["a"; 3], vec!["long axis label"; 12]] {
325                for series in [
326                    vec![],
327                    vec![RadarSeries::new("s", vec![])],
328                    vec![RadarSeries::new(
329                        "s",
330                        vec![f64::NAN, -1.0, f64::INFINITY, 5.0],
331                    )],
332                ] {
333                    let _ = draw(
334                        RadarChart::new(axes.clone(), series)
335                            .rings(99)
336                            .max(f64::NAN),
337                        w,
338                        h,
339                    );
340                }
341            }
342        }
343    }
344}